Implement WP-0009-T04: Control Plane interactive UI on whynot-design
Builds the Control Plane's browser UI (login, dashboard, Phase registration, Development Credit entry/proposal/review, credential admin, audit log) as a FastAPI + Jinja2 app over the already-finished T03 backend, rather than from scratch — whynot-design's Lit web components are vendored as static assets (source commit 4b62cffc, v0.4.1), with lit itself resolved via an esm.sh CDN import map. Session auth re-checks the credential token against the database on every request rather than trusting the session cookie's cached rights, so a mid-session revocation takes effect immediately. 9 new Docker-gated HTTP-level tests via FastAPI's TestClient (no browser-automation tool available, so real rendering of the <wn-*> components was never visually verified). All four WP-0009 tasks are now done; workplan marked finished. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5fbae0df02
commit
c89b4aa4a5
24 changed files with 3486 additions and 3 deletions
|
|
@ -81,7 +81,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule**
|
|||
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — **finished**, all 9 tasks done (Postgres-backed registries/ledger/metrics/attestation/breach-record, ADR-0002 accepted, onboarding CLI, hosted conformance suite) |
|
||||
| [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — **finished**, all 4 tasks done. `trsl:policy:linear-longstop-v0` confirmed 2026-07-29 as the v1 norm for the first pilot cohort; `progress-paused-longstop-v1` named as the next iteration, not yet adopted |
|
||||
| [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout — active; T01–T04 done. `info-tech-canon` dry-run onboarding routine exercised end-to-end 2026-07-29. **Org-wide TRSL license adoption executed 2026-07-30** across ~90 `coulomb`-org repos (`history/260730-TRSL-OrgWideLicenseRollout.md`) — a license-text adoption, not a Phase declaration. T05 (real Phase go-live gate) remains `todo` by design; no Phase exists yet for any repo |
|
||||
| [TREV-WP-0009](workplans/TREV-WP-0009-target-revenue-control-plane.md) | Target Revenue Control Plane — interactive UI for the `binky` tenant, incl. interactive Development Credit entry creation (`specs/TargetRevenueControlPlaneConcept.md`) — active; T01, T02, and **T03 (backend rights enforcement + audit log + propose/review workflow, `src/target_revenue/control_plane.py`) done**; T04 (interactive UI) next |
|
||||
| [TREV-WP-0009](workplans/TREV-WP-0009-target-revenue-control-plane.md) | Target Revenue Control Plane — interactive UI for the `binky` tenant, incl. interactive Development Credit entry creation (`specs/TargetRevenueControlPlaneConcept.md`) — **finished**, all 4 tasks done. **T04 (interactive UI, `src/target_revenue/service/control_plane_app.py`) built on vendored `whynot-design` web components** rather than from scratch, per an explicit feasibility check |
|
||||
| [TREV-WP-0010](workplans/TREV-WP-0010-development-effort-calculator.md) | Development Effort Calculator — **finished**, all 3 tasks done. Applied to the three real pilot candidates (`history/260730-EffortCalculator-CandidateApplication.md`) — every calculator-derived Initial Target came out materially lower than the earlier hand-picked placeholders, two of three carrying explicit warnings recommending manual review |
|
||||
|
||||
Hub index: [`WORK-RECORDS.md`](WORK-RECORDS.md) · brief: [`.custodian-brief.md`](.custodian-brief.md)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ service = [
|
|||
"psycopg-pool>=3.1",
|
||||
"fastapi>=0.110",
|
||||
"uvicorn>=0.27",
|
||||
"jinja2>=3.1",
|
||||
"itsdangerous>=2.1",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
service-dev = [
|
||||
"target-revenue[service,dev]",
|
||||
|
|
|
|||
|
|
@ -218,6 +218,22 @@ def get_phase_manifest(conn: Connection, phase_id: str) -> dict[str, Any] | None
|
|||
return row[0] if row else None
|
||||
|
||||
|
||||
def list_phase_manifests_for_licensor(conn: Connection, licensor_id: str) -> list[dict[str, Any]]:
|
||||
"""All Phases registered by one Licensor tenant, most recently
|
||||
registered first — needed by any UI that wants to show "my Phases"
|
||||
(WP-0009-T04's Control Plane dashboard) rather than requiring a
|
||||
caller to already know every `phase_id` in advance."""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT manifest FROM phase_manifests
|
||||
WHERE licensor_id = %s
|
||||
ORDER BY registered_at DESC
|
||||
""",
|
||||
(licensor_id,),
|
||||
).fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
def register_extension(
|
||||
conn: Connection, licensor: Licensor, extension: dict[str, Any]
|
||||
) -> None:
|
||||
|
|
|
|||
395
src/target_revenue/service/control_plane_app.py
Normal file
395
src/target_revenue/service/control_plane_app.py
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"""Target Revenue Control Plane — interactive UI (WP-0009-T04).
|
||||
|
||||
An HTML front end over `target_revenue.control_plane`, itself a client
|
||||
layer over the hosted Trust Service (`registry.py`, `ledger.py`) — this
|
||||
module adds no new rights-enforcement or audit logic of its own, it only
|
||||
frames `control_plane.py`'s functions as browser-usable forms and pages
|
||||
(session-based sign-in, flash messages, whynot-design-styled templates).
|
||||
|
||||
Session model: the browser session stores only the raw credential token
|
||||
(`itsdangerous`-signed cookie, via Starlette's `SessionMiddleware` — never
|
||||
the resolved rights tier, which is re-checked against the database on
|
||||
every request so a mid-session revocation takes effect immediately).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from psycopg import Connection
|
||||
from psycopg_pool import ConnectionPool
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .. import control_plane, ledger, metrics, registry
|
||||
from . import keys
|
||||
|
||||
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates")
|
||||
|
||||
_DATABASE_URL_ENV = "TRF_DATABASE_URL"
|
||||
_SECRET_KEY_ENV = "TRF_CONTROL_PLANE_SECRET_KEY"
|
||||
|
||||
app = FastAPI(title="Target Revenue Control Plane", version="0.1.0")
|
||||
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
||||
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
|
||||
|
||||
_secret_key = os.environ.get(_SECRET_KEY_ENV)
|
||||
if not _secret_key:
|
||||
import secrets
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"{_SECRET_KEY_ENV} not set — generating an ephemeral session secret. "
|
||||
"Every restart invalidates existing sessions; do not use this in any "
|
||||
"real deployment.",
|
||||
stacklevel=1,
|
||||
)
|
||||
_secret_key = secrets.token_hex(32)
|
||||
app.add_middleware(SessionMiddleware, secret_key=_secret_key)
|
||||
|
||||
|
||||
def get_pool() -> ConnectionPool:
|
||||
if not hasattr(app.state, "pool"):
|
||||
dsn = os.environ.get(_DATABASE_URL_ENV)
|
||||
if not dsn:
|
||||
raise RuntimeError(f"{_DATABASE_URL_ENV} is not set")
|
||||
app.state.pool = ConnectionPool(dsn, min_size=1, max_size=5, open=True)
|
||||
return app.state.pool
|
||||
|
||||
|
||||
def get_signing_key():
|
||||
if not hasattr(app.state, "signing_key"):
|
||||
app.state.signing_key = keys.load_signing_key()
|
||||
return app.state.signing_key
|
||||
|
||||
|
||||
def get_connection():
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def get_session_licensor(
|
||||
request: Request, conn: Connection = Depends(get_connection)
|
||||
) -> registry.Licensor | None:
|
||||
"""The signed-in credential, re-authenticated against the database on
|
||||
every request (not trusted from the session cookie alone) so a
|
||||
revocation between requests takes effect immediately."""
|
||||
token = request.session.get("token")
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return registry.authenticate(conn, token)
|
||||
except registry.RegistrationError:
|
||||
request.session.clear()
|
||||
return None
|
||||
|
||||
|
||||
class _NotAuthenticated(Exception):
|
||||
"""Raised by `require_login` when there is no valid session — handled
|
||||
below by a dedicated exception handler so it never collides with (or
|
||||
overrides the default handling of) ordinary HTTPExceptions like a
|
||||
404 for an unknown Phase id."""
|
||||
|
||||
|
||||
def require_login(
|
||||
licensor: registry.Licensor | None = Depends(get_session_licensor),
|
||||
) -> registry.Licensor:
|
||||
if licensor is None:
|
||||
raise _NotAuthenticated()
|
||||
return licensor
|
||||
|
||||
|
||||
def _template_context(request: Request, licensor: registry.Licensor | None, **extra: Any) -> dict[str, Any]:
|
||||
context = {
|
||||
"request": request,
|
||||
"session_credential_label": licensor.credential_label if licensor else None,
|
||||
"session_rights": licensor.rights if licensor else None,
|
||||
"session_licensor_id": licensor.licensor_id if licensor else None,
|
||||
"flash": request.session.pop("flash", None),
|
||||
"flash_variant": request.session.pop("flash_variant", None),
|
||||
}
|
||||
context.update(extra)
|
||||
return context
|
||||
|
||||
|
||||
def _redirect(url: str, request: Request, flash: str | None = None, variant: str = "info") -> RedirectResponse:
|
||||
if flash:
|
||||
request.session["flash"] = flash
|
||||
request.session["flash_variant"] = variant
|
||||
return RedirectResponse(url=url, status_code=303)
|
||||
|
||||
|
||||
@app.exception_handler(_NotAuthenticated)
|
||||
async def _redirect_to_login(request: Request, exc: _NotAuthenticated):
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
# --- Auth --------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)):
|
||||
if licensor is not None:
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
return templates.TemplateResponse(request, "login.html", _template_context(request, None))
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login_submit(
|
||||
request: Request,
|
||||
token: str = Form(...),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
try:
|
||||
registry.authenticate(conn, token)
|
||||
except registry.RegistrationError:
|
||||
request.session["flash"] = "Invalid or revoked credential token."
|
||||
request.session["flash_variant"] = "danger"
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
request.session["token"] = token
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
# --- Dashboard -----------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def dashboard(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
phases = registry.list_phase_manifests_for_licensor(conn, licensor.licensor_id)
|
||||
return templates.TemplateResponse(
|
||||
request, "dashboard.html", _template_context(request, licensor, phases=phases)
|
||||
)
|
||||
|
||||
|
||||
# --- Phases ----------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/phases/new")
|
||||
def phase_new_form(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "operator"):
|
||||
return _redirect("/", request, "Operator rights required to register a Phase.", "danger")
|
||||
return templates.TemplateResponse(request, "phase_new.html", _template_context(request, licensor))
|
||||
|
||||
|
||||
@app.post("/phases/new")
|
||||
def phase_new_submit(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
phase_id: str = Form(...),
|
||||
milestone_release_name: str = Form(...),
|
||||
source_revision: str = Form(...),
|
||||
initial_target_amount: float = Form(...),
|
||||
currency: str = Form(...),
|
||||
future_license: str = Form(...),
|
||||
degeneration_policy: str = Form(...),
|
||||
longstop_at: str = Form(...),
|
||||
ledger_uri: str = Form(..., alias="ledger"),
|
||||
):
|
||||
manifest = {
|
||||
"framework": "TRF-0.1",
|
||||
"license": "TRSL-0.1",
|
||||
"phase": {
|
||||
"id": phase_id,
|
||||
"milestone_release": {"name": milestone_release_name, "source_revision": source_revision},
|
||||
"initial_target": {"amount": initial_target_amount, "currency": currency},
|
||||
"future_license": future_license,
|
||||
"degeneration_policy": degeneration_policy,
|
||||
"longstop_at": longstop_at,
|
||||
"ledger": ledger_uri,
|
||||
},
|
||||
}
|
||||
try:
|
||||
control_plane.register_phase(conn, licensor, manifest)
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect("/phases/new", request, str(exc), "danger")
|
||||
return _redirect(f"/phases/{phase_id}", request, f"Phase {phase_id} registered.", "success")
|
||||
|
||||
|
||||
@app.get("/phases/{phase_id}")
|
||||
def phase_detail(
|
||||
phase_id: str,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
manifest = registry.get_phase_manifest(conn, phase_id)
|
||||
if manifest is None:
|
||||
raise HTTPException(status_code=404, detail="phase not found")
|
||||
entries = ledger.get_ledger(conn, phase_id)
|
||||
computed_metrics = metrics.compute_metrics(manifest, entries, metrics.utcnow())
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"phase_detail.html",
|
||||
_template_context(request, licensor, manifest=manifest, ledger=entries, metrics=computed_metrics),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/phases/{phase_id}/ledger")
|
||||
def phase_ledger_submit(
|
||||
phase_id: str,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
entry_id: str = Form(...),
|
||||
amount: float = Form(...),
|
||||
currency: str = Form(...),
|
||||
recognized_at: str = Form(...),
|
||||
evidence_reference: str = Form(...),
|
||||
extension_id: str = Form(...),
|
||||
extension_version: str = Form(...),
|
||||
):
|
||||
entry_input = {
|
||||
"id": entry_id,
|
||||
"phase": phase_id,
|
||||
"type": "development-credit",
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"recognized_at": recognized_at,
|
||||
"evidence_reference": evidence_reference,
|
||||
"extension": {"id": extension_id, "version": extension_version},
|
||||
}
|
||||
try:
|
||||
if registry.has_right(licensor.rights, "operator"):
|
||||
control_plane.append_development_credit(conn, licensor, phase_id, entry_input, signing_key)
|
||||
flash = f"Entry {entry_id} appended."
|
||||
else:
|
||||
control_plane.propose_ledger_entry(conn, licensor, phase_id, entry_input)
|
||||
flash = f"Entry {entry_id} submitted for review."
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect(f"/phases/{phase_id}", request, str(exc), "danger")
|
||||
return _redirect(f"/phases/{phase_id}", request, flash, "success")
|
||||
|
||||
|
||||
# --- Proposals (Operator+) --------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/proposals")
|
||||
def proposals_list(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "operator"):
|
||||
return _redirect("/", request, "Operator rights required to review proposals.", "danger")
|
||||
proposals = control_plane.list_proposed_entries(conn)
|
||||
return templates.TemplateResponse(
|
||||
request, "proposals.html", _template_context(request, licensor, proposals=proposals)
|
||||
)
|
||||
|
||||
|
||||
@app.post("/proposals/{proposal_id}/approve")
|
||||
def proposal_approve(
|
||||
proposal_id: int,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
):
|
||||
try:
|
||||
control_plane.approve_proposed_entry(conn, licensor, proposal_id, signing_key)
|
||||
except control_plane.ControlPlaneError as exc:
|
||||
return _redirect("/proposals", request, str(exc), "danger")
|
||||
return _redirect("/proposals", request, f"Proposal {proposal_id} approved.", "success")
|
||||
|
||||
|
||||
@app.post("/proposals/{proposal_id}/reject")
|
||||
def proposal_reject(
|
||||
proposal_id: int,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
try:
|
||||
control_plane.reject_proposed_entry(conn, licensor, proposal_id)
|
||||
except control_plane.ControlPlaneError as exc:
|
||||
return _redirect("/proposals", request, str(exc), "danger")
|
||||
return _redirect("/proposals", request, f"Proposal {proposal_id} rejected.", "success")
|
||||
|
||||
|
||||
# --- Credentials (Admin) ----------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/admin/credentials")
|
||||
def admin_credentials_form(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "admin"):
|
||||
return _redirect("/", request, "Admin rights required.", "danger")
|
||||
return templates.TemplateResponse(request, "admin_credentials.html", _template_context(request, licensor))
|
||||
|
||||
|
||||
@app.post("/admin/credentials")
|
||||
def admin_credentials_issue(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
credential_label: str = Form(...),
|
||||
rights: str = Form(...),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "admin"):
|
||||
return _redirect("/", request, "Admin rights required.", "danger")
|
||||
try:
|
||||
new_credential = control_plane.issue_user_credential(
|
||||
conn, licensor, licensor.licensor_id, credential_label, rights
|
||||
)
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect("/admin/credentials", request, str(exc), "danger")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin_credentials.html",
|
||||
_template_context(
|
||||
request, licensor,
|
||||
new_token=new_credential.token, new_token_label=new_credential.credential_label,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/credentials/revoke")
|
||||
def admin_credentials_revoke(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
token: str = Form(...),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "admin"):
|
||||
return _redirect("/", request, "Admin rights required.", "danger")
|
||||
try:
|
||||
control_plane.revoke_user_credential(conn, licensor, token)
|
||||
except control_plane.ControlPlaneError as exc:
|
||||
return _redirect("/admin/credentials", request, str(exc), "danger")
|
||||
return _redirect("/admin/credentials", request, "Credential revoked.", "success")
|
||||
|
||||
|
||||
# --- Audit log ---------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/audit")
|
||||
def audit_log(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
log = control_plane.get_audit_log(conn)
|
||||
return templates.TemplateResponse(request, "audit.html", _template_context(request, licensor, log=log))
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Credentials — Target Revenue Control Plane{% endblock %}
|
||||
{% block content %}
|
||||
<wn-page-header>
|
||||
<span slot="title">Manage credentials (Admin)</span>
|
||||
</wn-page-header>
|
||||
|
||||
{% if new_token %}
|
||||
<wn-banner variant="warning">
|
||||
New credential issued for <strong>{{ new_token_label }}</strong>. Copy it now —
|
||||
it will not be shown again: <code>{{ new_token }}</code>
|
||||
</wn-banner>
|
||||
{% endif %}
|
||||
|
||||
<h3>Issue a new credential</h3>
|
||||
<form class="wn-form" method="post" action="/admin/credentials">
|
||||
<wn-field-row label="Credential label (human name)">
|
||||
<wn-input name="credential_label" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Rights">
|
||||
<wn-select name="rights">
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="contributor">contributor</option>
|
||||
<option value="operator">operator</option>
|
||||
<option value="admin">admin</option>
|
||||
</wn-select>
|
||||
</wn-field-row>
|
||||
<wn-button type="submit" variant="primary">Issue credential</wn-button>
|
||||
</form>
|
||||
|
||||
<h3>Revoke a credential</h3>
|
||||
<form class="wn-form" method="post" action="/admin/credentials/revoke">
|
||||
<wn-field-row label="Token to revoke">
|
||||
<wn-input name="token" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-button type="submit" variant="secondary">Revoke</wn-button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Audit log — Target Revenue Control Plane{% endblock %}
|
||||
{% block content %}
|
||||
<wn-page-header>
|
||||
<span slot="title">Control Plane audit log</span>
|
||||
</wn-page-header>
|
||||
<p style="color:#888;">
|
||||
This is the Control Plane's own record of who did what — independent of
|
||||
the Trust Service's signed records, which only ever attest that the
|
||||
<code>{{ session_licensor_id }}</code> tenant acted, never which
|
||||
individual human (see <code>specs/TargetRevenueControlPlaneConcept.md</code> §5).
|
||||
</p>
|
||||
<table class="wn-plain">
|
||||
<thead><tr><th>when</th><th>who</th><th>action</th><th>Phase</th><th>Trust Service record</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in log %}
|
||||
<tr>
|
||||
<td>{{ row.created_at }}</td>
|
||||
<td>{{ row.actor_credential_label }}</td>
|
||||
<td>{{ row.action }}</td>
|
||||
<td>{{ row.phase_id }}</td>
|
||||
<td>{{ row.trust_service_record_id }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
54
src/target_revenue/service/control_plane_templates/base.html
Normal file
54
src/target_revenue/service/control_plane_templates/base.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{% block title %}Target Revenue Control Plane{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/whynot-design/styles/colors_and_type.css">
|
||||
<link rel="stylesheet" href="/static/whynot-design/styles/components.css">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"lit": "https://esm.sh/lit@3.3.3"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/static/whynot-design/index.js"></script>
|
||||
<style>
|
||||
body { max-width: 960px; margin: 0 auto; padding: 1.5rem; }
|
||||
form.wn-form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 480px; }
|
||||
.wn-flash { margin: 1rem 0; }
|
||||
table.wn-plain { width: 100%; border-collapse: collapse; }
|
||||
table.wn-plain th, table.wn-plain td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #ddd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<wn-top-nav>
|
||||
<span slot="brand">Target Revenue Control Plane</span>
|
||||
</wn-top-nav>
|
||||
|
||||
{% if session_credential_label %}
|
||||
<p>
|
||||
Signed in as <strong>{{ session_credential_label }}</strong>
|
||||
({{ session_rights }}) for <strong>{{ session_licensor_id }}</strong>
|
||||
· <a href="/">Dashboard</a>
|
||||
{% if session_rights in ("operator", "admin") %}· <a href="/proposals">Proposals</a>{% endif %}
|
||||
{% if session_rights == "admin" %}· <a href="/admin/credentials">Credentials</a>{% endif %}
|
||||
· <a href="/audit">Audit log</a>
|
||||
· <form method="post" action="/logout" style="display:inline"><button type="submit">Sign out</button></form>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if flash %}
|
||||
<wn-banner class="wn-flash" variant="{{ flash_variant | default('info') }}">{{ flash }}</wn-banner>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<p style="margin-top:3rem;color:#888;font-size:0.85rem;">
|
||||
This is a dry-run/pilot tool. No real Phase is authorized to go live via
|
||||
this interface — see <code>workplans/TREV-WP-0008-governance-and-pilot-rollout.md</code> T05.
|
||||
Visual language vendored from <code>whynot-design</code> — see
|
||||
<code>static/whynot-design/VENDORED.md</code>.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — Target Revenue Control Plane{% endblock %}
|
||||
{% block content %}
|
||||
<wn-page-header>
|
||||
<span slot="title">Phases for {{ session_licensor_id }}</span>
|
||||
</wn-page-header>
|
||||
|
||||
{% if session_rights in ("operator", "admin") %}
|
||||
<p><a href="/phases/new">+ Register a new Phase</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% if phases %}
|
||||
<table class="wn-plain">
|
||||
<thead><tr><th>Phase</th><th>Milestone Release</th><th>Initial Target</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for phase in phases %}
|
||||
<tr>
|
||||
<td>{{ phase.phase.id }}</td>
|
||||
<td>{{ phase.phase.milestone_release.name }}</td>
|
||||
<td>{{ phase.phase.initial_target.amount }} {{ phase.phase.initial_target.currency }}</td>
|
||||
<td><a href="/phases/{{ phase.phase.id }}">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<wn-empty-state>No Phases registered yet for this tenant.</wn-empty-state>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Sign in — Target Revenue Control Plane{% endblock %}
|
||||
{% block content %}
|
||||
<wn-page-header>
|
||||
<span slot="title">Sign in</span>
|
||||
</wn-page-header>
|
||||
<p>Paste the credential token you were issued by an Admin.</p>
|
||||
<form class="wn-form" method="post" action="/login">
|
||||
<wn-field-row label="Credential token">
|
||||
<wn-input type="password" name="token" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-button type="submit" variant="primary">Sign in</wn-button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ manifest.phase.id }} — Target Revenue Control Plane{% endblock %}
|
||||
{% block content %}
|
||||
<wn-page-header>
|
||||
<span slot="title">{{ manifest.phase.milestone_release.name }}</span>
|
||||
</wn-page-header>
|
||||
<p><wn-tag>{{ manifest.phase.id }}</wn-tag></p>
|
||||
|
||||
<h3>Status</h3>
|
||||
<table class="wn-plain">
|
||||
<tr><td>Initial Target</td><td>{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}</td></tr>
|
||||
<tr><td>Cumulative Development Credit</td><td>{{ metrics.facts.cumulative_development_credit }}</td></tr>
|
||||
<tr><td>Cumulative Remission Credit</td><td>{{ metrics.facts.cumulative_remission_credit }}</td></tr>
|
||||
<tr><td>Outstanding Target</td><td>{{ metrics.facts.outstanding_target }}</td></tr>
|
||||
<tr><td>Target satisfaction</td><td>{{ metrics.calculations.target_satisfaction_percentage }}%</td></tr>
|
||||
<tr><td>Converted</td><td>{{ metrics.facts.is_converted }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Ledger ({{ ledger | length }} entries)</h3>
|
||||
{% if ledger %}
|
||||
<table class="wn-plain">
|
||||
<thead><tr><th>id</th><th>type</th><th>amount</th><th>recognized_at</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in ledger %}
|
||||
<tr><td>{{ entry.id }}</td><td>{{ entry.type }}</td><td>{{ entry.amount }}</td><td>{{ entry.recognized_at }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<wn-empty-state>No ledger entries yet.</wn-empty-state>
|
||||
{% endif %}
|
||||
|
||||
{% if session_rights in ("contributor", "operator", "admin") %}
|
||||
<h3>
|
||||
{% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %}
|
||||
</h3>
|
||||
<form class="wn-form" method="post" action="/phases/{{ manifest.phase.id }}/ledger">
|
||||
<wn-field-row label="Entry id (trsl:entry:...)">
|
||||
<wn-input name="entry_id" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Amount">
|
||||
<wn-input type="number" step="0.01" name="amount" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Currency">
|
||||
<wn-input name="currency" value="{{ manifest.phase.initial_target.currency }}" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Recognized at (ISO 8601)">
|
||||
<wn-input name="recognized_at" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Evidence reference">
|
||||
<wn-input name="evidence_reference" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Extension id (trsl:extension:...)">
|
||||
<wn-input name="extension_id" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Extension version">
|
||||
<wn-input name="extension_version" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-button type="submit" variant="primary">
|
||||
{% if session_rights == "contributor" %}Submit for review{% else %}Append{% endif %}
|
||||
</wn-button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Register a Phase — Target Revenue Control Plane{% endblock %}
|
||||
{% block content %}
|
||||
<wn-page-header>
|
||||
<span slot="title">Register a new Phase (Operator+)</span>
|
||||
</wn-page-header>
|
||||
<p style="color:#888;">
|
||||
This registers a real Phase Manifest against the hosted Trust Service.
|
||||
It does not itself authorize going live for a repo — see
|
||||
<code>workplans/TREV-WP-0008-governance-and-pilot-rollout.md</code> T05.
|
||||
</p>
|
||||
<form class="wn-form" method="post" action="/phases/new">
|
||||
<wn-field-row label="Phase id (trsl:phase:...)">
|
||||
<wn-input name="phase_id" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Milestone Release name">
|
||||
<wn-input name="milestone_release_name" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Source revision">
|
||||
<wn-input name="source_revision" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Initial Target amount">
|
||||
<wn-input type="number" step="0.01" name="initial_target_amount" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Currency (ISO 4217)">
|
||||
<wn-input name="currency" value="EUR" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Future License">
|
||||
<wn-select name="future_license">
|
||||
<option value="MIT">MIT</option>
|
||||
<option value="Apache-2.0">Apache-2.0</option>
|
||||
</wn-select>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Degeneration policy">
|
||||
<wn-input name="degeneration_policy" value="trsl:policy:linear-longstop-v0@1.0" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Longstop date (ISO 8601)">
|
||||
<wn-input name="longstop_at" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Ledger URI (relative path or URL)">
|
||||
<wn-input name="ledger" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-button type="submit" variant="primary">Register Phase</wn-button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Proposals — Target Revenue Control Plane{% endblock %}
|
||||
{% block content %}
|
||||
<wn-page-header>
|
||||
<span slot="title">Pending Development Credit proposals (Operator+)</span>
|
||||
</wn-page-header>
|
||||
{% if proposals %}
|
||||
<table class="wn-plain">
|
||||
<thead><tr><th>id</th><th>Phase</th><th>type</th><th>amount</th><th>proposed at</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in proposals %}
|
||||
<tr>
|
||||
<td>{{ p.id }}</td>
|
||||
<td>{{ p.phase_id }}</td>
|
||||
<td>{{ p.entry.type }}</td>
|
||||
<td>{{ p.entry.amount }}</td>
|
||||
<td>{{ p.created_at }}</td>
|
||||
<td>
|
||||
<form method="post" action="/proposals/{{ p.id }}/approve" style="display:inline">
|
||||
<wn-button type="submit" variant="primary">Approve</wn-button>
|
||||
</form>
|
||||
<form method="post" action="/proposals/{{ p.id }}/reject" style="display:inline">
|
||||
<wn-button type="submit" variant="secondary">Reject</wn-button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<wn-empty-state>No pending proposals.</wn-empty-state>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
27
src/target_revenue/service/static/whynot-design/VENDORED.md
Normal file
27
src/target_revenue/service/static/whynot-design/VENDORED.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Vendored from `whynot-design`
|
||||
|
||||
Source: local `~/whynot-design` clone, commit `4b62cffc86496d587ac8d48a8e199624bc4a5c1f`
|
||||
(2026-07-30), package version `0.4.1`.
|
||||
|
||||
Vendored (copied verbatim, no modifications): `src/elements/*.js`,
|
||||
`src/index.js`, `src/styles/*.css`. These files only import from each
|
||||
other by relative path plus the bare specifier `"lit"` — no other
|
||||
external dependency, confirmed before vendoring
|
||||
(`workplans/TREV-WP-0009-target-revenue-control-plane.md` T04).
|
||||
|
||||
**Not vendored:** Lit itself (a third-party runtime, resolved via an
|
||||
import map to a CDN — see `service/control_plane_templates/base.html` —
|
||||
rather than vendored alongside whynot-design's own code). This is a
|
||||
disclosed v0 trade-off: whynot-design's own code stays fully local and
|
||||
traceable to this exact commit, matching this framework's offline-first
|
||||
ethos for *its own* code, while the third-party JS runtime dependency
|
||||
does not. Vendoring Lit locally (with a matching import map for its own
|
||||
internal bare-specifier imports) is reasonable future work, not attempted
|
||||
here to keep T04's scope bounded.
|
||||
|
||||
**Update policy:** this is a manual vendor copy, not an npm-managed
|
||||
dependency — `whynot-design`'s own `CONSUMING.md` describes a
|
||||
lockfile-based drift-tracking mechanism (`.whynot-design.lock`,
|
||||
`ir/manifest.json`) for npm consumers; this repo does not use npm for its
|
||||
Python-based service, so re-vendoring means repeating this copy manually
|
||||
against a newer commit, updating the commit hash above.
|
||||
|
|
@ -0,0 +1,604 @@
|
|||
/* Auto-generated from src/styles/components.css by scripts/sync-shared-styles.mjs.
|
||||
* Do NOT edit by hand. Edit components.css and re-run the script.
|
||||
*/
|
||||
|
||||
export const SHARED_CSS = String.raw`/* ============================================================
|
||||
WhyNot Design System — Component Styles
|
||||
------------------------------------------------------------
|
||||
Utility classes that the Lit web components render to. These
|
||||
are also consumable directly from any HTML (no JS required)
|
||||
for the "Layer 1 only" use case — see MultiFrameworkSupport.md.
|
||||
============================================================ */
|
||||
|
||||
/* ====== Custom-element display defaults ======
|
||||
* For shadow-DOM components, the wn-* host has display: inline by default.
|
||||
* Set sensible defaults so layout works without the consumer specifying them.
|
||||
*/
|
||||
wn-eyebrow, wn-tag, wn-stage-dot, wn-phase-dot, wn-stamp, wn-icon,
|
||||
wn-search-input, wn-button { display: inline-block; }
|
||||
|
||||
wn-card, wn-modal, wn-top-nav, wn-sidebar, wn-page-header,
|
||||
wn-pipeline, wn-prototype-card, wn-field-row, wn-breadcrumb,
|
||||
wn-table, wn-banner, wn-empty-state,
|
||||
wn-input, wn-textarea, wn-select { display: block; }
|
||||
|
||||
wn-toast-region { display: block; }
|
||||
wn-toast { display: block; }
|
||||
|
||||
wn-sidebar-group, wn-sidebar-item { display: block; }
|
||||
wn-table-row, wn-table-cell { display: contents; }
|
||||
|
||||
/* host hidden state — needed because shadow-DOM components don't inherit
|
||||
* \`[hidden]\` semantics in light DOM. Lit's host attribute reflection
|
||||
* handles attributes, but \`hidden\` on the host itself should still work. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ====== Buttons ====== */
|
||||
.wn-btn {
|
||||
font: 500 13px var(--ff-sans);
|
||||
letter-spacing: -0.005em;
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--r-2);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
text-decoration: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wn-btn:hover { border-color: var(--ink); }
|
||||
.wn-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
.wn-btn:active { background: var(--bg-3); }
|
||||
.wn-btn[disabled], .wn-btn.is-disabled {
|
||||
color: var(--ink-5); border-color: var(--border); cursor: not-allowed; background: var(--paper);
|
||||
}
|
||||
|
||||
.wn-btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||
.wn-btn--primary:hover { background: var(--ink-2); border-color: var(--ink-2); }
|
||||
.wn-btn--primary:active { background: var(--ink); }
|
||||
.wn-btn--primary[disabled], .wn-btn--primary.is-disabled {
|
||||
background: var(--ink-5); border-color: var(--ink-5); color: var(--paper);
|
||||
}
|
||||
|
||||
.wn-btn--ghost { background: transparent; border-color: transparent; padding: 7px 10px; }
|
||||
.wn-btn--ghost:hover { background: var(--bg-3); border-color: transparent; }
|
||||
|
||||
.wn-btn--danger { background: var(--paper); color: var(--ink); border-color: var(--ink); }
|
||||
|
||||
.wn-btn--sm { padding: 5px 10px; font-size: 12px; }
|
||||
.wn-btn--lg { padding: 12px 20px; font-size: 14px; }
|
||||
|
||||
.wn-btn__icon { width: 14px; height: 14px; flex: none; }
|
||||
.wn-btn--lg .wn-btn__icon { width: 16px; height: 16px; }
|
||||
|
||||
/* ====== Eyebrows & labels ====== */
|
||||
.wn-eyebrow {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
display: inline-block;
|
||||
}
|
||||
.wn-eyebrow--strong { color: var(--fg-1); }
|
||||
|
||||
/* ====== Tags ====== */
|
||||
.wn-tag {
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-2);
|
||||
background: var(--paper);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wn-tag--active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||
.wn-tag--draft { background: var(--hi); color: var(--hi-ink); border-color: transparent; }
|
||||
|
||||
/* ====== Stage / Phase dots ====== */
|
||||
.wn-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.wn-dot__bullet { width: 8px; height: 8px; border-radius: 999px; background: var(--ink); flex: none; }
|
||||
|
||||
/* signal levels (S0–S4) */
|
||||
.wn-stage-dot--s0 .wn-dot__bullet { background: var(--status-raw); }
|
||||
.wn-stage-dot--s1 .wn-dot__bullet { background: var(--status-weak); }
|
||||
.wn-stage-dot--s2 .wn-dot__bullet { background: var(--status-medium); }
|
||||
.wn-stage-dot--s3 .wn-dot__bullet { background: var(--status-strong); }
|
||||
.wn-stage-dot--s4 .wn-dot__bullet { background: var(--status-commercial); }
|
||||
|
||||
/* phase states (todo / active / done / warn) — numbered phases, distinct from signal */
|
||||
.wn-phase-dot__bullet {
|
||||
width: 18px; height: 18px; border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--paper);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font: 500 10px/1 var(--ff-mono); color: var(--fg-3);
|
||||
flex: none;
|
||||
}
|
||||
.wn-phase-dot--todo .wn-phase-dot__bullet { border-color: var(--border-strong); color: var(--fg-3); background: var(--paper); }
|
||||
.wn-phase-dot--active .wn-phase-dot__bullet { border-color: var(--ink); color: var(--ink); background: var(--paper); box-shadow: 0 0 0 3px rgba(10,10,10,0.06); }
|
||||
.wn-phase-dot--done .wn-phase-dot__bullet { border-color: var(--ink); color: var(--paper); background: var(--ink); }
|
||||
.wn-phase-dot--warn .wn-phase-dot__bullet { border-color: var(--hi-2); color: var(--hi-ink); background: var(--hi); }
|
||||
|
||||
/* ====== Stamp ====== */
|
||||
.wn-stamp {
|
||||
display: inline-block;
|
||||
background: var(--hi);
|
||||
color: var(--hi-ink);
|
||||
padding: 5px 10px 3px;
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
|
||||
/* ====== Icon ====== */
|
||||
.wn-icon { stroke-width: 1.5; stroke: currentColor; fill: none; display: inline-block; vertical-align: middle; }
|
||||
.wn-icon--sm { width: 14px; height: 14px; }
|
||||
.wn-icon--md { width: 16px; height: 16px; }
|
||||
.wn-icon--lg { width: 20px; height: 20px; }
|
||||
.wn-icon--xl { width: 24px; height: 24px; }
|
||||
|
||||
/* ====== Card ====== */
|
||||
.wn-card {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
position: relative;
|
||||
}
|
||||
.wn-card--inset { background: var(--paper-2); border-color: var(--border); }
|
||||
.wn-card--recessed { background: var(--paper-3); }
|
||||
.wn-card--lg { padding: var(--sp-6); border-radius: var(--r-3); }
|
||||
.wn-card--sm { padding: var(--sp-4); gap: var(--sp-2); }
|
||||
.wn-card--clickable { cursor: pointer; transition: border-color 120ms ease; }
|
||||
.wn-card--clickable:hover { border-color: var(--ink); }
|
||||
.wn-card--clickable:hover::before {
|
||||
content: ""; position: absolute; left: -1px; top: -1px; bottom: -1px;
|
||||
width: 2px; background: var(--ink); border-radius: 2px 0 0 2px;
|
||||
}
|
||||
.wn-card__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); }
|
||||
.wn-card__title { font: 500 17px/1.35 var(--ff-sans); margin: 4px 0 8px; color: var(--fg-1); }
|
||||
.wn-card__foot {
|
||||
display: flex; justify-content: space-between; gap: var(--sp-3);
|
||||
padding-top: var(--sp-3); margin-top: 4px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ====== Field row (label + value, 3-col grid) ====== */
|
||||
.wn-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
gap: var(--sp-4) var(--sp-5);
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
align-items: baseline;
|
||||
}
|
||||
.wn-field-row:last-child { border-bottom: 0; }
|
||||
.wn-field-row__label {
|
||||
font: 500 11px/1.5 var(--ff-mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-field-row__value { font: 400 15px/1.55 var(--ff-sans); color: var(--fg-1); }
|
||||
.wn-field-row__aside { font: 400 12px var(--ff-mono); color: var(--fg-3); text-align: right; }
|
||||
.wn-field-row--stacked { grid-template-columns: 1fr; gap: 6px; }
|
||||
.wn-field-row--narrow { grid-template-columns: 120px 1fr; }
|
||||
|
||||
/* ====== Form inputs ====== */
|
||||
.wn-form-label {
|
||||
font: 500 11px/1 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wn-input, .wn-textarea, .wn-select {
|
||||
font: 400 14px var(--ff-sans);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-1);
|
||||
color: var(--fg-1);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color 120ms ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wn-input:hover, .wn-textarea:hover, .wn-select:hover { border-color: var(--border-strong); }
|
||||
.wn-input:focus, .wn-textarea:focus, .wn-select:focus { border-color: var(--ink); }
|
||||
.wn-input::placeholder, .wn-textarea::placeholder { color: var(--ink-5); }
|
||||
.wn-input[disabled], .wn-textarea[disabled], .wn-select[disabled] {
|
||||
background: var(--paper-2); color: var(--fg-3); cursor: not-allowed;
|
||||
}
|
||||
.wn-textarea { resize: vertical; min-height: 96px; font-family: var(--ff-sans); }
|
||||
.wn-select {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%235C5C5C' stroke-width='1.5'><path d='M1 1l4 4 4-4'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.wn-input--error, .wn-textarea--error, .wn-select--error {
|
||||
border-color: var(--ink); border-bottom-width: 2px;
|
||||
}
|
||||
.wn-form-help { font: 400 11px var(--ff-mono); color: var(--fg-3); margin-top: 6px; display: block; }
|
||||
.wn-form-error { font: 400 11px var(--ff-mono); color: var(--ink); margin-top: 6px; display: block; }
|
||||
|
||||
/* Search input — extracted from TopNav, also usable standalone */
|
||||
.wn-search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-1);
|
||||
background: var(--paper);
|
||||
color: var(--fg-3);
|
||||
font: 400 12px var(--ff-mono);
|
||||
min-width: 200px;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.wn-search:focus-within { border-color: var(--ink); }
|
||||
.wn-search input {
|
||||
border: 0; outline: 0; background: none; flex: 1;
|
||||
font: inherit; color: var(--fg-1); padding: 0;
|
||||
}
|
||||
.wn-search input::placeholder { color: var(--ink-5); }
|
||||
.wn-search__kbd {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ====== Breadcrumb ====== */
|
||||
.wn-breadcrumb {
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
gap: 6px;
|
||||
font: 400 12px/1.5 var(--ff-mono);
|
||||
color: var(--fg-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.wn-breadcrumb a {
|
||||
color: var(--fg-2);
|
||||
text-decoration: none;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.wn-breadcrumb a:hover { color: var(--fg-1); border-bottom-color: var(--border-strong); }
|
||||
.wn-breadcrumb__sep { color: var(--ink-5); user-select: none; }
|
||||
.wn-breadcrumb__current { color: var(--fg-1); }
|
||||
|
||||
/* ====== Modal / Dialog ====== */
|
||||
.wn-modal__backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(10, 10, 10, 0.40);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
.wn-modal__panel {
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-3);
|
||||
box-shadow: var(--shadow-3);
|
||||
max-width: 560px; width: 100%;
|
||||
max-height: calc(100vh - 64px);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wn-modal__head {
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4);
|
||||
}
|
||||
.wn-modal__title { font: 500 20px/1.25 var(--ff-sans); margin: 0; color: var(--fg-1); }
|
||||
.wn-modal__close {
|
||||
background: none; border: 0; cursor: pointer; padding: 4px;
|
||||
color: var(--fg-3); border-radius: var(--r-1);
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.wn-modal__close:hover { color: var(--fg-1); }
|
||||
.wn-modal__body {
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
font: 400 15px/1.6 var(--ff-sans);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.wn-modal__foot {
|
||||
padding: var(--sp-4) var(--sp-6) var(--sp-5);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ====== Table ======
|
||||
* Note: shadow-DOM-rendered rows can't be children of a real <table> (the
|
||||
* HTML table model rejects unknown elements between <table> and <tr>). The
|
||||
* <wn-table> component therefore renders a CSS-grid imitation. For real
|
||||
* <table> markup (Django QuerySet rendering, etc.) use these classes
|
||||
* directly on <table>/<tr>/<td> elements — see also the .wn-table--native
|
||||
* variant below.
|
||||
*/
|
||||
|
||||
/* CSS-grid imitation (default <wn-table>) */
|
||||
.wn-table {
|
||||
width: 100%;
|
||||
font-size: var(--fs-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wn-table__thead { border-bottom: 1px solid var(--border); }
|
||||
.wn-table__tbody { display: flex; flex-direction: column; }
|
||||
.wn-table__tr {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
align-items: baseline;
|
||||
}
|
||||
.wn-table__tr:last-child { border-bottom: 0; }
|
||||
.wn-table__tr--head { border-bottom: 0; padding: var(--sp-3) var(--sp-4); }
|
||||
.wn-table__th {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-table__td {
|
||||
color: var(--fg-1);
|
||||
line-height: 1.5;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.wn-table--compact .wn-table__tr { padding: var(--sp-2) var(--sp-3); }
|
||||
.wn-table__cell--mono { font-family: var(--ff-mono); color: var(--fg-2); font-size: 12px; }
|
||||
.wn-table__cell--meta { color: var(--fg-3); font: 400 12px var(--ff-mono); }
|
||||
.wn-table__cell--right { text-align: right; }
|
||||
|
||||
/* Native <table> variant — for Django QuerySet rendering etc. */
|
||||
.wn-table--native {
|
||||
border-collapse: collapse;
|
||||
display: table;
|
||||
}
|
||||
.wn-table--native thead th {
|
||||
text-align: left;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-table--native tbody td {
|
||||
padding: var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
vertical-align: top;
|
||||
color: var(--fg-1);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wn-table--native tbody tr:hover { background: var(--paper-2); }
|
||||
.wn-table--native tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ====== Banner / Toast (success / info / warn) ====== */
|
||||
.wn-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-2);
|
||||
font: 400 14px/1.5 var(--ff-sans);
|
||||
color: var(--fg-1);
|
||||
position: relative;
|
||||
}
|
||||
.wn-banner__icon { color: var(--fg-2); flex: none; padding-top: 2px; }
|
||||
.wn-banner__body { flex: 1; }
|
||||
.wn-banner__title {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.wn-banner__dismiss {
|
||||
background: none; border: 0; cursor: pointer;
|
||||
color: var(--fg-3); padding: 4px;
|
||||
}
|
||||
.wn-banner__dismiss:hover { color: var(--fg-1); }
|
||||
.wn-banner--success { border-left: 2px solid var(--ink); }
|
||||
.wn-banner--warn { border-left: 2px solid var(--hi-2); background: #FFFCEB; }
|
||||
.wn-banner--error { border-left: 2px solid var(--ink); background: var(--paper); }
|
||||
.wn-banner--info { border-left: 2px solid var(--border-strong); }
|
||||
|
||||
.wn-toast-region {
|
||||
position: fixed;
|
||||
bottom: var(--sp-5); right: var(--sp-5);
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
z-index: 200;
|
||||
max-width: 380px;
|
||||
}
|
||||
.wn-toast { box-shadow: var(--shadow-3); }
|
||||
|
||||
/* ====== Empty state ====== */
|
||||
.wn-empty {
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-7);
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: var(--sp-2);
|
||||
text-align: center;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-empty__icon { color: var(--fg-3); margin-bottom: var(--sp-2); }
|
||||
.wn-empty__title { font: 500 14px var(--ff-sans); color: var(--fg-2); margin: 0; }
|
||||
.wn-empty__body { font: 400 13px/1.5 var(--ff-sans); color: var(--fg-3); max-width: 40ch; margin: 0; }
|
||||
.wn-empty__cta { margin-top: var(--sp-2); }
|
||||
|
||||
/* ====== Top navigation ====== */
|
||||
.wn-topnav {
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center;
|
||||
gap: var(--sp-6);
|
||||
padding: 0 var(--sp-5);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.wn-topnav__brand { display: flex; align-items: center; gap: 10px; font: 500 14px var(--ff-sans); }
|
||||
.wn-topnav__brand img { width: 22px; height: 22px; }
|
||||
.wn-topnav__brand-slug { font-family: var(--ff-mono); font-size: 12px; color: var(--fg-3); letter-spacing: 0.04em; }
|
||||
.wn-topnav__links { display: flex; gap: 22px; }
|
||||
.wn-topnav__link {
|
||||
font: 500 13px var(--ff-sans);
|
||||
color: var(--fg-2);
|
||||
text-decoration: none;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.wn-topnav__link:hover { color: var(--fg-1); }
|
||||
.wn-topnav__link--active { color: var(--fg-1); border-bottom-color: var(--ink); }
|
||||
.wn-topnav__right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
|
||||
|
||||
/* ====== Sidebar ====== */
|
||||
.wn-sidebar {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
background: var(--paper-2);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: var(--sp-5) var(--sp-4);
|
||||
display: flex; flex-direction: column; gap: var(--sp-5);
|
||||
height: calc(100vh - 56px);
|
||||
position: sticky; top: 56px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wn-sidebar__group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.wn-sidebar__group-label { padding-left: 12px; }
|
||||
.wn-sidebar__item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
color: var(--fg-2);
|
||||
font: 500 13px var(--ff-sans);
|
||||
cursor: pointer; text-decoration: none;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.wn-sidebar__item:hover { color: var(--fg-1); }
|
||||
.wn-sidebar__item--active {
|
||||
color: var(--fg-1); background: var(--paper);
|
||||
box-shadow: 0 0 0 1px var(--border) inset;
|
||||
}
|
||||
.wn-sidebar__item--doc { font-family: var(--ff-mono); font-size: 12px; }
|
||||
.wn-sidebar__count { margin-left: auto; font: 400 11px var(--ff-mono); color: var(--fg-3); }
|
||||
.wn-sidebar__footer { margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--border); }
|
||||
.wn-sidebar__activation {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
|
||||
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.wn-sidebar__activation-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--hi-2); }
|
||||
|
||||
/* ====== Page header ====== */
|
||||
.wn-page-header {
|
||||
margin-bottom: var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.wn-page-header__row { display: flex; align-items: flex-end; gap: var(--sp-5); }
|
||||
.wn-page-header__title {
|
||||
font: 500 32px/1.15 var(--ff-sans);
|
||||
letter-spacing: -0.015em;
|
||||
margin: 0; flex: 1; color: var(--fg-1);
|
||||
}
|
||||
.wn-page-header__actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.wn-page-header__lede {
|
||||
font: 400 16px/1.55 var(--ff-sans);
|
||||
color: var(--fg-2);
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* ====== Pipeline ====== */
|
||||
.wn-pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
margin: 0 0 var(--sp-6);
|
||||
}
|
||||
.wn-pipeline__stage {
|
||||
padding: 10px 12px 14px;
|
||||
border-top: 2px solid var(--border);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
.wn-pipeline__stage--done { border-top-color: var(--ink); }
|
||||
.wn-pipeline__stage--active { border-top-color: var(--hi-2); }
|
||||
.wn-pipeline__num {
|
||||
font: 500 10px/1 var(--ff-mono); letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-pipeline__stage--done .wn-pipeline__num,
|
||||
.wn-pipeline__stage--active .wn-pipeline__num { color: var(--fg-1); }
|
||||
.wn-pipeline__name { font: 500 14px/1.25 var(--ff-sans); color: var(--fg-1); }
|
||||
.wn-pipeline__stage--pending .wn-pipeline__name { color: var(--fg-3); }
|
||||
.wn-pipeline__meta { font: 400 11px/1.35 var(--ff-mono); color: var(--fg-3); }
|
||||
.wn-pipeline__arrow {
|
||||
position: absolute; top: -8px; right: -7px;
|
||||
font: 400 14px var(--ff-mono); color: var(--ink-5);
|
||||
}
|
||||
.wn-pipeline__stage--done .wn-pipeline__arrow,
|
||||
.wn-pipeline__stage--active .wn-pipeline__arrow { color: var(--ink); }
|
||||
|
||||
/* ====== Prototype card (combined card variant) ====== */
|
||||
.wn-prototype-card { /* extends .wn-card */ }
|
||||
.wn-prototype-card__qrow {
|
||||
display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px;
|
||||
font-size: 13px; color: var(--fg-1);
|
||||
}
|
||||
.wn-prototype-card__qkey {
|
||||
font: 500 11px/1.5 var(--ff-mono);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-prototype-card__qval { line-height: 1.45; }
|
||||
|
||||
/* ====== Layout helpers ====== */
|
||||
.wn-main { padding: 40px 48px 80px; max-width: 1180px; }
|
||||
.wn-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||
`;
|
||||
|
||||
let _sheet = null;
|
||||
export function getSharedSheet() {
|
||||
if (!_sheet) {
|
||||
_sheet = new CSSStyleSheet();
|
||||
_sheet.replaceSync(SHARED_CSS);
|
||||
}
|
||||
return _sheet;
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
/* =============================================================
|
||||
* @whynot/design — atoms.js
|
||||
* ------------------------------------------------------------
|
||||
* <wn-button>, <wn-tag>, <wn-eyebrow>, <wn-stamp>,
|
||||
* <wn-stage-dot>, <wn-phase-dot>, <wn-icon>
|
||||
*
|
||||
* Shadow-DOM components. Each adopts the shared component
|
||||
* stylesheet so utility classes inside the shadow root work.
|
||||
* Token CSS variables cascade through shadow boundaries
|
||||
* because they're inherited properties.
|
||||
* ============================================================= */
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { getSharedSheet } from "./_styles.js";
|
||||
import { ICON_PATHS } from "./icons.js";
|
||||
|
||||
class WnBase extends LitElement {
|
||||
static styles = [];
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Adopt the shared sheet on first connect, after super() has built the shadow root.
|
||||
const root = this.shadowRoot;
|
||||
if (root && !root.adoptedStyleSheets.includes(getSharedSheet())) {
|
||||
root.adoptedStyleSheets = [...root.adoptedStyleSheets, getSharedSheet()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-button> ---------- */
|
||||
export class WnButton extends WnBase {
|
||||
static properties = {
|
||||
variant: { type: String, reflect: true },
|
||||
size: { type: String, reflect: true },
|
||||
icon: { type: String },
|
||||
iconEnd: { type: String, attribute: "icon-end" },
|
||||
type: { type: String },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
href: { type: String },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.variant = "secondary";
|
||||
this.size = "md";
|
||||
this.type = "button";
|
||||
this.disabled = false;
|
||||
}
|
||||
render() {
|
||||
const cls = [
|
||||
"wn-btn",
|
||||
this.variant && this.variant !== "secondary" ? `wn-btn--${this.variant}` : "",
|
||||
this.size === "sm" ? "wn-btn--sm" : this.size === "lg" ? "wn-btn--lg" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const iconStart = this.icon
|
||||
? html`<wn-icon name=${this.icon} size="sm" class="wn-btn__icon"></wn-icon>`
|
||||
: nothing;
|
||||
const iconEnd = this.iconEnd
|
||||
? html`<wn-icon name=${this.iconEnd} size="sm" class="wn-btn__icon"></wn-icon>`
|
||||
: nothing;
|
||||
if (this.href) {
|
||||
return html`<a class=${cls} href=${this.href} part="button"
|
||||
aria-disabled=${this.disabled ? "true" : "false"}>${iconStart}<slot></slot>${iconEnd}</a>`;
|
||||
}
|
||||
return html`<button class=${cls} part="button"
|
||||
type=${this.type} ?disabled=${this.disabled}>${iconStart}<slot></slot>${iconEnd}</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-tag> ---------- */
|
||||
export class WnTag extends WnBase {
|
||||
static properties = {
|
||||
active: { type: Boolean, reflect: true },
|
||||
draft: { type: Boolean, reflect: true },
|
||||
};
|
||||
render() {
|
||||
const cls = ["wn-tag",
|
||||
this.active ? "wn-tag--active" : "",
|
||||
this.draft ? "wn-tag--draft" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return html`<span class=${cls} part="tag"><slot></slot></span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-eyebrow> ---------- */
|
||||
export class WnEyebrow extends WnBase {
|
||||
static properties = { strong: { type: Boolean, reflect: true } };
|
||||
render() {
|
||||
const cls = "wn-eyebrow" + (this.strong ? " wn-eyebrow--strong" : "");
|
||||
return html`<span class=${cls} part="eyebrow"><slot></slot></span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-stamp> ---------- */
|
||||
export class WnStamp extends WnBase {
|
||||
render() { return html`<span class="wn-stamp" part="stamp"><slot></slot></span>`; }
|
||||
}
|
||||
|
||||
/* ---------- <wn-stage-dot> ---------- */
|
||||
export class WnStageDot extends WnBase {
|
||||
static properties = {
|
||||
level: { type: String, reflect: true },
|
||||
label: { type: String },
|
||||
};
|
||||
constructor() { super(); this.level = "S2"; }
|
||||
render() {
|
||||
const lvl = String(this.level || "S2").toLowerCase();
|
||||
const cls = `wn-dot wn-stage-dot wn-stage-dot--${lvl}`;
|
||||
return html`
|
||||
<span class=${cls} part="root">
|
||||
<span class="wn-dot__bullet"></span>
|
||||
<slot>${this.label || this.level}</slot>
|
||||
</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-phase-dot> ---------- */
|
||||
export class WnPhaseDot extends WnBase {
|
||||
static properties = {
|
||||
state: { type: String, reflect: true },
|
||||
num: { type: String, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.state = "todo"; this.num = ""; }
|
||||
render() {
|
||||
const cls = `wn-phase-dot wn-phase-dot--${this.state}`;
|
||||
const glyph = this.state === "done" ? "✓" : this.num;
|
||||
return html`
|
||||
<span class=${cls} part="root">
|
||||
<span class="wn-phase-dot__bullet">${glyph}</span>
|
||||
<slot></slot>
|
||||
</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-icon> ---------- */
|
||||
export class WnIcon extends WnBase {
|
||||
static properties = {
|
||||
name: { type: String, reflect: true },
|
||||
size: { type: String, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.size = "md"; }
|
||||
render() {
|
||||
const path = ICON_PATHS[this.name];
|
||||
const cls = `wn-icon wn-icon--${this.size || "md"}`;
|
||||
if (!path) {
|
||||
return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true" part="svg">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor"/>
|
||||
</svg>`;
|
||||
}
|
||||
return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true" part="svg">${
|
||||
path.map(d => html`<path d=${d}></path>`)
|
||||
}</svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function defineAtoms() {
|
||||
if (!customElements.get("wn-button")) customElements.define("wn-button", WnButton);
|
||||
if (!customElements.get("wn-tag")) customElements.define("wn-tag", WnTag);
|
||||
if (!customElements.get("wn-eyebrow")) customElements.define("wn-eyebrow", WnEyebrow);
|
||||
if (!customElements.get("wn-stamp")) customElements.define("wn-stamp", WnStamp);
|
||||
if (!customElements.get("wn-stage-dot")) customElements.define("wn-stage-dot", WnStageDot);
|
||||
if (!customElements.get("wn-phase-dot")) customElements.define("wn-phase-dot", WnPhaseDot);
|
||||
if (!customElements.get("wn-icon")) customElements.define("wn-icon", WnIcon);
|
||||
}
|
||||
|
||||
export { WnBase };
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
/* =============================================================
|
||||
* @whynot/design — chrome.js
|
||||
* ------------------------------------------------------------
|
||||
* <wn-top-nav>, <wn-sidebar> / <wn-sidebar-group> /
|
||||
* <wn-sidebar-item>, <wn-page-header>, <wn-pipeline>,
|
||||
* <wn-prototype-card>
|
||||
* ============================================================= */
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { WnBase } from "./atoms.js";
|
||||
|
||||
/* ---------- <wn-top-nav> ---------- */
|
||||
export class WnTopNav extends WnBase {
|
||||
static properties = {
|
||||
logoSrc: { type: String, attribute: "logo-src" },
|
||||
brand: { type: String },
|
||||
slug: { type: String },
|
||||
};
|
||||
constructor() { super(); this.brand = "whynot"; this.slug = "control"; }
|
||||
render() {
|
||||
return html`
|
||||
<nav class="wn-topnav" part="nav">
|
||||
<div class="wn-topnav__brand">
|
||||
${this.logoSrc ? html`<img src=${this.logoSrc} alt="">` : nothing}
|
||||
<span>${this.brand}</span>
|
||||
${this.slug ? html`<span class="wn-topnav__brand-slug">/ ${this.slug}</span>` : nothing}
|
||||
</div>
|
||||
<div class="wn-topnav__links"><slot name="links"></slot></div>
|
||||
<div class="wn-topnav__right"><slot name="right"></slot></div>
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-sidebar> ---------- */
|
||||
export class WnSidebar extends WnBase {
|
||||
static properties = { activation: { type: String } };
|
||||
render() {
|
||||
return html`
|
||||
<aside class="wn-sidebar" part="sidebar">
|
||||
<slot></slot>
|
||||
${this.activation
|
||||
? html`<div class="wn-sidebar__footer">
|
||||
<div class="wn-sidebar__activation">
|
||||
<span class="wn-sidebar__activation-dot"></span>
|
||||
<span>${this.activation}</span>
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class WnSidebarGroup extends WnBase {
|
||||
static properties = { label: { type: String } };
|
||||
render() {
|
||||
return html`
|
||||
<div class="wn-sidebar__group" part="group">
|
||||
${this.label ? html`<wn-eyebrow class="wn-sidebar__group-label">${this.label}</wn-eyebrow>` : nothing}
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class WnSidebarItem extends WnBase {
|
||||
static properties = {
|
||||
href: { type: String },
|
||||
icon: { type: String },
|
||||
active: { type: Boolean, reflect: true },
|
||||
count: { type: String },
|
||||
variant: { type: String, reflect: true },
|
||||
};
|
||||
render() {
|
||||
const cls = ["wn-sidebar__item",
|
||||
this.active ? "wn-sidebar__item--active" : "",
|
||||
this.variant === "doc" ? "wn-sidebar__item--doc" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const inner = html`
|
||||
${this.icon ? html`<wn-icon name=${this.icon} size=${this.variant === "doc" ? "sm" : "md"}></wn-icon>` : nothing}
|
||||
<slot></slot>
|
||||
${this.count ? html`<span class="wn-sidebar__count">${this.count}</span>` : nothing}
|
||||
`;
|
||||
return this.href
|
||||
? html`<a class=${cls} href=${this.href} part="item">${inner}</a>`
|
||||
: html`<div class=${cls} part="item">${inner}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-page-header> ---------- */
|
||||
export class WnPageHeader extends WnBase {
|
||||
static properties = {
|
||||
eyebrow: { type: String },
|
||||
title: { type: String },
|
||||
lede: { type: String },
|
||||
hasActions: { state: true },
|
||||
};
|
||||
constructor() { super(); this.hasActions = false; }
|
||||
_onSlot() { this.hasActions = !!this.querySelector('[slot="actions"]'); }
|
||||
render() {
|
||||
return html`
|
||||
<header class="wn-page-header" part="root">
|
||||
${this.eyebrow ? html`<wn-eyebrow>${this.eyebrow}</wn-eyebrow>` : nothing}
|
||||
<slot name="eyebrow"></slot>
|
||||
<div class="wn-page-header__row">
|
||||
<h1 class="wn-page-header__title">${this.title}<slot name="title"></slot></h1>
|
||||
<div class="wn-page-header__actions" ?hidden=${!this.hasActions}>
|
||||
<slot name="actions" @slotchange=${this._onSlot}></slot>
|
||||
</div>
|
||||
</div>
|
||||
${this.lede ? html`<p class="wn-page-header__lede">${this.lede}</p>` : nothing}
|
||||
<slot name="lede"></slot>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-pipeline> ---------- */
|
||||
export class WnPipeline extends WnBase {
|
||||
static properties = {
|
||||
stages: { type: Array },
|
||||
activeIdx: { type: Number, attribute: "active-idx" },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.stages = [
|
||||
{ num: "Stage 0", name: "Raw idea", meta: "inbox/" },
|
||||
{ num: "Stage 1", name: "Triage", meta: "" },
|
||||
{ num: "Stage 2", name: "Prototype card", meta: "prototypes/" },
|
||||
{ num: "Stage 3", name: "Experiment", meta: "" },
|
||||
{ num: "Stage 4", name: "Signal review", meta: "" },
|
||||
];
|
||||
this.activeIdx = 0;
|
||||
}
|
||||
render() {
|
||||
return html`
|
||||
<div class="wn-pipeline" part="root">
|
||||
${this.stages.map((s, i) => {
|
||||
const state = i < this.activeIdx ? "done" : i === this.activeIdx ? "active" : "pending";
|
||||
const cls = `wn-pipeline__stage wn-pipeline__stage--${state}`;
|
||||
return html`
|
||||
<div class=${cls}>
|
||||
<span class="wn-pipeline__num">${s.num}</span>
|
||||
<span class="wn-pipeline__name">${s.name}</span>
|
||||
${s.meta ? html`<span class="wn-pipeline__meta">${s.meta}</span>` : nothing}
|
||||
${i > 0 ? html`<span class="wn-pipeline__arrow">→</span>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-prototype-card> ---------- */
|
||||
export class WnPrototypeCard extends WnBase {
|
||||
static properties = {
|
||||
cardId: { type: String, attribute: "card-id", reflect: true },
|
||||
signal: { type: String, reflect: true },
|
||||
stageLabel: { type: String, attribute: "stage-label" },
|
||||
href: { type: String },
|
||||
};
|
||||
constructor() { super(); this.signal = "S1"; }
|
||||
_onClick() {
|
||||
if (this.href) window.location.href = this.href;
|
||||
this.dispatchEvent(new CustomEvent("wn-open", { detail: { id: this.cardId }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
const clickable = !!this.href;
|
||||
const cls = "wn-card wn-prototype-card" + (clickable ? " wn-card--clickable" : "");
|
||||
return html`
|
||||
<article class=${cls}
|
||||
role=${clickable ? "button" : nothing}
|
||||
tabindex=${clickable ? "0" : nothing}
|
||||
@click=${clickable ? this._onClick : nothing}
|
||||
part="card">
|
||||
<header class="wn-card__head">
|
||||
<wn-eyebrow>${this.cardId ? this.cardId + " · " : ""}Prototype</wn-eyebrow>
|
||||
<wn-stage-dot level=${this.signal}>${this.stageLabel || this.signal}</wn-stage-dot>
|
||||
</header>
|
||||
<h3 class="wn-card__title"><slot name="pitch"></slot></h3>
|
||||
<div class="wn-prototype-card__qrow">
|
||||
<span class="wn-prototype-card__qkey">Learning q.</span>
|
||||
<span class="wn-prototype-card__qval"><slot name="learning"></slot></span>
|
||||
<span class="wn-prototype-card__qkey">Smallest test</span>
|
||||
<span class="wn-prototype-card__qval"><slot name="test"></slot></span>
|
||||
</div>
|
||||
<footer class="wn-card__foot">
|
||||
<span><slot name="target"></slot></span>
|
||||
<span>${this.signal} signal</span>
|
||||
</footer>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function defineChrome() {
|
||||
if (!customElements.get("wn-top-nav")) customElements.define("wn-top-nav", WnTopNav);
|
||||
if (!customElements.get("wn-sidebar")) customElements.define("wn-sidebar", WnSidebar);
|
||||
if (!customElements.get("wn-sidebar-group")) customElements.define("wn-sidebar-group", WnSidebarGroup);
|
||||
if (!customElements.get("wn-sidebar-item")) customElements.define("wn-sidebar-item", WnSidebarItem);
|
||||
if (!customElements.get("wn-page-header")) customElements.define("wn-page-header", WnPageHeader);
|
||||
if (!customElements.get("wn-pipeline")) customElements.define("wn-pipeline", WnPipeline);
|
||||
if (!customElements.get("wn-prototype-card")) customElements.define("wn-prototype-card", WnPrototypeCard);
|
||||
}
|
||||
205
src/target_revenue/service/static/whynot-design/elements/form.js
Normal file
205
src/target_revenue/service/static/whynot-design/elements/form.js
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/* =============================================================
|
||||
* @whynot/design — form.js
|
||||
* ------------------------------------------------------------
|
||||
* <wn-input>, <wn-textarea>, <wn-select>,
|
||||
* <wn-search-input>, <wn-field-row>
|
||||
*
|
||||
* Each wraps a real native element. Form participation works
|
||||
* because the native input is part of the light DOM via the
|
||||
* `name` attribute being copied through; for richer integration
|
||||
* use ElementInternals (deferred — see CHANGELOG).
|
||||
* ============================================================= */
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { WnBase } from "./atoms.js";
|
||||
|
||||
/* ---------- <wn-input> ---------- */
|
||||
export class WnInput extends WnBase {
|
||||
static properties = {
|
||||
name: { type: String, reflect: true },
|
||||
type: { type: String, reflect: true },
|
||||
value: { type: String },
|
||||
placeholder: { type: String },
|
||||
required: { type: Boolean, reflect: true },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
readonly: { type: Boolean, reflect: true },
|
||||
autocomplete:{ type: String },
|
||||
error: { type: Boolean, reflect: true },
|
||||
help: { type: String },
|
||||
errorText: { type: String, attribute: "error-text" },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.type = "text";
|
||||
this.value = "";
|
||||
this.required = false;
|
||||
this.disabled = false;
|
||||
this.readonly = false;
|
||||
this.error = false;
|
||||
}
|
||||
_onInput(e) {
|
||||
this.value = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
const cls = "wn-input" + (this.error ? " wn-input--error" : "");
|
||||
return html`
|
||||
<input class=${cls}
|
||||
part="input"
|
||||
name=${this.name ?? nothing}
|
||||
type=${this.type}
|
||||
.value=${this.value}
|
||||
placeholder=${this.placeholder ?? nothing}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
?readonly=${this.readonly}
|
||||
autocomplete=${this.autocomplete ?? nothing}
|
||||
@input=${this._onInput}>
|
||||
${this.error && this.errorText
|
||||
? html`<span class="wn-form-error">${this.errorText}</span>`
|
||||
: this.help
|
||||
? html`<span class="wn-form-help">${this.help}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-textarea> ---------- */
|
||||
export class WnTextarea extends WnBase {
|
||||
static properties = {
|
||||
name: { type: String, reflect: true },
|
||||
value: { type: String },
|
||||
placeholder: { type: String },
|
||||
rows: { type: Number },
|
||||
required: { type: Boolean, reflect: true },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
error: { type: Boolean, reflect: true },
|
||||
help: { type: String },
|
||||
errorText: { type: String, attribute: "error-text" },
|
||||
};
|
||||
constructor() { super(); this.value = ""; this.rows = 4; }
|
||||
_onInput(e) {
|
||||
this.value = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
const cls = "wn-textarea" + (this.error ? " wn-textarea--error" : "");
|
||||
return html`
|
||||
<textarea class=${cls}
|
||||
part="textarea"
|
||||
name=${this.name ?? nothing}
|
||||
rows=${this.rows}
|
||||
placeholder=${this.placeholder ?? nothing}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
@input=${this._onInput}
|
||||
.value=${this.value}></textarea>
|
||||
${this.error && this.errorText
|
||||
? html`<span class="wn-form-error">${this.errorText}</span>`
|
||||
: this.help
|
||||
? html`<span class="wn-form-help">${this.help}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-select> ----------
|
||||
* Slot <option> elements; they're cloned into the inner <select>. */
|
||||
export class WnSelect extends WnBase {
|
||||
static properties = {
|
||||
name: { type: String, reflect: true },
|
||||
value: { type: String },
|
||||
required: { type: Boolean, reflect: true },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
error: { type: Boolean, reflect: true },
|
||||
help: { type: String },
|
||||
};
|
||||
_onSlotChange(e) {
|
||||
const slot = e.target;
|
||||
const select = this.shadowRoot?.querySelector("select.wn-select");
|
||||
if (!select) return;
|
||||
const options = slot.assignedElements({ flatten: true }).filter(el => el.tagName === "OPTION");
|
||||
select.innerHTML = "";
|
||||
for (const opt of options) select.appendChild(opt.cloneNode(true));
|
||||
if (this.value != null) select.value = this.value;
|
||||
}
|
||||
_onChange(e) {
|
||||
this.value = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent("wn-change", { detail: { value: this.value }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
const cls = "wn-select" + (this.error ? " wn-select--error" : "");
|
||||
return html`
|
||||
<select class=${cls}
|
||||
part="select"
|
||||
name=${this.name ?? nothing}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
@change=${this._onChange}></select>
|
||||
<slot @slotchange=${this._onSlotChange} style="display:none"></slot>
|
||||
${this.help ? html`<span class="wn-form-help">${this.help}</span>` : nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-search-input> ---------- */
|
||||
export class WnSearchInput extends WnBase {
|
||||
static properties = {
|
||||
placeholder: { type: String },
|
||||
kbd: { type: String },
|
||||
value: { type: String },
|
||||
name: { type: String, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.placeholder = "Search…"; this.kbd = "⌘ K"; this.value = ""; }
|
||||
_onInput(e) {
|
||||
this.value = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
return html`
|
||||
<label class="wn-search" part="root">
|
||||
<wn-icon name="search" size="sm"></wn-icon>
|
||||
<input type="search"
|
||||
name=${this.name ?? nothing}
|
||||
.value=${this.value}
|
||||
placeholder=${this.placeholder}
|
||||
@input=${this._onInput}>
|
||||
${this.kbd ? html`<span class="wn-search__kbd">${this.kbd}</span>` : nothing}
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-field-row> ---------- */
|
||||
export class WnFieldRow extends WnBase {
|
||||
static properties = {
|
||||
label: { type: String },
|
||||
aside: { type: String },
|
||||
stacked: { type: Boolean, reflect: true },
|
||||
narrow: { type: Boolean, reflect: true },
|
||||
htmlFor: { type: String, attribute: "for" },
|
||||
};
|
||||
render() {
|
||||
const cls = ["wn-field-row",
|
||||
this.stacked ? "wn-field-row--stacked" : "",
|
||||
this.narrow ? "wn-field-row--narrow" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return html`
|
||||
<div class=${cls} part="root">
|
||||
<label class="wn-field-row__label" for=${this.htmlFor ?? nothing}>${this.label}</label>
|
||||
<div class="wn-field-row__value"><slot></slot></div>
|
||||
${this.aside
|
||||
? html`<div class="wn-field-row__aside">${this.aside}<slot name="aside"></slot></div>`
|
||||
: html`<div class="wn-field-row__aside"><slot name="aside"></slot></div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function defineForm() {
|
||||
if (!customElements.get("wn-input")) customElements.define("wn-input", WnInput);
|
||||
if (!customElements.get("wn-textarea")) customElements.define("wn-textarea", WnTextarea);
|
||||
if (!customElements.get("wn-select")) customElements.define("wn-select", WnSelect);
|
||||
if (!customElements.get("wn-search-input")) customElements.define("wn-search-input", WnSearchInput);
|
||||
if (!customElements.get("wn-field-row")) customElements.define("wn-field-row", WnFieldRow);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/* =============================================================
|
||||
* @whynot/design — icons.js
|
||||
* ------------------------------------------------------------
|
||||
* Inline Lucide-style icon paths (24×24, stroke 1.5, fill none).
|
||||
* Only the icons used by the system ship here; consumers can
|
||||
* extend by importing extras directly from `lucide`.
|
||||
*
|
||||
* Each value is an array of `d` attributes — multi-path icons
|
||||
* are rendered as multiple <path> elements.
|
||||
*
|
||||
* Paths derived from Lucide (ISC). If you need an icon not in
|
||||
* this list, add it here, not in a consuming repo.
|
||||
* ============================================================= */
|
||||
|
||||
/* prettier-ignore */
|
||||
export const ICON_PATHS = {
|
||||
/* Navigation */
|
||||
"inbox": ["M22 12h-6l-2 3h-4l-2-3H2", "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"],
|
||||
"lightbulb": ["M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5", "M9 18h6", "M10 22h4"],
|
||||
"flask-conical": ["M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2", "M6.453 15h11.094", "M8.5 2h7"],
|
||||
"activity": ["M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.5.5 0 0 1-.96 0L9.24 2.18a.5.5 0 0 0-.96 0l-2.35 8.36A2 2 0 0 1 4 12H2"],
|
||||
"users": ["M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", "M22 21v-2a4 4 0 0 0-3-3.87", "M16 3.13a4 4 0 0 1 0 7.75", "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"],
|
||||
"git-branch": ["M6 3v12", "M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M15 6a9 9 0 0 0-9 9"],
|
||||
"check-square": ["M9 11l3 3L22 4", "M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"],
|
||||
"archive": ["M21 8v13H3V8", "M1 3h22v5H1z", "M10 12h4"],
|
||||
"file-text": ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z", "M14 2v5h6", "M16 13H8", "M16 17H8", "M10 9H8"],
|
||||
"folder": ["M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"],
|
||||
|
||||
/* Actions / signals */
|
||||
"arrow-right": ["M5 12h14", "M12 5l7 7-7 7"],
|
||||
"arrow-left": ["M19 12H5", "M12 19l-7-7 7-7"],
|
||||
"plus": ["M12 5v14", "M5 12h14"],
|
||||
"x": ["M18 6L6 18", "M6 6l12 12"],
|
||||
"check": ["M20 6 9 17l-5-5"],
|
||||
"search": ["M21 21l-4.34-4.34", "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"],
|
||||
"filter": ["M22 3H2l8 9.46V19l4 2v-8.54L22 3z"],
|
||||
"circle-help": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3", "M12 17h.01"],
|
||||
"circle-alert": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M12 8v4", "M12 16h.01"],
|
||||
"circle-check": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M9 12l2 2 4-4"],
|
||||
"circle-info": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M12 16v-4", "M12 8h.01"],
|
||||
"settings": ["M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"],
|
||||
"more-horizontal": ["M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"],
|
||||
"chevron-down": ["M6 9l6 6 6-6"],
|
||||
"chevron-right": ["M9 18l6-6-6-6"],
|
||||
};
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
/* =============================================================
|
||||
* @whynot/design — layout.js
|
||||
* ------------------------------------------------------------
|
||||
* <wn-card>, <wn-modal>, <wn-table> / <wn-table-row> /
|
||||
* <wn-table-cell>, <wn-banner>, <wn-toast>, <wn-toast-region>,
|
||||
* <wn-empty-state>, <wn-breadcrumb>
|
||||
* ============================================================= */
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { WnBase } from "./atoms.js";
|
||||
|
||||
/* ---------- <wn-card> ---------- */
|
||||
export class WnCard extends WnBase {
|
||||
static properties = {
|
||||
variant: { type: String, reflect: true },
|
||||
size: { type: String, reflect: true },
|
||||
clickable: { type: Boolean, reflect: true },
|
||||
hasHeader: { state: true },
|
||||
hasFooter: { state: true },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.hasHeader = false;
|
||||
this.hasFooter = false;
|
||||
}
|
||||
_onSlotChange() {
|
||||
this.hasHeader = !!this.querySelector('[slot="header"]');
|
||||
this.hasFooter = !!this.querySelector('[slot="footer"]');
|
||||
}
|
||||
render() {
|
||||
const cls = ["wn-card",
|
||||
this.variant && this.variant !== "default" ? `wn-card--${this.variant}` : "",
|
||||
this.size === "sm" ? "wn-card--sm" : this.size === "lg" ? "wn-card--lg" : "",
|
||||
this.clickable ? "wn-card--clickable" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return html`
|
||||
<div class=${cls}
|
||||
role=${this.clickable ? "button" : nothing}
|
||||
tabindex=${this.clickable ? "0" : nothing}
|
||||
part="card">
|
||||
<header class="wn-card__head" ?hidden=${!this.hasHeader}>
|
||||
<slot name="header" @slotchange=${this._onSlotChange}></slot>
|
||||
</header>
|
||||
<slot @slotchange=${this._onSlotChange}></slot>
|
||||
<footer class="wn-card__foot" ?hidden=${!this.hasFooter}>
|
||||
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-modal> ---------- */
|
||||
export class WnModal extends WnBase {
|
||||
static properties = {
|
||||
open: { type: Boolean, reflect: true },
|
||||
title: { type: String },
|
||||
dismissible: { type: Boolean, reflect: true },
|
||||
hasFooter: { state: true },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.open = false;
|
||||
this.dismissible = true;
|
||||
this.hasFooter = false;
|
||||
}
|
||||
_onSlotChange() {
|
||||
this.hasFooter = !!this.querySelector('[slot="footer"]');
|
||||
}
|
||||
_onBackdrop(e) {
|
||||
if (e.target === e.currentTarget && this.dismissible) this._dismiss();
|
||||
}
|
||||
_dismiss() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
|
||||
}
|
||||
_bindKey = (e) => {
|
||||
if (e.key === "Escape" && this.dismissible && this.open) this._dismiss();
|
||||
};
|
||||
connectedCallback() { super.connectedCallback(); document.addEventListener("keydown", this._bindKey); }
|
||||
disconnectedCallback() { document.removeEventListener("keydown", this._bindKey); super.disconnectedCallback(); }
|
||||
|
||||
render() {
|
||||
if (!this.open) return nothing;
|
||||
return html`
|
||||
<div class="wn-modal__backdrop" @click=${this._onBackdrop} role="presentation" part="backdrop">
|
||||
<div class="wn-modal__panel" role="dialog" aria-modal="true" aria-label=${this.title ?? nothing} part="panel">
|
||||
<header class="wn-modal__head">
|
||||
<h2 class="wn-modal__title">${this.title}<slot name="title"></slot></h2>
|
||||
${this.dismissible
|
||||
? html`<button class="wn-modal__close" type="button" aria-label="Close" @click=${this._dismiss}>
|
||||
<wn-icon name="x" size="md"></wn-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
</header>
|
||||
<div class="wn-modal__body"><slot @slotchange=${this._onSlotChange}></slot></div>
|
||||
<footer class="wn-modal__foot" ?hidden=${!this.hasFooter}>
|
||||
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-table>, <wn-table-row>, <wn-table-cell> ----------
|
||||
* Tables in shadow DOM can't render real <table>/<tr> with slotted rows —
|
||||
* the table model requires the row to be a child of <table>. So these
|
||||
* components use CSS grid + flexbox to imitate a table visually. For real
|
||||
* <table> + Django QuerySet rendering, write raw <table class="wn-table">
|
||||
* markup directly using utility classes.
|
||||
*/
|
||||
export class WnTable extends WnBase {
|
||||
static properties = {
|
||||
columns: { type: Array },
|
||||
compact: { type: Boolean, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.columns = []; }
|
||||
render() {
|
||||
const cols = this.columns || [];
|
||||
const cls = "wn-table" + (this.compact ? " wn-table--compact" : "");
|
||||
return html`
|
||||
<div class=${cls} part="table" role="table">
|
||||
${cols.length
|
||||
? html`<div class="wn-table__thead" role="rowgroup">
|
||||
<div class="wn-table__tr wn-table__tr--head" role="row"
|
||||
style=${`grid-template-columns: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`}>
|
||||
${cols.map(c => html`<div class="wn-table__th" role="columnheader">${typeof c === "string" ? c : c.label}</div>`)}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="wn-table__tbody" role="rowgroup"
|
||||
style=${cols.length
|
||||
? `--wn-cols: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`
|
||||
: nothing}>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class WnTableRow extends WnBase {
|
||||
render() {
|
||||
return html`<div class="wn-table__tr" role="row" part="row"
|
||||
style="grid-template-columns: var(--wn-cols, repeat(auto-fit, minmax(80px, 1fr)));">
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export class WnTableCell extends WnBase {
|
||||
static properties = { variant: { type: String, reflect: true } };
|
||||
render() {
|
||||
const cls = "wn-table__td" + (this.variant ? ` wn-table__cell--${this.variant}` : "");
|
||||
return html`<div class=${cls} role="cell" part="cell"><slot></slot></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-banner> ---------- */
|
||||
export class WnBanner extends WnBase {
|
||||
static properties = {
|
||||
variant: { type: String, reflect: true },
|
||||
title: { type: String },
|
||||
icon: { type: String },
|
||||
dismissible: { type: Boolean, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.variant = "info"; }
|
||||
_dismiss() {
|
||||
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
|
||||
this.remove();
|
||||
}
|
||||
render() {
|
||||
const iconName = this.icon || ({
|
||||
info: "circle-info", success: "circle-check",
|
||||
warn: "circle-alert", error: "circle-alert",
|
||||
}[this.variant]);
|
||||
const cls = `wn-banner wn-banner--${this.variant}`;
|
||||
return html`
|
||||
<div class=${cls} role=${this.variant === "error" || this.variant === "warn" ? "alert" : "status"} part="banner">
|
||||
${iconName ? html`<span class="wn-banner__icon"><wn-icon name=${iconName} size="md"></wn-icon></span>` : nothing}
|
||||
<div class="wn-banner__body">
|
||||
${this.title ? html`<p class="wn-banner__title">${this.title}</p>` : nothing}
|
||||
<slot></slot>
|
||||
</div>
|
||||
${this.dismissible
|
||||
? html`<button class="wn-banner__dismiss" type="button" aria-label="Dismiss" @click=${this._dismiss}>
|
||||
<wn-icon name="x" size="sm"></wn-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-toast> / <wn-toast-region> ---------- */
|
||||
export class WnToast extends WnBanner {
|
||||
constructor() { super(); this.dismissible = true; }
|
||||
render() {
|
||||
const base = super.render();
|
||||
return html`<div class="wn-toast" part="toast">${base}</div>`;
|
||||
}
|
||||
}
|
||||
export class WnToastRegion extends WnBase {
|
||||
render() {
|
||||
return html`<div class="wn-toast-region" role="region" aria-label="Notifications" part="root">
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-empty-state> ---------- */
|
||||
export class WnEmptyState extends WnBase {
|
||||
static properties = {
|
||||
icon: { type: String },
|
||||
title: { type: String },
|
||||
hasCta: { state: true },
|
||||
};
|
||||
constructor() { super(); this.hasCta = false; }
|
||||
_onSlot() { this.hasCta = !!this.querySelector('[slot="cta"]'); }
|
||||
render() {
|
||||
return html`
|
||||
<div class="wn-empty" part="empty">
|
||||
${this.icon ? html`<wn-icon class="wn-empty__icon" name=${this.icon} size="lg"></wn-icon>` : nothing}
|
||||
${this.title ? html`<p class="wn-empty__title">${this.title}</p>` : nothing}
|
||||
<p class="wn-empty__body"><slot @slotchange=${this._onSlot}></slot></p>
|
||||
<div class="wn-empty__cta" ?hidden=${!this.hasCta}>
|
||||
<slot name="cta" @slotchange=${this._onSlot}></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-breadcrumb> ---------- */
|
||||
export class WnBreadcrumb extends WnBase {
|
||||
_onSlot(e) {
|
||||
const slot = e.target;
|
||||
// Separators are inserted into the LIGHT DOM (so they sit in document order
|
||||
// between the slotted items), which re-fires this slotchange. We must
|
||||
// therefore be idempotent: exclude our own separators when reading items,
|
||||
// and skip all mutation once the separators are already correct — otherwise
|
||||
// each insertion retriggers slotchange and the main thread loops forever.
|
||||
const items = slot.assignedElements({ flatten: true })
|
||||
.filter((el) => !el.classList.contains("wn-breadcrumb__sep"));
|
||||
const existing = [...this.querySelectorAll(":scope > .wn-breadcrumb__sep")];
|
||||
|
||||
if (existing.length === Math.max(0, items.length - 1)) {
|
||||
// Structure already correct — only refresh the "current" marker, do not
|
||||
// touch the child list (no mutation ⇒ no slotchange re-fire ⇒ loop ends).
|
||||
items.forEach((el, i) => el.classList.toggle("wn-breadcrumb__current", i === items.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
existing.forEach((s) => s.remove());
|
||||
items.forEach((el, i) => {
|
||||
el.classList.toggle("wn-breadcrumb__current", i === items.length - 1);
|
||||
if (i > 0) {
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "wn-breadcrumb__sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
sep.textContent = "/";
|
||||
el.parentNode.insertBefore(sep, el);
|
||||
}
|
||||
});
|
||||
}
|
||||
render() {
|
||||
return html`
|
||||
<nav class="wn-breadcrumb" aria-label="Breadcrumb" part="root">
|
||||
<span class="wn-breadcrumb__list"><slot @slotchange=${this._onSlot}></slot></span>
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function defineLayout() {
|
||||
if (!customElements.get("wn-card")) customElements.define("wn-card", WnCard);
|
||||
if (!customElements.get("wn-modal")) customElements.define("wn-modal", WnModal);
|
||||
if (!customElements.get("wn-table")) customElements.define("wn-table", WnTable);
|
||||
if (!customElements.get("wn-table-row")) customElements.define("wn-table-row", WnTableRow);
|
||||
if (!customElements.get("wn-table-cell")) customElements.define("wn-table-cell", WnTableCell);
|
||||
if (!customElements.get("wn-banner")) customElements.define("wn-banner", WnBanner);
|
||||
if (!customElements.get("wn-toast")) customElements.define("wn-toast", WnToast);
|
||||
if (!customElements.get("wn-toast-region")) customElements.define("wn-toast-region", WnToastRegion);
|
||||
if (!customElements.get("wn-empty-state")) customElements.define("wn-empty-state", WnEmptyState);
|
||||
if (!customElements.get("wn-breadcrumb")) customElements.define("wn-breadcrumb", WnBreadcrumb);
|
||||
}
|
||||
35
src/target_revenue/service/static/whynot-design/index.js
Normal file
35
src/target_revenue/service/static/whynot-design/index.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/* =============================================================
|
||||
* @whynot/design — entry point
|
||||
* ------------------------------------------------------------
|
||||
* Side-effect import that registers every <wn-*> custom element.
|
||||
*
|
||||
* import "@whynot/design";
|
||||
*
|
||||
* If you only need a subset, import the per-group files instead:
|
||||
*
|
||||
* import "@whynot/design/atoms";
|
||||
* import "@whynot/design/form";
|
||||
* import "@whynot/design/layout";
|
||||
* import "@whynot/design/chrome";
|
||||
*
|
||||
* CSS is imported separately:
|
||||
*
|
||||
* import "@whynot/design/styles/colors_and_type.css";
|
||||
* import "@whynot/design/styles/components.css";
|
||||
* ============================================================= */
|
||||
|
||||
import { defineAtoms } from "./elements/atoms.js";
|
||||
import { defineForm } from "./elements/form.js";
|
||||
import { defineLayout } from "./elements/layout.js";
|
||||
import { defineChrome } from "./elements/chrome.js";
|
||||
|
||||
defineAtoms();
|
||||
defineForm();
|
||||
defineLayout();
|
||||
defineChrome();
|
||||
|
||||
// Re-export classes for consumers that want to extend or reference them.
|
||||
export * from "./elements/atoms.js";
|
||||
export * from "./elements/form.js";
|
||||
export * from "./elements/layout.js";
|
||||
export * from "./elements/chrome.js";
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
/* ============================================================
|
||||
WhyNot Design System — Colors & Type
|
||||
------------------------------------------------------------
|
||||
Neutral, mostly black/white. Color is used SPARINGLY — only
|
||||
one warm accent (annotation yellow) borrowed from the LEGO
|
||||
brick in the logo. The system favours light grey wireframe
|
||||
artefacts over heavy fills.
|
||||
============================================================ */
|
||||
|
||||
/* No webfont is loaded. Every token font stack is system-ui based
|
||||
(ui-sans-serif / ui-monospace / ui-serif), so the design ships with zero
|
||||
network font dependency. (Historically this imported IBM Plex from Google
|
||||
Fonts; that webfont was unused and a source of CI flakiness, so it was
|
||||
dropped.) */
|
||||
|
||||
/* @generated tokens — regenerated by `make adapt-lit` from ir/tokens.json. DO NOT EDIT. */
|
||||
:root {
|
||||
/* color */
|
||||
--ink: #0A0A0A;
|
||||
--ink-2: #1F1F1F;
|
||||
--ink-3: #5C5C5C;
|
||||
--ink-4: #8A8A8A;
|
||||
--ink-5: #B5B5B3;
|
||||
--line: #E5E5E2;
|
||||
--line-strong: #C9C9C5;
|
||||
--line-soft: #F0F0EC;
|
||||
--paper: #FFFFFF;
|
||||
--paper-2: #FAFAF7;
|
||||
--paper-3: #F4F4EF;
|
||||
--fg-1: var(--ink);
|
||||
--fg-2: var(--ink-3);
|
||||
--fg-3: var(--ink-4);
|
||||
--fg-mute: var(--ink-5);
|
||||
--fg-on-dark: #FAFAF7;
|
||||
--bg-1: var(--paper);
|
||||
--bg-2: var(--paper-2);
|
||||
--bg-3: var(--paper-3);
|
||||
--bg-invert: var(--ink);
|
||||
--border: var(--line);
|
||||
--border-strong: var(--line-strong);
|
||||
--border-soft: var(--line-soft);
|
||||
--hi: #FFE14A;
|
||||
--hi-2: #FFD400;
|
||||
--hi-ink: #1A1500;
|
||||
--status-raw: #B5B5B3;
|
||||
--status-weak: #8A8A8A;
|
||||
--status-medium: #5C5C5C;
|
||||
--status-strong: #0A0A0A;
|
||||
--status-commercial: #FFD400;
|
||||
--status-error: #B33A2E;
|
||||
--status-error-bg: #FCF3F1;
|
||||
--status-warn: #C28000;
|
||||
--status-warn-bg: #FFFCEB;
|
||||
--status-success: #2F6B3A;
|
||||
--status-success-bg: #F2F7F2;
|
||||
--status-info: #2E5C8A;
|
||||
--status-info-bg: #F2F5FA;
|
||||
/* fontFamily */
|
||||
--ff-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--ff-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
--ff-serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
/* fontSize */
|
||||
--fs-xs: 11px;
|
||||
--fs-sm: 13px;
|
||||
--fs-base: 15px;
|
||||
--fs-md: 17px;
|
||||
--fs-lg: 20px;
|
||||
--fs-xl: 24px;
|
||||
--fs-2xl: 32px;
|
||||
--fs-3xl: 44px;
|
||||
--fs-4xl: 64px;
|
||||
--fs-5xl: 96px;
|
||||
/* lineHeight */
|
||||
--lh-tight: 1.05;
|
||||
--lh-snug: 1.25;
|
||||
--lh-base: 1.5;
|
||||
--lh-loose: 1.7;
|
||||
/* letterSpacing */
|
||||
--tr-tight: -0.02em;
|
||||
--tr-snug: -0.01em;
|
||||
--tr-base: 0em;
|
||||
--tr-mono: 0.02em;
|
||||
--tr-label: 0.08em;
|
||||
/* space */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 24px;
|
||||
--sp-6: 32px;
|
||||
--sp-7: 48px;
|
||||
--sp-8: 64px;
|
||||
--sp-9: 96px;
|
||||
--sp-10: 128px;
|
||||
/* radius */
|
||||
--r-0: 0px;
|
||||
--r-1: 2px;
|
||||
--r-2: 4px;
|
||||
--r-3: 8px;
|
||||
--r-pill: 999px;
|
||||
/* shadow */
|
||||
--shadow-0: none;
|
||||
--shadow-1: 0 1px 0 var(--line);
|
||||
--shadow-2: 0 1px 0 var(--line-strong);
|
||||
--shadow-3: 0 4px 12px -6px rgba(10,10,10,0.10);
|
||||
}
|
||||
/* @end generated tokens */
|
||||
|
||||
/* ============================================================
|
||||
Semantic element styles
|
||||
============================================================ */
|
||||
|
||||
html {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--fg-1);
|
||||
background: var(--bg-1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* ---------- Headings ---------- */
|
||||
h1, .h1 {
|
||||
font: 600 var(--fs-3xl)/var(--lh-tight) var(--ff-sans);
|
||||
letter-spacing: var(--tr-tight);
|
||||
margin: 0 0 var(--sp-5);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
h2, .h2 {
|
||||
font: 500 var(--fs-2xl)/var(--lh-snug) var(--ff-sans);
|
||||
letter-spacing: var(--tr-snug);
|
||||
margin: 0 0 var(--sp-4);
|
||||
}
|
||||
h3, .h3 {
|
||||
font: 500 var(--fs-xl)/var(--lh-snug) var(--ff-sans);
|
||||
letter-spacing: var(--tr-snug);
|
||||
margin: 0 0 var(--sp-3);
|
||||
}
|
||||
h4, .h4 {
|
||||
font: 500 var(--fs-lg)/var(--lh-snug) var(--ff-sans);
|
||||
margin: 0 0 var(--sp-2);
|
||||
}
|
||||
h5, .h5 {
|
||||
font: 500 var(--fs-md)/var(--lh-snug) var(--ff-sans);
|
||||
margin: 0 0 var(--sp-2);
|
||||
}
|
||||
|
||||
/* ---------- Display (for hero / title slides) ---------- */
|
||||
.display-1 {
|
||||
font: 300 var(--fs-5xl)/0.95 var(--ff-sans);
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.display-2 {
|
||||
font: 400 var(--fs-4xl)/1.0 var(--ff-sans);
|
||||
letter-spacing: var(--tr-tight);
|
||||
}
|
||||
|
||||
/* ---------- Body ---------- */
|
||||
p {
|
||||
margin: 0 0 var(--sp-4);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.lead {
|
||||
font-size: var(--fs-md);
|
||||
line-height: 1.55;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
small, .small {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
/* ---------- Eyebrow / uppercase labels (very common in this system) ---------- */
|
||||
.eyebrow,
|
||||
.label {
|
||||
font: 500 var(--fs-xs)/1.2 var(--ff-mono);
|
||||
letter-spacing: var(--tr-label);
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ---------- Code / mono ---------- */
|
||||
code, kbd, samp, pre, .mono {
|
||||
font-family: var(--ff-mono);
|
||||
font-size: 0.92em;
|
||||
letter-spacing: var(--tr-mono);
|
||||
}
|
||||
code {
|
||||
background: var(--bg-3);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r-1);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
pre {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--sp-4);
|
||||
overflow-x: auto;
|
||||
border-radius: var(--r-2);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: var(--lh-snug);
|
||||
}
|
||||
pre code { background: none; padding: 0; }
|
||||
|
||||
/* ---------- Editorial serif moments ---------- */
|
||||
.serif { font-family: var(--ff-serif); }
|
||||
.serif-quote {
|
||||
font: 400 italic var(--fs-xl)/1.4 var(--ff-serif);
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
/* ---------- Links ---------- */
|
||||
a {
|
||||
color: var(--fg-1);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border-strong);
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-thickness: 1px;
|
||||
transition: text-decoration-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration-color: var(--fg-1);
|
||||
}
|
||||
|
||||
/* ---------- HR ---------- */
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: var(--sp-5) 0;
|
||||
}
|
||||
|
||||
/* ---------- Highlighter (the one place yellow appears in body copy) ---------- */
|
||||
mark, .mark {
|
||||
background: var(--hi);
|
||||
color: var(--hi-ink);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* ---------- Tables (used in templates) ---------- */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th {
|
||||
font-weight: 500;
|
||||
color: var(--fg-2);
|
||||
font-family: var(--ff-mono);
|
||||
font-size: var(--fs-xs);
|
||||
letter-spacing: var(--tr-label);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---------- Selection ---------- */
|
||||
::selection { background: var(--hi); color: var(--hi-ink); }
|
||||
|
|
@ -0,0 +1,590 @@
|
|||
/* ============================================================
|
||||
WhyNot Design System — Component Styles
|
||||
------------------------------------------------------------
|
||||
Utility classes that the Lit web components render to. These
|
||||
are also consumable directly from any HTML (no JS required)
|
||||
for the "Layer 1 only" use case — see MultiFrameworkSupport.md.
|
||||
============================================================ */
|
||||
|
||||
/* ====== Custom-element display defaults ======
|
||||
* For shadow-DOM components, the wn-* host has display: inline by default.
|
||||
* Set sensible defaults so layout works without the consumer specifying them.
|
||||
*/
|
||||
wn-eyebrow, wn-tag, wn-stage-dot, wn-phase-dot, wn-stamp, wn-icon,
|
||||
wn-search-input, wn-button { display: inline-block; }
|
||||
|
||||
wn-card, wn-modal, wn-top-nav, wn-sidebar, wn-page-header,
|
||||
wn-pipeline, wn-prototype-card, wn-field-row, wn-breadcrumb,
|
||||
wn-table, wn-banner, wn-empty-state,
|
||||
wn-input, wn-textarea, wn-select { display: block; }
|
||||
|
||||
wn-toast-region { display: block; }
|
||||
wn-toast { display: block; }
|
||||
|
||||
wn-sidebar-group, wn-sidebar-item { display: block; }
|
||||
wn-table-row, wn-table-cell { display: contents; }
|
||||
|
||||
/* host hidden state — needed because shadow-DOM components don't inherit
|
||||
* `[hidden]` semantics in light DOM. Lit's host attribute reflection
|
||||
* handles attributes, but `hidden` on the host itself should still work. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ====== Buttons ====== */
|
||||
.wn-btn {
|
||||
font: 500 13px var(--ff-sans);
|
||||
letter-spacing: -0.005em;
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--r-2);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
text-decoration: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wn-btn:hover { border-color: var(--ink); }
|
||||
.wn-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
.wn-btn:active { background: var(--bg-3); }
|
||||
.wn-btn[disabled], .wn-btn.is-disabled {
|
||||
color: var(--ink-5); border-color: var(--border); cursor: not-allowed; background: var(--paper);
|
||||
}
|
||||
|
||||
.wn-btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||
.wn-btn--primary:hover { background: var(--ink-2); border-color: var(--ink-2); }
|
||||
.wn-btn--primary:active { background: var(--ink); }
|
||||
.wn-btn--primary[disabled], .wn-btn--primary.is-disabled {
|
||||
background: var(--ink-5); border-color: var(--ink-5); color: var(--paper);
|
||||
}
|
||||
|
||||
.wn-btn--ghost { background: transparent; border-color: transparent; padding: 7px 10px; }
|
||||
.wn-btn--ghost:hover { background: var(--bg-3); border-color: transparent; }
|
||||
|
||||
.wn-btn--danger { background: var(--paper); color: var(--ink); border-color: var(--ink); }
|
||||
|
||||
.wn-btn--sm { padding: 5px 10px; font-size: 12px; }
|
||||
.wn-btn--lg { padding: 12px 20px; font-size: 14px; }
|
||||
|
||||
.wn-btn__icon { width: 14px; height: 14px; flex: none; }
|
||||
.wn-btn--lg .wn-btn__icon { width: 16px; height: 16px; }
|
||||
|
||||
/* ====== Eyebrows & labels ====== */
|
||||
.wn-eyebrow {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
display: inline-block;
|
||||
}
|
||||
.wn-eyebrow--strong { color: var(--fg-1); }
|
||||
|
||||
/* ====== Tags ====== */
|
||||
.wn-tag {
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-2);
|
||||
background: var(--paper);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wn-tag--active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||
.wn-tag--draft { background: var(--hi); color: var(--hi-ink); border-color: transparent; }
|
||||
|
||||
/* ====== Stage / Phase dots ====== */
|
||||
.wn-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.wn-dot__bullet { width: 8px; height: 8px; border-radius: 999px; background: var(--ink); flex: none; }
|
||||
|
||||
/* signal levels (S0–S4) */
|
||||
.wn-stage-dot--s0 .wn-dot__bullet { background: var(--status-raw); }
|
||||
.wn-stage-dot--s1 .wn-dot__bullet { background: var(--status-weak); }
|
||||
.wn-stage-dot--s2 .wn-dot__bullet { background: var(--status-medium); }
|
||||
.wn-stage-dot--s3 .wn-dot__bullet { background: var(--status-strong); }
|
||||
.wn-stage-dot--s4 .wn-dot__bullet { background: var(--status-commercial); }
|
||||
|
||||
/* phase states (todo / active / done / warn) — numbered phases, distinct from signal */
|
||||
.wn-phase-dot__bullet {
|
||||
width: 18px; height: 18px; border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--paper);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font: 500 10px/1 var(--ff-mono); color: var(--fg-3);
|
||||
flex: none;
|
||||
}
|
||||
.wn-phase-dot--todo .wn-phase-dot__bullet { border-color: var(--border-strong); color: var(--fg-3); background: var(--paper); }
|
||||
.wn-phase-dot--active .wn-phase-dot__bullet { border-color: var(--ink); color: var(--ink); background: var(--paper); box-shadow: 0 0 0 3px rgba(10,10,10,0.06); }
|
||||
.wn-phase-dot--done .wn-phase-dot__bullet { border-color: var(--ink); color: var(--paper); background: var(--ink); }
|
||||
.wn-phase-dot--warn .wn-phase-dot__bullet { border-color: var(--hi-2); color: var(--hi-ink); background: var(--hi); }
|
||||
|
||||
/* ====== Stamp ====== */
|
||||
.wn-stamp {
|
||||
display: inline-block;
|
||||
background: var(--hi);
|
||||
color: var(--hi-ink);
|
||||
padding: 5px 10px 3px;
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
|
||||
/* ====== Icon ====== */
|
||||
.wn-icon { stroke-width: 1.5; stroke: currentColor; fill: none; display: inline-block; vertical-align: middle; }
|
||||
.wn-icon--sm { width: 14px; height: 14px; }
|
||||
.wn-icon--md { width: 16px; height: 16px; }
|
||||
.wn-icon--lg { width: 20px; height: 20px; }
|
||||
.wn-icon--xl { width: 24px; height: 24px; }
|
||||
|
||||
/* ====== Card ====== */
|
||||
.wn-card {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
position: relative;
|
||||
}
|
||||
.wn-card--inset { background: var(--paper-2); border-color: var(--border); }
|
||||
.wn-card--recessed { background: var(--paper-3); }
|
||||
.wn-card--lg { padding: var(--sp-6); border-radius: var(--r-3); }
|
||||
.wn-card--sm { padding: var(--sp-4); gap: var(--sp-2); }
|
||||
.wn-card--clickable { cursor: pointer; transition: border-color 120ms ease; }
|
||||
.wn-card--clickable:hover { border-color: var(--ink); }
|
||||
.wn-card--clickable:hover::before {
|
||||
content: ""; position: absolute; left: -1px; top: -1px; bottom: -1px;
|
||||
width: 2px; background: var(--ink); border-radius: 2px 0 0 2px;
|
||||
}
|
||||
.wn-card__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); }
|
||||
.wn-card__title { font: 500 17px/1.35 var(--ff-sans); margin: 4px 0 8px; color: var(--fg-1); }
|
||||
.wn-card__foot {
|
||||
display: flex; justify-content: space-between; gap: var(--sp-3);
|
||||
padding-top: var(--sp-3); margin-top: 4px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ====== Field row (label + value, 3-col grid) ====== */
|
||||
.wn-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
gap: var(--sp-4) var(--sp-5);
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
align-items: baseline;
|
||||
}
|
||||
.wn-field-row:last-child { border-bottom: 0; }
|
||||
.wn-field-row__label {
|
||||
font: 500 11px/1.5 var(--ff-mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-field-row__value { font: 400 15px/1.55 var(--ff-sans); color: var(--fg-1); }
|
||||
.wn-field-row__aside { font: 400 12px var(--ff-mono); color: var(--fg-3); text-align: right; }
|
||||
.wn-field-row--stacked { grid-template-columns: 1fr; gap: 6px; }
|
||||
.wn-field-row--narrow { grid-template-columns: 120px 1fr; }
|
||||
|
||||
/* ====== Form inputs ====== */
|
||||
.wn-form-label {
|
||||
font: 500 11px/1 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wn-input, .wn-textarea, .wn-select {
|
||||
font: 400 14px var(--ff-sans);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-1);
|
||||
color: var(--fg-1);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color 120ms ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wn-input:hover, .wn-textarea:hover, .wn-select:hover { border-color: var(--border-strong); }
|
||||
.wn-input:focus, .wn-textarea:focus, .wn-select:focus { border-color: var(--ink); }
|
||||
.wn-input::placeholder, .wn-textarea::placeholder { color: var(--ink-5); }
|
||||
.wn-input[disabled], .wn-textarea[disabled], .wn-select[disabled] {
|
||||
background: var(--paper-2); color: var(--fg-3); cursor: not-allowed;
|
||||
}
|
||||
.wn-textarea { resize: vertical; min-height: 96px; font-family: var(--ff-sans); }
|
||||
.wn-select {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%235C5C5C' stroke-width='1.5'><path d='M1 1l4 4 4-4'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.wn-input--error, .wn-textarea--error, .wn-select--error {
|
||||
border-color: var(--ink); border-bottom-width: 2px;
|
||||
}
|
||||
.wn-form-help { font: 400 11px var(--ff-mono); color: var(--fg-3); margin-top: 6px; display: block; }
|
||||
.wn-form-error { font: 400 11px var(--ff-mono); color: var(--ink); margin-top: 6px; display: block; }
|
||||
|
||||
/* Search input — extracted from TopNav, also usable standalone */
|
||||
.wn-search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-1);
|
||||
background: var(--paper);
|
||||
color: var(--fg-3);
|
||||
font: 400 12px var(--ff-mono);
|
||||
min-width: 200px;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.wn-search:focus-within { border-color: var(--ink); }
|
||||
.wn-search input {
|
||||
border: 0; outline: 0; background: none; flex: 1;
|
||||
font: inherit; color: var(--fg-1); padding: 0;
|
||||
}
|
||||
.wn-search input::placeholder { color: var(--ink-5); }
|
||||
.wn-search__kbd {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ====== Breadcrumb ====== */
|
||||
.wn-breadcrumb {
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
gap: 6px;
|
||||
font: 400 12px/1.5 var(--ff-mono);
|
||||
color: var(--fg-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.wn-breadcrumb a {
|
||||
color: var(--fg-2);
|
||||
text-decoration: none;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.wn-breadcrumb a:hover { color: var(--fg-1); border-bottom-color: var(--border-strong); }
|
||||
.wn-breadcrumb__sep { color: var(--ink-5); user-select: none; }
|
||||
.wn-breadcrumb__current { color: var(--fg-1); }
|
||||
|
||||
/* ====== Modal / Dialog ====== */
|
||||
.wn-modal__backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(10, 10, 10, 0.40);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
.wn-modal__panel {
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-3);
|
||||
box-shadow: var(--shadow-3);
|
||||
max-width: 560px; width: 100%;
|
||||
max-height: calc(100vh - 64px);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wn-modal__head {
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4);
|
||||
}
|
||||
.wn-modal__title { font: 500 20px/1.25 var(--ff-sans); margin: 0; color: var(--fg-1); }
|
||||
.wn-modal__close {
|
||||
background: none; border: 0; cursor: pointer; padding: 4px;
|
||||
color: var(--fg-3); border-radius: var(--r-1);
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.wn-modal__close:hover { color: var(--fg-1); }
|
||||
.wn-modal__body {
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
font: 400 15px/1.6 var(--ff-sans);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.wn-modal__foot {
|
||||
padding: var(--sp-4) var(--sp-6) var(--sp-5);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ====== Table ======
|
||||
* Note: shadow-DOM-rendered rows can't be children of a real <table> (the
|
||||
* HTML table model rejects unknown elements between <table> and <tr>). The
|
||||
* <wn-table> component therefore renders a CSS-grid imitation. For real
|
||||
* <table> markup (Django QuerySet rendering, etc.) use these classes
|
||||
* directly on <table>/<tr>/<td> elements — see also the .wn-table--native
|
||||
* variant below.
|
||||
*/
|
||||
|
||||
/* CSS-grid imitation (default <wn-table>) */
|
||||
.wn-table {
|
||||
width: 100%;
|
||||
font-size: var(--fs-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wn-table__thead { border-bottom: 1px solid var(--border); }
|
||||
.wn-table__tbody { display: flex; flex-direction: column; }
|
||||
.wn-table__tr {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
align-items: baseline;
|
||||
}
|
||||
.wn-table__tr:last-child { border-bottom: 0; }
|
||||
.wn-table__tr--head { border-bottom: 0; padding: var(--sp-3) var(--sp-4); }
|
||||
.wn-table__th {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-table__td {
|
||||
color: var(--fg-1);
|
||||
line-height: 1.5;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.wn-table--compact .wn-table__tr { padding: var(--sp-2) var(--sp-3); }
|
||||
.wn-table__cell--mono { font-family: var(--ff-mono); color: var(--fg-2); font-size: 12px; }
|
||||
.wn-table__cell--meta { color: var(--fg-3); font: 400 12px var(--ff-mono); }
|
||||
.wn-table__cell--right { text-align: right; }
|
||||
|
||||
/* Native <table> variant — for Django QuerySet rendering etc. */
|
||||
.wn-table--native {
|
||||
border-collapse: collapse;
|
||||
display: table;
|
||||
}
|
||||
.wn-table--native thead th {
|
||||
text-align: left;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-table--native tbody td {
|
||||
padding: var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
vertical-align: top;
|
||||
color: var(--fg-1);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wn-table--native tbody tr:hover { background: var(--paper-2); }
|
||||
.wn-table--native tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ====== Banner / Toast (success / info / warn) ====== */
|
||||
.wn-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-2);
|
||||
font: 400 14px/1.5 var(--ff-sans);
|
||||
color: var(--fg-1);
|
||||
position: relative;
|
||||
}
|
||||
.wn-banner__icon { color: var(--fg-2); flex: none; padding-top: 2px; }
|
||||
.wn-banner__body { flex: 1; }
|
||||
.wn-banner__title {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.wn-banner__dismiss {
|
||||
background: none; border: 0; cursor: pointer;
|
||||
color: var(--fg-3); padding: 4px;
|
||||
}
|
||||
.wn-banner__dismiss:hover { color: var(--fg-1); }
|
||||
.wn-banner--success { border-left: 2px solid var(--ink); }
|
||||
.wn-banner--warn { border-left: 2px solid var(--hi-2); background: #FFFCEB; }
|
||||
.wn-banner--error { border-left: 2px solid var(--ink); background: var(--paper); }
|
||||
.wn-banner--info { border-left: 2px solid var(--border-strong); }
|
||||
|
||||
.wn-toast-region {
|
||||
position: fixed;
|
||||
bottom: var(--sp-5); right: var(--sp-5);
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
z-index: 200;
|
||||
max-width: 380px;
|
||||
}
|
||||
.wn-toast { box-shadow: var(--shadow-3); }
|
||||
|
||||
/* ====== Empty state ====== */
|
||||
.wn-empty {
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-7);
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: var(--sp-2);
|
||||
text-align: center;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-empty__icon { color: var(--fg-3); margin-bottom: var(--sp-2); }
|
||||
.wn-empty__title { font: 500 14px var(--ff-sans); color: var(--fg-2); margin: 0; }
|
||||
.wn-empty__body { font: 400 13px/1.5 var(--ff-sans); color: var(--fg-3); max-width: 40ch; margin: 0; }
|
||||
.wn-empty__cta { margin-top: var(--sp-2); }
|
||||
|
||||
/* ====== Top navigation ====== */
|
||||
.wn-topnav {
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center;
|
||||
gap: var(--sp-6);
|
||||
padding: 0 var(--sp-5);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.wn-topnav__brand { display: flex; align-items: center; gap: 10px; font: 500 14px var(--ff-sans); }
|
||||
.wn-topnav__brand img { width: 22px; height: 22px; }
|
||||
.wn-topnav__brand-slug { font-family: var(--ff-mono); font-size: 12px; color: var(--fg-3); letter-spacing: 0.04em; }
|
||||
.wn-topnav__links { display: flex; gap: 22px; }
|
||||
.wn-topnav__link {
|
||||
font: 500 13px var(--ff-sans);
|
||||
color: var(--fg-2);
|
||||
text-decoration: none;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.wn-topnav__link:hover { color: var(--fg-1); }
|
||||
.wn-topnav__link--active { color: var(--fg-1); border-bottom-color: var(--ink); }
|
||||
.wn-topnav__right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
|
||||
|
||||
/* ====== Sidebar ====== */
|
||||
.wn-sidebar {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
background: var(--paper-2);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: var(--sp-5) var(--sp-4);
|
||||
display: flex; flex-direction: column; gap: var(--sp-5);
|
||||
height: calc(100vh - 56px);
|
||||
position: sticky; top: 56px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wn-sidebar__group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.wn-sidebar__group-label { padding-left: 12px; }
|
||||
.wn-sidebar__item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
color: var(--fg-2);
|
||||
font: 500 13px var(--ff-sans);
|
||||
cursor: pointer; text-decoration: none;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.wn-sidebar__item:hover { color: var(--fg-1); }
|
||||
.wn-sidebar__item--active {
|
||||
color: var(--fg-1); background: var(--paper);
|
||||
box-shadow: 0 0 0 1px var(--border) inset;
|
||||
}
|
||||
.wn-sidebar__item--doc { font-family: var(--ff-mono); font-size: 12px; }
|
||||
.wn-sidebar__count { margin-left: auto; font: 400 11px var(--ff-mono); color: var(--fg-3); }
|
||||
.wn-sidebar__footer { margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--border); }
|
||||
.wn-sidebar__activation {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
|
||||
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.wn-sidebar__activation-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--hi-2); }
|
||||
|
||||
/* ====== Page header ====== */
|
||||
.wn-page-header {
|
||||
margin-bottom: var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.wn-page-header__row { display: flex; align-items: flex-end; gap: var(--sp-5); }
|
||||
.wn-page-header__title {
|
||||
font: 500 32px/1.15 var(--ff-sans);
|
||||
letter-spacing: -0.015em;
|
||||
margin: 0; flex: 1; color: var(--fg-1);
|
||||
}
|
||||
.wn-page-header__actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.wn-page-header__lede {
|
||||
font: 400 16px/1.55 var(--ff-sans);
|
||||
color: var(--fg-2);
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* ====== Pipeline ====== */
|
||||
.wn-pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
margin: 0 0 var(--sp-6);
|
||||
}
|
||||
.wn-pipeline__stage {
|
||||
padding: 10px 12px 14px;
|
||||
border-top: 2px solid var(--border);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
.wn-pipeline__stage--done { border-top-color: var(--ink); }
|
||||
.wn-pipeline__stage--active { border-top-color: var(--hi-2); }
|
||||
.wn-pipeline__num {
|
||||
font: 500 10px/1 var(--ff-mono); letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-pipeline__stage--done .wn-pipeline__num,
|
||||
.wn-pipeline__stage--active .wn-pipeline__num { color: var(--fg-1); }
|
||||
.wn-pipeline__name { font: 500 14px/1.25 var(--ff-sans); color: var(--fg-1); }
|
||||
.wn-pipeline__stage--pending .wn-pipeline__name { color: var(--fg-3); }
|
||||
.wn-pipeline__meta { font: 400 11px/1.35 var(--ff-mono); color: var(--fg-3); }
|
||||
.wn-pipeline__arrow {
|
||||
position: absolute; top: -8px; right: -7px;
|
||||
font: 400 14px var(--ff-mono); color: var(--ink-5);
|
||||
}
|
||||
.wn-pipeline__stage--done .wn-pipeline__arrow,
|
||||
.wn-pipeline__stage--active .wn-pipeline__arrow { color: var(--ink); }
|
||||
|
||||
/* ====== Prototype card (combined card variant) ====== */
|
||||
.wn-prototype-card { /* extends .wn-card */ }
|
||||
.wn-prototype-card__qrow {
|
||||
display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px;
|
||||
font-size: 13px; color: var(--fg-1);
|
||||
}
|
||||
.wn-prototype-card__qkey {
|
||||
font: 500 11px/1.5 var(--ff-mono);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-prototype-card__qval { line-height: 1.45; }
|
||||
|
||||
/* ====== Layout helpers ====== */
|
||||
.wn-main { padding: 40px 48px 80px; max-width: 1180px; }
|
||||
.wn-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||
289
tests/test_control_plane_app.py
Normal file
289
tests/test_control_plane_app.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"""HTTP-level integration tests for the Control Plane interactive UI
|
||||
(WP-0009-T04), via FastAPI's TestClient.
|
||||
|
||||
Same ephemeral, disposable Postgres-via-Docker pattern as
|
||||
`test_control_plane.py` (never the shared state-hub instance).
|
||||
|
||||
Scope disclosure: these tests exercise the FastAPI app's routing, form
|
||||
handling, session auth, and rights gating at the HTTP layer — they do not
|
||||
render or interact with the pages in a real browser, since no
|
||||
browser-automation tool is available in this environment. The
|
||||
whynot-design web components (`<wn-*>` custom elements) are not
|
||||
themselves exercised; only the server-rendered HTML/session/redirect
|
||||
behavior around them is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
psycopg = pytest.importorskip("psycopg")
|
||||
pytest.importorskip("jinja2")
|
||||
pytest.importorskip("itsdangerous")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATIONS = [
|
||||
REPO_ROOT / "migrations" / "0001_registries.sql",
|
||||
REPO_ROOT / "migrations" / "0002_ledger.sql",
|
||||
REPO_ROOT / "migrations" / "0003_attestations.sql",
|
||||
REPO_ROOT / "migrations" / "0004_breach_records.sql",
|
||||
REPO_ROOT / "migrations" / "0005_licensor_credentials.sql",
|
||||
REPO_ROOT / "migrations" / "0006_control_plane.sql",
|
||||
]
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("docker") is None, reason="docker not available"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pg_container():
|
||||
name = f"trf-test-pg-cpapp-{uuid.uuid4().hex[:8]}"
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "--rm", "-d",
|
||||
"--name", name,
|
||||
"-e", "POSTGRES_PASSWORD=postgres",
|
||||
"-e", "POSTGRES_DB=target_revenue_test",
|
||||
"-p", "127.0.0.1::5432",
|
||||
"postgres:16-alpine",
|
||||
],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
try:
|
||||
port_out = subprocess.run(
|
||||
["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
host_port = port_out.split(":")[-1]
|
||||
dsn = f"host=127.0.0.1 port={host_port} dbname=target_revenue_test user=postgres password=postgres"
|
||||
|
||||
for _ in range(60):
|
||||
try:
|
||||
with psycopg.connect(dsn, connect_timeout=1):
|
||||
break
|
||||
except psycopg.OperationalError:
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise RuntimeError("postgres container did not become ready in time")
|
||||
|
||||
with psycopg.connect(dsn) as admin_conn:
|
||||
for migration in MIGRATIONS:
|
||||
admin_conn.execute(migration.read_text(encoding="utf-8"))
|
||||
admin_conn.commit()
|
||||
admin_conn.execute(
|
||||
"INSERT INTO licensors (token, licensor_id, credential_label, rights, issued_by) "
|
||||
"VALUES (%s, %s, %s, %s, %s)",
|
||||
("founding-admin-token", "binky", "founder", "admin", "bootstrap"),
|
||||
)
|
||||
admin_conn.commit()
|
||||
|
||||
app_dsn = (
|
||||
f"host=127.0.0.1 port={host_port} dbname=target_revenue_test "
|
||||
f"user=trf_app password=changeme-in-deployment"
|
||||
)
|
||||
yield {"admin_dsn": dsn, "app_dsn": app_dsn, "admin_token": "founding-admin-token"}
|
||||
finally:
|
||||
subprocess.run(["docker", "stop", name], capture_output=True)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(pg_container, monkeypatch):
|
||||
monkeypatch.setenv("TRF_DATABASE_URL", pg_container["app_dsn"])
|
||||
monkeypatch.setenv("TRF_CONTROL_PLANE_SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("TRF_SIGNING_KEY_HEX", "11" * 32)
|
||||
|
||||
import importlib
|
||||
|
||||
from target_revenue.service import control_plane_app as mod
|
||||
|
||||
importlib.reload(mod)
|
||||
if hasattr(mod.app.state, "pool"):
|
||||
mod.app.state.pool.close()
|
||||
del mod.app.state.pool
|
||||
if hasattr(mod.app.state, "signing_key"):
|
||||
del mod.app.state.signing_key
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(mod.app) as test_client:
|
||||
yield test_client
|
||||
|
||||
if hasattr(mod.app.state, "pool"):
|
||||
mod.app.state.pool.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(pg_container):
|
||||
with psycopg.connect(pg_container["app_dsn"]) as connection:
|
||||
yield connection
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def credentials(conn, pg_container):
|
||||
from target_revenue import registry
|
||||
|
||||
admin = registry.authenticate(conn, pg_container["admin_token"])
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
tiers = {}
|
||||
for label, rights in [
|
||||
("viewer-user", "viewer"),
|
||||
("contributor-user", "contributor"),
|
||||
("operator-user", "operator"),
|
||||
]:
|
||||
cred = registry.issue_sub_credential(
|
||||
conn, licensor_id="binky", credential_label=f"{label}-{suffix}", rights=rights,
|
||||
issued_by="founder",
|
||||
)
|
||||
tiers[rights] = cred
|
||||
conn.commit()
|
||||
tiers["admin"] = admin
|
||||
return tiers
|
||||
|
||||
|
||||
def _login(client, token):
|
||||
resp = client.post("/login", data={"token": token}, follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/"
|
||||
|
||||
|
||||
def test_root_requires_login_redirects(client):
|
||||
resp = client.get("/", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_invalid_token_rejected(client):
|
||||
resp = client.post("/login", data={"token": "not-a-real-token"}, follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_valid_login_then_dashboard(client, credentials):
|
||||
_login(client, credentials["viewer"].token)
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "Phases for binky" in resp.text
|
||||
|
||||
|
||||
def test_viewer_cannot_reach_phase_new(client, credentials):
|
||||
_login(client, credentials["viewer"].token)
|
||||
resp = client.get("/phases/new", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/"
|
||||
|
||||
|
||||
def test_operator_registers_phase_and_appends_entry(client, credentials):
|
||||
_login(client, credentials["operator"].token)
|
||||
phase_id = "trsl:phase:cpapp-test-" + uuid.uuid4().hex[:8]
|
||||
|
||||
resp = client.post(
|
||||
"/phases/new",
|
||||
data={
|
||||
"phase_id": phase_id,
|
||||
"milestone_release_name": "CP UI smoke test release",
|
||||
"source_revision": "abc123",
|
||||
"initial_target_amount": "1000",
|
||||
"currency": "USD",
|
||||
"future_license": "MIT",
|
||||
"degeneration_policy": "trsl:policy:linear-longstop-v0@1.0",
|
||||
"longstop_at": "2027-01-01T00:00:00Z",
|
||||
"ledger": f"examples/{phase_id}/ledger.json",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == f"/phases/{phase_id}"
|
||||
|
||||
detail = client.get(f"/phases/{phase_id}")
|
||||
assert detail.status_code == 200
|
||||
assert phase_id in detail.text
|
||||
|
||||
ledger_resp = client.post(
|
||||
f"/phases/{phase_id}/ledger",
|
||||
data={
|
||||
"entry_id": "trsl:entry:cpappledger0001",
|
||||
"amount": "500",
|
||||
"currency": "USD",
|
||||
"recognized_at": "2026-08-01T00:00:00Z",
|
||||
"evidence_reference": "confidential:evidence:cpappledger0001",
|
||||
"extension_id": "trsl:extension:development-license",
|
||||
"extension_version": "1.0",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert ledger_resp.status_code == 303
|
||||
|
||||
detail_after = client.get(f"/phases/{phase_id}")
|
||||
assert "trsl:entry:cpappledger0001" in detail_after.text
|
||||
|
||||
|
||||
def test_contributor_proposes_operator_approves(client, credentials):
|
||||
_login(client, credentials["operator"].token)
|
||||
phase_id = "trsl:phase:cpapp-propose-" + uuid.uuid4().hex[:8]
|
||||
client.post(
|
||||
"/phases/new",
|
||||
data={
|
||||
"phase_id": phase_id,
|
||||
"milestone_release_name": "CP UI propose-flow release",
|
||||
"source_revision": "def456",
|
||||
"initial_target_amount": "1000",
|
||||
"currency": "USD",
|
||||
"future_license": "MIT",
|
||||
"degeneration_policy": "trsl:policy:linear-longstop-v0@1.0",
|
||||
"longstop_at": "2027-01-01T00:00:00Z",
|
||||
"ledger": f"examples/{phase_id}/ledger.json",
|
||||
},
|
||||
)
|
||||
client.post("/logout")
|
||||
|
||||
_login(client, credentials["contributor"].token)
|
||||
propose_resp = client.post(
|
||||
f"/phases/{phase_id}/ledger",
|
||||
data={
|
||||
"entry_id": "trsl:entry:cpappprop0001",
|
||||
"amount": "250",
|
||||
"currency": "USD",
|
||||
"recognized_at": "2026-08-01T00:00:00Z",
|
||||
"evidence_reference": "confidential:evidence:cpappprop0001",
|
||||
"extension_id": "trsl:extension:development-license",
|
||||
"extension_version": "1.0",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert propose_resp.status_code == 303
|
||||
client.post("/logout")
|
||||
|
||||
_login(client, credentials["operator"].token)
|
||||
proposals_page = client.get("/proposals")
|
||||
assert proposals_page.status_code == 200
|
||||
assert phase_id in proposals_page.text
|
||||
|
||||
|
||||
def test_admin_issues_and_revokes_credential(client, credentials):
|
||||
_login(client, credentials["admin"].token)
|
||||
issue_resp = client.post(
|
||||
"/admin/credentials",
|
||||
data={"credential_label": f"dana-{uuid.uuid4().hex[:6]}", "rights": "viewer"},
|
||||
)
|
||||
assert issue_resp.status_code == 200
|
||||
assert "New credential issued" in issue_resp.text
|
||||
|
||||
|
||||
def test_non_admin_cannot_reach_admin_credentials(client, credentials):
|
||||
_login(client, credentials["operator"].token)
|
||||
resp = client.get("/admin/credentials", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/"
|
||||
|
||||
|
||||
def test_audit_log_visible_to_signed_in_user(client, credentials):
|
||||
_login(client, credentials["viewer"].token)
|
||||
resp = client.get("/audit")
|
||||
assert resp.status_code == 200
|
||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Target Revenue Control Plane"
|
||||
domain: infotech
|
||||
repo: target-revenue
|
||||
status: active
|
||||
status: finished
|
||||
owner: claude
|
||||
topic_slug: infotech
|
||||
created: "2026-07-30"
|
||||
|
|
@ -191,7 +191,7 @@ containers left running.
|
|||
|
||||
```task
|
||||
id: TREV-WP-0009-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "01b295f2-8f97-4fc4-a14e-6f68aa85458d"
|
||||
```
|
||||
|
|
@ -212,3 +212,46 @@ sub-credential extension as the backend:
|
|||
- read-only views (Phase status, Ledger, Attestation, Breach/Compliance
|
||||
Record history) — already public/unauthenticated at the Trust Service
|
||||
layer, so these need no new backend work, only UI.
|
||||
|
||||
**Result:** Built on `whynot-design` (vendored, source commit
|
||||
`4b62cffc86496d587ac8d48a8e199624bc4a5c1f`, v0.4.1 — see
|
||||
`src/target_revenue/service/static/whynot-design/VENDORED.md` for what is
|
||||
vendored versus resolved via an `esm.sh` CDN import map for the `lit`
|
||||
peer dependency, a disclosed v0 trade-off) rather than from scratch, per
|
||||
the explicit direction to check feasibility first. New FastAPI app
|
||||
`src/target_revenue/service/control_plane_app.py`, session-based sign-in
|
||||
(paste-a-credential-token, `itsdangerous`-signed cookie via Starlette's
|
||||
`SessionMiddleware`, re-authenticated against the database on every
|
||||
request so a mid-session revocation takes effect immediately, never
|
||||
trusted from the cookie alone), seven Jinja2 templates under
|
||||
`service/control_plane_templates/` (`base`, `login`, `dashboard`,
|
||||
`phase_new`, `phase_detail`, `proposals`, `admin_credentials`, `audit`),
|
||||
all wired to real `control_plane.py`/`registry.py`/`ledger.py`/`metrics.py`
|
||||
calls — no mock data. Routes cover: login/logout; dashboard (list Phases
|
||||
for the signed-in tenant); Phase registration (Operator+); Phase detail
|
||||
(status/metrics + Ledger table); the priority flow — append a
|
||||
Development Credit entry directly (Operator+/Admin) or submit it for
|
||||
review (Contributor), same form, button label switches on rights;
|
||||
proposal review (approve/reject, Operator+); credential issue/revoke
|
||||
(Admin); and the Control Plane's own audit log (any signed-in user).
|
||||
Rights gating is enforced identically to T03's backend — the UI layer
|
||||
adds no new authorization logic, it only redirects with a flash message
|
||||
when a route requires more than the signed-in credential's tier.
|
||||
|
||||
Tests: `tests/test_control_plane_app.py`, 9 Docker-gated tests via
|
||||
FastAPI's `TestClient` (same disposable-Postgres-per-module pattern as
|
||||
`test_control_plane.py`) — covers unauthenticated redirect to `/login`,
|
||||
invalid-token rejection, successful login/dashboard, rights-gated route
|
||||
redirects (viewer blocked from Phase registration and admin pages), the
|
||||
full Operator register-Phase-then-append-entry flow, the
|
||||
Contributor-proposes/Operator-reviews flow, and admin credential
|
||||
issuance. Explicit scope disclosure: these are HTTP-level tests against
|
||||
the FastAPI app, not real-browser tests — no browser-automation tool is
|
||||
available in this environment, so the `<wn-*>` web components' actual
|
||||
rendering/interactivity was never visually verified, only the
|
||||
server-rendered HTML/session/redirect behavior around them. Full suite:
|
||||
84 passing offline (unchanged), 146 passing with Docker (up from 53); no
|
||||
stray containers left running.
|
||||
|
||||
All four WP-0009 tasks (T01–T04) are now done — this workplan is
|
||||
finished.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue