diff --git a/README.md b/README.md index b96feb1..c1668ab 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index b6c4c58..f66d396 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]", diff --git a/src/target_revenue/registry.py b/src/target_revenue/registry.py index b90a1a0..1a2010b 100644 --- a/src/target_revenue/registry.py +++ b/src/target_revenue/registry.py @@ -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: diff --git a/src/target_revenue/service/control_plane_app.py b/src/target_revenue/service/control_plane_app.py new file mode 100644 index 0000000..398f2b2 --- /dev/null +++ b/src/target_revenue/service/control_plane_app.py @@ -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)) diff --git a/src/target_revenue/service/control_plane_templates/admin_credentials.html b/src/target_revenue/service/control_plane_templates/admin_credentials.html new file mode 100644 index 0000000..6669383 --- /dev/null +++ b/src/target_revenue/service/control_plane_templates/admin_credentials.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Credentials — Target Revenue Control Plane{% endblock %} +{% block content %} + + Manage credentials (Admin) + + +{% if new_token %} + + New credential issued for {{ new_token_label }}. Copy it now — + it will not be shown again: {{ new_token }} + +{% endif %} + +

Issue a new credential

+
+ + + + + + + + + + + + Issue credential +
+ +

Revoke a credential

+
+ + + + Revoke +
+{% endblock %} diff --git a/src/target_revenue/service/control_plane_templates/audit.html b/src/target_revenue/service/control_plane_templates/audit.html new file mode 100644 index 0000000..8b7245e --- /dev/null +++ b/src/target_revenue/service/control_plane_templates/audit.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Audit log — Target Revenue Control Plane{% endblock %} +{% block content %} + + Control Plane audit log + +

+ 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 + {{ session_licensor_id }} tenant acted, never which + individual human (see specs/TargetRevenueControlPlaneConcept.md §5). +

+ + + + {% for row in log %} + + + + + + + + {% endfor %} + +
whenwhoactionPhaseTrust Service record
{{ row.created_at }}{{ row.actor_credential_label }}{{ row.action }}{{ row.phase_id }}{{ row.trust_service_record_id }}
+{% endblock %} diff --git a/src/target_revenue/service/control_plane_templates/base.html b/src/target_revenue/service/control_plane_templates/base.html new file mode 100644 index 0000000..d2919fb --- /dev/null +++ b/src/target_revenue/service/control_plane_templates/base.html @@ -0,0 +1,54 @@ + + + + +{% block title %}Target Revenue Control Plane{% endblock %} + + + + + + + + + Target Revenue Control Plane + + +{% if session_credential_label %} +

+ Signed in as {{ session_credential_label }} + ({{ session_rights }}) for {{ session_licensor_id }} + · Dashboard + {% if session_rights in ("operator", "admin") %}· Proposals{% endif %} + {% if session_rights == "admin" %}· Credentials{% endif %} + · Audit log + ·

+

+{% endif %} + +{% if flash %} +{{ flash }} +{% endif %} + +{% block content %}{% endblock %} + +

+ This is a dry-run/pilot tool. No real Phase is authorized to go live via + this interface — see workplans/TREV-WP-0008-governance-and-pilot-rollout.md T05. + Visual language vendored from whynot-design — see + static/whynot-design/VENDORED.md. +

+ + diff --git a/src/target_revenue/service/control_plane_templates/dashboard.html b/src/target_revenue/service/control_plane_templates/dashboard.html new file mode 100644 index 0000000..cb16a20 --- /dev/null +++ b/src/target_revenue/service/control_plane_templates/dashboard.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Target Revenue Control Plane{% endblock %} +{% block content %} + + Phases for {{ session_licensor_id }} + + +{% if session_rights in ("operator", "admin") %} +

+ Register a new Phase

+{% endif %} + +{% if phases %} + + + + {% for phase in phases %} + + + + + + + {% endfor %} + +
PhaseMilestone ReleaseInitial Target
{{ phase.phase.id }}{{ phase.phase.milestone_release.name }}{{ phase.phase.initial_target.amount }} {{ phase.phase.initial_target.currency }}View
+{% else %} +No Phases registered yet for this tenant. +{% endif %} +{% endblock %} diff --git a/src/target_revenue/service/control_plane_templates/login.html b/src/target_revenue/service/control_plane_templates/login.html new file mode 100644 index 0000000..eb97de7 --- /dev/null +++ b/src/target_revenue/service/control_plane_templates/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Sign in — Target Revenue Control Plane{% endblock %} +{% block content %} + + Sign in + +

Paste the credential token you were issued by an Admin.

+
+ + + + Sign in +
+{% endblock %} diff --git a/src/target_revenue/service/control_plane_templates/phase_detail.html b/src/target_revenue/service/control_plane_templates/phase_detail.html new file mode 100644 index 0000000..4b411de --- /dev/null +++ b/src/target_revenue/service/control_plane_templates/phase_detail.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}{{ manifest.phase.id }} — Target Revenue Control Plane{% endblock %} +{% block content %} + + {{ manifest.phase.milestone_release.name }} + +

{{ manifest.phase.id }}

+ +

Status

+ + + + + + + +
Initial Target{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}
Cumulative Development Credit{{ metrics.facts.cumulative_development_credit }}
Cumulative Remission Credit{{ metrics.facts.cumulative_remission_credit }}
Outstanding Target{{ metrics.facts.outstanding_target }}
Target satisfaction{{ metrics.calculations.target_satisfaction_percentage }}%
Converted{{ metrics.facts.is_converted }}
+ +

Ledger ({{ ledger | length }} entries)

+{% if ledger %} + + + + {% for entry in ledger %} + + {% endfor %} + +
idtypeamountrecognized_at
{{ entry.id }}{{ entry.type }}{{ entry.amount }}{{ entry.recognized_at }}
+{% else %} +No ledger entries yet. +{% endif %} + +{% if session_rights in ("contributor", "operator", "admin") %} +

+ {% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %} +

+
+ + + + + + + + + + + + + + + + + + + + + + + {% if session_rights == "contributor" %}Submit for review{% else %}Append{% endif %} + +
+{% endif %} +{% endblock %} diff --git a/src/target_revenue/service/control_plane_templates/phase_new.html b/src/target_revenue/service/control_plane_templates/phase_new.html new file mode 100644 index 0000000..5175406 --- /dev/null +++ b/src/target_revenue/service/control_plane_templates/phase_new.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Register a Phase — Target Revenue Control Plane{% endblock %} +{% block content %} + + Register a new Phase (Operator+) + +

+ This registers a real Phase Manifest against the hosted Trust Service. + It does not itself authorize going live for a repo — see + workplans/TREV-WP-0008-governance-and-pilot-rollout.md T05. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Register Phase +
+{% endblock %} diff --git a/src/target_revenue/service/control_plane_templates/proposals.html b/src/target_revenue/service/control_plane_templates/proposals.html new file mode 100644 index 0000000..233038e --- /dev/null +++ b/src/target_revenue/service/control_plane_templates/proposals.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Proposals — Target Revenue Control Plane{% endblock %} +{% block content %} + + Pending Development Credit proposals (Operator+) + +{% if proposals %} + + + + {% for p in proposals %} + + + + + + + + + {% endfor %} + +
idPhasetypeamountproposed at
{{ p.id }}{{ p.phase_id }}{{ p.entry.type }}{{ p.entry.amount }}{{ p.created_at }} +
+ Approve +
+
+ Reject +
+
+{% else %} +No pending proposals. +{% endif %} +{% endblock %} diff --git a/src/target_revenue/service/static/whynot-design/VENDORED.md b/src/target_revenue/service/static/whynot-design/VENDORED.md new file mode 100644 index 0000000..1c7fd9a --- /dev/null +++ b/src/target_revenue/service/static/whynot-design/VENDORED.md @@ -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. diff --git a/src/target_revenue/service/static/whynot-design/elements/_styles.js b/src/target_revenue/service/static/whynot-design/elements/_styles.js new file mode 100644 index 0000000..b0f9dd5 --- /dev/null +++ b/src/target_revenue/service/static/whynot-design/elements/_styles.js @@ -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,"); + 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 (the + * HTML table model rejects unknown elements between
and ). The + * component therefore renders a CSS-grid imitation. For real + *
markup (Django QuerySet rendering, etc.) use these classes + * directly on
//
elements — see also the .wn-table--native + * variant below. + */ + +/* CSS-grid imitation (default ) */ +.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 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; +} diff --git a/src/target_revenue/service/static/whynot-design/elements/atoms.js b/src/target_revenue/service/static/whynot-design/elements/atoms.js new file mode 100644 index 0000000..7663bd3 --- /dev/null +++ b/src/target_revenue/service/static/whynot-design/elements/atoms.js @@ -0,0 +1,164 @@ +/* ============================================================= + * @whynot/design — atoms.js + * ------------------------------------------------------------ + * , , , , + * , , + * + * 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()]; + } + } +} + +/* ---------- ---------- */ +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`` + : nothing; + const iconEnd = this.iconEnd + ? html`` + : nothing; + if (this.href) { + return html`${iconStart}${iconEnd}`; + } + return html``; + } +} + +/* ---------- ---------- */ +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``; + } +} + +/* ---------- ---------- */ +export class WnEyebrow extends WnBase { + static properties = { strong: { type: Boolean, reflect: true } }; + render() { + const cls = "wn-eyebrow" + (this.strong ? " wn-eyebrow--strong" : ""); + return html``; + } +} + +/* ---------- ---------- */ +export class WnStamp extends WnBase { + render() { return html``; } +} + +/* ---------- ---------- */ +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` + + + ${this.label || this.level} + `; + } +} + +/* ---------- ---------- */ +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` + + ${glyph} + + `; + } +} + +/* ---------- ---------- */ +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``; + } + return html``; + } +} + +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 }; diff --git a/src/target_revenue/service/static/whynot-design/elements/chrome.js b/src/target_revenue/service/static/whynot-design/elements/chrome.js new file mode 100644 index 0000000..c5d992b --- /dev/null +++ b/src/target_revenue/service/static/whynot-design/elements/chrome.js @@ -0,0 +1,206 @@ +/* ============================================================= + * @whynot/design — chrome.js + * ------------------------------------------------------------ + * , / / + * , , , + * + * ============================================================= */ + +import { LitElement, html, nothing } from "lit"; +import { WnBase } from "./atoms.js"; + +/* ---------- ---------- */ +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` + + `; + } +} + +/* ---------- ---------- */ +export class WnSidebar extends WnBase { + static properties = { activation: { type: String } }; + render() { + return html` + + `; + } +} + +export class WnSidebarGroup extends WnBase { + static properties = { label: { type: String } }; + render() { + return html` +
+ ${this.label ? html`${this.label}` : nothing} + +
+ `; + } +} + +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`` : nothing} + + ${this.count ? html`${this.count}` : nothing} + `; + return this.href + ? html`${inner}` + : html`
${inner}
`; + } +} + +/* ---------- ---------- */ +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` +
+ ${this.eyebrow ? html`${this.eyebrow}` : nothing} + +
+

${this.title}

+
+ +
+
+ ${this.lede ? html`

${this.lede}

` : nothing} + +
+ `; + } +} + +/* ---------- ---------- */ +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` +
+ ${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` +
+ ${s.num} + ${s.name} + ${s.meta ? html`${s.meta}` : nothing} + ${i > 0 ? html`` : nothing} +
+ `; + })} +
+ `; + } +} + +/* ---------- ---------- */ +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` +
+
+ ${this.cardId ? this.cardId + " · " : ""}Prototype + ${this.stageLabel || this.signal} +
+

+
+ Learning q. + + Smallest test + +
+
+ + ${this.signal} signal +
+
+ `; + } +} + +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); +} diff --git a/src/target_revenue/service/static/whynot-design/elements/form.js b/src/target_revenue/service/static/whynot-design/elements/form.js new file mode 100644 index 0000000..d24b714 --- /dev/null +++ b/src/target_revenue/service/static/whynot-design/elements/form.js @@ -0,0 +1,205 @@ +/* ============================================================= + * @whynot/design — form.js + * ------------------------------------------------------------ + * , , , + * , + * + * 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"; + +/* ---------- ---------- */ +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` + + ${this.error && this.errorText + ? html`${this.errorText}` + : this.help + ? html`${this.help}` + : nothing} + `; + } +} + +/* ---------- ---------- */ +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` + + ${this.error && this.errorText + ? html`${this.errorText}` + : this.help + ? html`${this.help}` + : nothing} + `; + } +} + +/* ---------- ---------- + * Slot
/ with slotted rows — + * the table model requires the row to be a child of
. So these + * components use CSS grid + flexbox to imitate a table visually. For real + *
+ Django QuerySet rendering, write raw
+ * 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` +
+ ${cols.length + ? html`
+
(typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`}> + ${cols.map(c => html`
${typeof c === "string" ? c : c.label}
`)} +
+
` + : nothing} +
(typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}` + : nothing}> + +
+
+ `; + } +} + +export class WnTableRow extends WnBase { + render() { + return html`
+ +
`; + } +} + +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`
`; + } +} + +/* ---------- ---------- */ +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` +
+ ${iconName ? html`` : nothing} +
+ ${this.title ? html`

${this.title}

` : nothing} + +
+ ${this.dismissible + ? html`` + : nothing} +
+ `; + } +} + +/* ---------- / ---------- */ +export class WnToast extends WnBanner { + constructor() { super(); this.dismissible = true; } + render() { + const base = super.render(); + return html`
${base}
`; + } +} +export class WnToastRegion extends WnBase { + render() { + return html`
+ +
`; + } +} + +/* ---------- ---------- */ +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` +
+ ${this.icon ? html`` : nothing} + ${this.title ? html`

${this.title}

` : nothing} +

+
+ +
+
+ `; + } +} + +/* ---------- ---------- */ +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` + + `; + } +} + +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); +} diff --git a/src/target_revenue/service/static/whynot-design/index.js b/src/target_revenue/service/static/whynot-design/index.js new file mode 100644 index 0000000..d567d18 --- /dev/null +++ b/src/target_revenue/service/static/whynot-design/index.js @@ -0,0 +1,35 @@ +/* ============================================================= + * @whynot/design — entry point + * ------------------------------------------------------------ + * Side-effect import that registers every 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"; diff --git a/src/target_revenue/service/static/whynot-design/styles/colors_and_type.css b/src/target_revenue/service/static/whynot-design/styles/colors_and_type.css new file mode 100644 index 0000000..0f0c076 --- /dev/null +++ b/src/target_revenue/service/static/whynot-design/styles/colors_and_type.css @@ -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); } diff --git a/src/target_revenue/service/static/whynot-design/styles/components.css b/src/target_revenue/service/static/whynot-design/styles/components.css new file mode 100644 index 0000000..6fe2814 --- /dev/null +++ b/src/target_revenue/service/static/whynot-design/styles/components.css @@ -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,"); + 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
(the + * HTML table model rejects unknown elements between
and ). The + * component therefore renders a CSS-grid imitation. For real + *
markup (Django QuerySet rendering, etc.) use these classes + * directly on
//
elements — see also the .wn-table--native + * variant below. + */ + +/* CSS-grid imitation (default ) */ +.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 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; } diff --git a/tests/test_control_plane_app.py b/tests/test_control_plane_app.py new file mode 100644 index 0000000..6fd195d --- /dev/null +++ b/tests/test_control_plane_app.py @@ -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 (`` 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 diff --git a/workplans/TREV-WP-0009-target-revenue-control-plane.md b/workplans/TREV-WP-0009-target-revenue-control-plane.md index d92a091..f121507 100644 --- a/workplans/TREV-WP-0009-target-revenue-control-plane.md +++ b/workplans/TREV-WP-0009-target-revenue-control-plane.md @@ -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 `` 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.