Implement WP-0009-T04: Control Plane interactive UI on whynot-design

Builds the Control Plane's browser UI (login, dashboard, Phase
registration, Development Credit entry/proposal/review, credential
admin, audit log) as a FastAPI + Jinja2 app over the already-finished
T03 backend, rather than from scratch — whynot-design's Lit web
components are vendored as static assets (source commit 4b62cffc,
v0.4.1), with lit itself resolved via an esm.sh CDN import map.

Session auth re-checks the credential token against the database on
every request rather than trusting the session cookie's cached rights,
so a mid-session revocation takes effect immediately.

9 new Docker-gated HTTP-level tests via FastAPI's TestClient (no
browser-automation tool available, so real rendering of the <wn-*>
components was never visually verified). All four WP-0009 tasks are
now done; workplan marked finished.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-30 15:43:16 +02:00
parent 5fbae0df02
commit c89b4aa4a5
24 changed files with 3486 additions and 3 deletions

View file

@ -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; T01T04 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)

View file

@ -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]",

View file

@ -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:

View file

@ -0,0 +1,395 @@
"""Target Revenue Control Plane — interactive UI (WP-0009-T04).
An HTML front end over `target_revenue.control_plane`, itself a client
layer over the hosted Trust Service (`registry.py`, `ledger.py`) this
module adds no new rights-enforcement or audit logic of its own, it only
frames `control_plane.py`'s functions as browser-usable forms and pages
(session-based sign-in, flash messages, whynot-design-styled templates).
Session model: the browser session stores only the raw credential token
(`itsdangerous`-signed cookie, via Starlette's `SessionMiddleware` — never
the resolved rights tier, which is re-checked against the database on
every request so a mid-session revocation takes effect immediately).
"""
from __future__ import annotations
import os
from typing import Any
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from psycopg import Connection
from psycopg_pool import ConnectionPool
from starlette.middleware.sessions import SessionMiddleware
from .. import control_plane, ledger, metrics, registry
from . import keys
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates")
_DATABASE_URL_ENV = "TRF_DATABASE_URL"
_SECRET_KEY_ENV = "TRF_CONTROL_PLANE_SECRET_KEY"
app = FastAPI(title="Target Revenue Control Plane", version="0.1.0")
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
_secret_key = os.environ.get(_SECRET_KEY_ENV)
if not _secret_key:
import secrets
import warnings
warnings.warn(
f"{_SECRET_KEY_ENV} not set — generating an ephemeral session secret. "
"Every restart invalidates existing sessions; do not use this in any "
"real deployment.",
stacklevel=1,
)
_secret_key = secrets.token_hex(32)
app.add_middleware(SessionMiddleware, secret_key=_secret_key)
def get_pool() -> ConnectionPool:
if not hasattr(app.state, "pool"):
dsn = os.environ.get(_DATABASE_URL_ENV)
if not dsn:
raise RuntimeError(f"{_DATABASE_URL_ENV} is not set")
app.state.pool = ConnectionPool(dsn, min_size=1, max_size=5, open=True)
return app.state.pool
def get_signing_key():
if not hasattr(app.state, "signing_key"):
app.state.signing_key = keys.load_signing_key()
return app.state.signing_key
def get_connection():
pool = get_pool()
with pool.connection() as conn:
yield conn
def get_session_licensor(
request: Request, conn: Connection = Depends(get_connection)
) -> registry.Licensor | None:
"""The signed-in credential, re-authenticated against the database on
every request (not trusted from the session cookie alone) so a
revocation between requests takes effect immediately."""
token = request.session.get("token")
if not token:
return None
try:
return registry.authenticate(conn, token)
except registry.RegistrationError:
request.session.clear()
return None
class _NotAuthenticated(Exception):
"""Raised by `require_login` when there is no valid session — handled
below by a dedicated exception handler so it never collides with (or
overrides the default handling of) ordinary HTTPExceptions like a
404 for an unknown Phase id."""
def require_login(
licensor: registry.Licensor | None = Depends(get_session_licensor),
) -> registry.Licensor:
if licensor is None:
raise _NotAuthenticated()
return licensor
def _template_context(request: Request, licensor: registry.Licensor | None, **extra: Any) -> dict[str, Any]:
context = {
"request": request,
"session_credential_label": licensor.credential_label if licensor else None,
"session_rights": licensor.rights if licensor else None,
"session_licensor_id": licensor.licensor_id if licensor else None,
"flash": request.session.pop("flash", None),
"flash_variant": request.session.pop("flash_variant", None),
}
context.update(extra)
return context
def _redirect(url: str, request: Request, flash: str | None = None, variant: str = "info") -> RedirectResponse:
if flash:
request.session["flash"] = flash
request.session["flash_variant"] = variant
return RedirectResponse(url=url, status_code=303)
@app.exception_handler(_NotAuthenticated)
async def _redirect_to_login(request: Request, exc: _NotAuthenticated):
return RedirectResponse(url="/login", status_code=303)
# --- Auth --------------------------------------------------------------
@app.get("/login")
def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)):
if licensor is not None:
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse(request, "login.html", _template_context(request, None))
@app.post("/login")
def login_submit(
request: Request,
token: str = Form(...),
conn: Connection = Depends(get_connection),
):
try:
registry.authenticate(conn, token)
except registry.RegistrationError:
request.session["flash"] = "Invalid or revoked credential token."
request.session["flash_variant"] = "danger"
return RedirectResponse(url="/login", status_code=303)
request.session["token"] = token
return RedirectResponse(url="/", status_code=303)
@app.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
# --- Dashboard -----------------------------------------------------------
@app.get("/")
def dashboard(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
phases = registry.list_phase_manifests_for_licensor(conn, licensor.licensor_id)
return templates.TemplateResponse(
request, "dashboard.html", _template_context(request, licensor, phases=phases)
)
# --- Phases ----------------------------------------------------------------
@app.get("/phases/new")
def phase_new_form(
request: Request,
licensor: registry.Licensor = Depends(require_login),
):
if not registry.has_right(licensor.rights, "operator"):
return _redirect("/", request, "Operator rights required to register a Phase.", "danger")
return templates.TemplateResponse(request, "phase_new.html", _template_context(request, licensor))
@app.post("/phases/new")
def phase_new_submit(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
phase_id: str = Form(...),
milestone_release_name: str = Form(...),
source_revision: str = Form(...),
initial_target_amount: float = Form(...),
currency: str = Form(...),
future_license: str = Form(...),
degeneration_policy: str = Form(...),
longstop_at: str = Form(...),
ledger_uri: str = Form(..., alias="ledger"),
):
manifest = {
"framework": "TRF-0.1",
"license": "TRSL-0.1",
"phase": {
"id": phase_id,
"milestone_release": {"name": milestone_release_name, "source_revision": source_revision},
"initial_target": {"amount": initial_target_amount, "currency": currency},
"future_license": future_license,
"degeneration_policy": degeneration_policy,
"longstop_at": longstop_at,
"ledger": ledger_uri,
},
}
try:
control_plane.register_phase(conn, licensor, manifest)
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
return _redirect("/phases/new", request, str(exc), "danger")
return _redirect(f"/phases/{phase_id}", request, f"Phase {phase_id} registered.", "success")
@app.get("/phases/{phase_id}")
def phase_detail(
phase_id: str,
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
manifest = registry.get_phase_manifest(conn, phase_id)
if manifest is None:
raise HTTPException(status_code=404, detail="phase not found")
entries = ledger.get_ledger(conn, phase_id)
computed_metrics = metrics.compute_metrics(manifest, entries, metrics.utcnow())
return templates.TemplateResponse(
request,
"phase_detail.html",
_template_context(request, licensor, manifest=manifest, ledger=entries, metrics=computed_metrics),
)
@app.post("/phases/{phase_id}/ledger")
def phase_ledger_submit(
phase_id: str,
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
signing_key=Depends(get_signing_key),
entry_id: str = Form(...),
amount: float = Form(...),
currency: str = Form(...),
recognized_at: str = Form(...),
evidence_reference: str = Form(...),
extension_id: str = Form(...),
extension_version: str = Form(...),
):
entry_input = {
"id": entry_id,
"phase": phase_id,
"type": "development-credit",
"amount": amount,
"currency": currency,
"recognized_at": recognized_at,
"evidence_reference": evidence_reference,
"extension": {"id": extension_id, "version": extension_version},
}
try:
if registry.has_right(licensor.rights, "operator"):
control_plane.append_development_credit(conn, licensor, phase_id, entry_input, signing_key)
flash = f"Entry {entry_id} appended."
else:
control_plane.propose_ledger_entry(conn, licensor, phase_id, entry_input)
flash = f"Entry {entry_id} submitted for review."
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
return _redirect(f"/phases/{phase_id}", request, str(exc), "danger")
return _redirect(f"/phases/{phase_id}", request, flash, "success")
# --- Proposals (Operator+) --------------------------------------------------
@app.get("/proposals")
def proposals_list(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
if not registry.has_right(licensor.rights, "operator"):
return _redirect("/", request, "Operator rights required to review proposals.", "danger")
proposals = control_plane.list_proposed_entries(conn)
return templates.TemplateResponse(
request, "proposals.html", _template_context(request, licensor, proposals=proposals)
)
@app.post("/proposals/{proposal_id}/approve")
def proposal_approve(
proposal_id: int,
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
signing_key=Depends(get_signing_key),
):
try:
control_plane.approve_proposed_entry(conn, licensor, proposal_id, signing_key)
except control_plane.ControlPlaneError as exc:
return _redirect("/proposals", request, str(exc), "danger")
return _redirect("/proposals", request, f"Proposal {proposal_id} approved.", "success")
@app.post("/proposals/{proposal_id}/reject")
def proposal_reject(
proposal_id: int,
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
try:
control_plane.reject_proposed_entry(conn, licensor, proposal_id)
except control_plane.ControlPlaneError as exc:
return _redirect("/proposals", request, str(exc), "danger")
return _redirect("/proposals", request, f"Proposal {proposal_id} rejected.", "success")
# --- Credentials (Admin) ----------------------------------------------------
@app.get("/admin/credentials")
def admin_credentials_form(
request: Request,
licensor: registry.Licensor = Depends(require_login),
):
if not registry.has_right(licensor.rights, "admin"):
return _redirect("/", request, "Admin rights required.", "danger")
return templates.TemplateResponse(request, "admin_credentials.html", _template_context(request, licensor))
@app.post("/admin/credentials")
def admin_credentials_issue(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
credential_label: str = Form(...),
rights: str = Form(...),
):
if not registry.has_right(licensor.rights, "admin"):
return _redirect("/", request, "Admin rights required.", "danger")
try:
new_credential = control_plane.issue_user_credential(
conn, licensor, licensor.licensor_id, credential_label, rights
)
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
return _redirect("/admin/credentials", request, str(exc), "danger")
return templates.TemplateResponse(
request,
"admin_credentials.html",
_template_context(
request, licensor,
new_token=new_credential.token, new_token_label=new_credential.credential_label,
),
)
@app.post("/admin/credentials/revoke")
def admin_credentials_revoke(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
token: str = Form(...),
):
if not registry.has_right(licensor.rights, "admin"):
return _redirect("/", request, "Admin rights required.", "danger")
try:
control_plane.revoke_user_credential(conn, licensor, token)
except control_plane.ControlPlaneError as exc:
return _redirect("/admin/credentials", request, str(exc), "danger")
return _redirect("/admin/credentials", request, "Credential revoked.", "success")
# --- Audit log ---------------------------------------------------------
@app.get("/audit")
def audit_log(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
log = control_plane.get_audit_log(conn)
return templates.TemplateResponse(request, "audit.html", _template_context(request, licensor, log=log))

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Credentials — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Manage credentials (Admin)</span>
</wn-page-header>
{% if new_token %}
<wn-banner variant="warning">
New credential issued for <strong>{{ new_token_label }}</strong>. Copy it now —
it will not be shown again: <code>{{ new_token }}</code>
</wn-banner>
{% endif %}
<h3>Issue a new credential</h3>
<form class="wn-form" method="post" action="/admin/credentials">
<wn-field-row label="Credential label (human name)">
<wn-input name="credential_label" required></wn-input>
</wn-field-row>
<wn-field-row label="Rights">
<wn-select name="rights">
<option value="viewer">viewer</option>
<option value="contributor">contributor</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</wn-select>
</wn-field-row>
<wn-button type="submit" variant="primary">Issue credential</wn-button>
</form>
<h3>Revoke a credential</h3>
<form class="wn-form" method="post" action="/admin/credentials/revoke">
<wn-field-row label="Token to revoke">
<wn-input name="token" required></wn-input>
</wn-field-row>
<wn-button type="submit" variant="secondary">Revoke</wn-button>
</form>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Audit log — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Control Plane audit log</span>
</wn-page-header>
<p style="color:#888;">
This is the Control Plane's own record of who did what — independent of
the Trust Service's signed records, which only ever attest that the
<code>{{ session_licensor_id }}</code> tenant acted, never which
individual human (see <code>specs/TargetRevenueControlPlaneConcept.md</code> §5).
</p>
<table class="wn-plain">
<thead><tr><th>when</th><th>who</th><th>action</th><th>Phase</th><th>Trust Service record</th></tr></thead>
<tbody>
{% for row in log %}
<tr>
<td>{{ row.created_at }}</td>
<td>{{ row.actor_credential_label }}</td>
<td>{{ row.action }}</td>
<td>{{ row.phase_id }}</td>
<td>{{ row.trust_service_record_id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Target Revenue Control Plane{% endblock %}</title>
<link rel="stylesheet" href="/static/whynot-design/styles/colors_and_type.css">
<link rel="stylesheet" href="/static/whynot-design/styles/components.css">
<script type="importmap">
{
"imports": {
"lit": "https://esm.sh/lit@3.3.3"
}
}
</script>
<script type="module" src="/static/whynot-design/index.js"></script>
<style>
body { max-width: 960px; margin: 0 auto; padding: 1.5rem; }
form.wn-form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 480px; }
.wn-flash { margin: 1rem 0; }
table.wn-plain { width: 100%; border-collapse: collapse; }
table.wn-plain th, table.wn-plain td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #ddd; }
</style>
</head>
<body>
<wn-top-nav>
<span slot="brand">Target Revenue Control Plane</span>
</wn-top-nav>
{% if session_credential_label %}
<p>
Signed in as <strong>{{ session_credential_label }}</strong>
({{ session_rights }}) for <strong>{{ session_licensor_id }}</strong>
&middot; <a href="/">Dashboard</a>
{% if session_rights in ("operator", "admin") %}&middot; <a href="/proposals">Proposals</a>{% endif %}
{% if session_rights == "admin" %}&middot; <a href="/admin/credentials">Credentials</a>{% endif %}
&middot; <a href="/audit">Audit log</a>
&middot; <form method="post" action="/logout" style="display:inline"><button type="submit">Sign out</button></form>
</p>
{% endif %}
{% if flash %}
<wn-banner class="wn-flash" variant="{{ flash_variant | default('info') }}">{{ flash }}</wn-banner>
{% endif %}
{% block content %}{% endblock %}
<p style="margin-top:3rem;color:#888;font-size:0.85rem;">
This is a dry-run/pilot tool. No real Phase is authorized to go live via
this interface — see <code>workplans/TREV-WP-0008-governance-and-pilot-rollout.md</code> T05.
Visual language vendored from <code>whynot-design</code> — see
<code>static/whynot-design/VENDORED.md</code>.
</p>
</body>
</html>

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Dashboard — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Phases for {{ session_licensor_id }}</span>
</wn-page-header>
{% if session_rights in ("operator", "admin") %}
<p><a href="/phases/new">+ Register a new Phase</a></p>
{% endif %}
{% if phases %}
<table class="wn-plain">
<thead><tr><th>Phase</th><th>Milestone Release</th><th>Initial Target</th><th></th></tr></thead>
<tbody>
{% for phase in phases %}
<tr>
<td>{{ phase.phase.id }}</td>
<td>{{ phase.phase.milestone_release.name }}</td>
<td>{{ phase.phase.initial_target.amount }} {{ phase.phase.initial_target.currency }}</td>
<td><a href="/phases/{{ phase.phase.id }}">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<wn-empty-state>No Phases registered yet for this tenant.</wn-empty-state>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Sign in — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Sign in</span>
</wn-page-header>
<p>Paste the credential token you were issued by an Admin.</p>
<form class="wn-form" method="post" action="/login">
<wn-field-row label="Credential token">
<wn-input type="password" name="token" required></wn-input>
</wn-field-row>
<wn-button type="submit" variant="primary">Sign in</wn-button>
</form>
{% endblock %}

View file

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}{{ manifest.phase.id }} — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">{{ manifest.phase.milestone_release.name }}</span>
</wn-page-header>
<p><wn-tag>{{ manifest.phase.id }}</wn-tag></p>
<h3>Status</h3>
<table class="wn-plain">
<tr><td>Initial Target</td><td>{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}</td></tr>
<tr><td>Cumulative Development Credit</td><td>{{ metrics.facts.cumulative_development_credit }}</td></tr>
<tr><td>Cumulative Remission Credit</td><td>{{ metrics.facts.cumulative_remission_credit }}</td></tr>
<tr><td>Outstanding Target</td><td>{{ metrics.facts.outstanding_target }}</td></tr>
<tr><td>Target satisfaction</td><td>{{ metrics.calculations.target_satisfaction_percentage }}%</td></tr>
<tr><td>Converted</td><td>{{ metrics.facts.is_converted }}</td></tr>
</table>
<h3>Ledger ({{ ledger | length }} entries)</h3>
{% if ledger %}
<table class="wn-plain">
<thead><tr><th>id</th><th>type</th><th>amount</th><th>recognized_at</th></tr></thead>
<tbody>
{% for entry in ledger %}
<tr><td>{{ entry.id }}</td><td>{{ entry.type }}</td><td>{{ entry.amount }}</td><td>{{ entry.recognized_at }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<wn-empty-state>No ledger entries yet.</wn-empty-state>
{% endif %}
{% if session_rights in ("contributor", "operator", "admin") %}
<h3>
{% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %}
</h3>
<form class="wn-form" method="post" action="/phases/{{ manifest.phase.id }}/ledger">
<wn-field-row label="Entry id (trsl:entry:...)">
<wn-input name="entry_id" required></wn-input>
</wn-field-row>
<wn-field-row label="Amount">
<wn-input type="number" step="0.01" name="amount" required></wn-input>
</wn-field-row>
<wn-field-row label="Currency">
<wn-input name="currency" value="{{ manifest.phase.initial_target.currency }}" required></wn-input>
</wn-field-row>
<wn-field-row label="Recognized at (ISO 8601)">
<wn-input name="recognized_at" required></wn-input>
</wn-field-row>
<wn-field-row label="Evidence reference">
<wn-input name="evidence_reference" required></wn-input>
</wn-field-row>
<wn-field-row label="Extension id (trsl:extension:...)">
<wn-input name="extension_id" required></wn-input>
</wn-field-row>
<wn-field-row label="Extension version">
<wn-input name="extension_version" required></wn-input>
</wn-field-row>
<wn-button type="submit" variant="primary">
{% if session_rights == "contributor" %}Submit for review{% else %}Append{% endif %}
</wn-button>
</form>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Register a Phase — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Register a new Phase (Operator+)</span>
</wn-page-header>
<p style="color:#888;">
This registers a real Phase Manifest against the hosted Trust Service.
It does not itself authorize going live for a repo — see
<code>workplans/TREV-WP-0008-governance-and-pilot-rollout.md</code> T05.
</p>
<form class="wn-form" method="post" action="/phases/new">
<wn-field-row label="Phase id (trsl:phase:...)">
<wn-input name="phase_id" required></wn-input>
</wn-field-row>
<wn-field-row label="Milestone Release name">
<wn-input name="milestone_release_name" required></wn-input>
</wn-field-row>
<wn-field-row label="Source revision">
<wn-input name="source_revision" required></wn-input>
</wn-field-row>
<wn-field-row label="Initial Target amount">
<wn-input type="number" step="0.01" name="initial_target_amount" required></wn-input>
</wn-field-row>
<wn-field-row label="Currency (ISO 4217)">
<wn-input name="currency" value="EUR" required></wn-input>
</wn-field-row>
<wn-field-row label="Future License">
<wn-select name="future_license">
<option value="MIT">MIT</option>
<option value="Apache-2.0">Apache-2.0</option>
</wn-select>
</wn-field-row>
<wn-field-row label="Degeneration policy">
<wn-input name="degeneration_policy" value="trsl:policy:linear-longstop-v0@1.0" required></wn-input>
</wn-field-row>
<wn-field-row label="Longstop date (ISO 8601)">
<wn-input name="longstop_at" required></wn-input>
</wn-field-row>
<wn-field-row label="Ledger URI (relative path or URL)">
<wn-input name="ledger" required></wn-input>
</wn-field-row>
<wn-button type="submit" variant="primary">Register Phase</wn-button>
</form>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Proposals — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Pending Development Credit proposals (Operator+)</span>
</wn-page-header>
{% if proposals %}
<table class="wn-plain">
<thead><tr><th>id</th><th>Phase</th><th>type</th><th>amount</th><th>proposed at</th><th></th></tr></thead>
<tbody>
{% for p in proposals %}
<tr>
<td>{{ p.id }}</td>
<td>{{ p.phase_id }}</td>
<td>{{ p.entry.type }}</td>
<td>{{ p.entry.amount }}</td>
<td>{{ p.created_at }}</td>
<td>
<form method="post" action="/proposals/{{ p.id }}/approve" style="display:inline">
<wn-button type="submit" variant="primary">Approve</wn-button>
</form>
<form method="post" action="/proposals/{{ p.id }}/reject" style="display:inline">
<wn-button type="submit" variant="secondary">Reject</wn-button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<wn-empty-state>No pending proposals.</wn-empty-state>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,27 @@
# Vendored from `whynot-design`
Source: local `~/whynot-design` clone, commit `4b62cffc86496d587ac8d48a8e199624bc4a5c1f`
(2026-07-30), package version `0.4.1`.
Vendored (copied verbatim, no modifications): `src/elements/*.js`,
`src/index.js`, `src/styles/*.css`. These files only import from each
other by relative path plus the bare specifier `"lit"` — no other
external dependency, confirmed before vendoring
(`workplans/TREV-WP-0009-target-revenue-control-plane.md` T04).
**Not vendored:** Lit itself (a third-party runtime, resolved via an
import map to a CDN — see `service/control_plane_templates/base.html`
rather than vendored alongside whynot-design's own code). This is a
disclosed v0 trade-off: whynot-design's own code stays fully local and
traceable to this exact commit, matching this framework's offline-first
ethos for *its own* code, while the third-party JS runtime dependency
does not. Vendoring Lit locally (with a matching import map for its own
internal bare-specifier imports) is reasonable future work, not attempted
here to keep T04's scope bounded.
**Update policy:** this is a manual vendor copy, not an npm-managed
dependency — `whynot-design`'s own `CONSUMING.md` describes a
lockfile-based drift-tracking mechanism (`.whynot-design.lock`,
`ir/manifest.json`) for npm consumers; this repo does not use npm for its
Python-based service, so re-vendoring means repeating this copy manually
against a newer commit, updating the commit hash above.

View file

@ -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 (S0S4) */
.wn-stage-dot--s0 .wn-dot__bullet { background: var(--status-raw); }
.wn-stage-dot--s1 .wn-dot__bullet { background: var(--status-weak); }
.wn-stage-dot--s2 .wn-dot__bullet { background: var(--status-medium); }
.wn-stage-dot--s3 .wn-dot__bullet { background: var(--status-strong); }
.wn-stage-dot--s4 .wn-dot__bullet { background: var(--status-commercial); }
/* phase states (todo / active / done / warn) — numbered phases, distinct from signal */
.wn-phase-dot__bullet {
width: 18px; height: 18px; border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--paper);
display: inline-flex; align-items: center; justify-content: center;
font: 500 10px/1 var(--ff-mono); color: var(--fg-3);
flex: none;
}
.wn-phase-dot--todo .wn-phase-dot__bullet { border-color: var(--border-strong); color: var(--fg-3); background: var(--paper); }
.wn-phase-dot--active .wn-phase-dot__bullet { border-color: var(--ink); color: var(--ink); background: var(--paper); box-shadow: 0 0 0 3px rgba(10,10,10,0.06); }
.wn-phase-dot--done .wn-phase-dot__bullet { border-color: var(--ink); color: var(--paper); background: var(--ink); }
.wn-phase-dot--warn .wn-phase-dot__bullet { border-color: var(--hi-2); color: var(--hi-ink); background: var(--hi); }
/* ====== Stamp ====== */
.wn-stamp {
display: inline-block;
background: var(--hi);
color: var(--hi-ink);
padding: 5px 10px 3px;
font: 500 10px/1 var(--ff-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
transform: rotate(-1.5deg);
}
/* ====== Icon ====== */
.wn-icon { stroke-width: 1.5; stroke: currentColor; fill: none; display: inline-block; vertical-align: middle; }
.wn-icon--sm { width: 14px; height: 14px; }
.wn-icon--md { width: 16px; height: 16px; }
.wn-icon--lg { width: 20px; height: 20px; }
.wn-icon--xl { width: 24px; height: 24px; }
/* ====== Card ====== */
.wn-card {
background: var(--paper);
border: 1px solid var(--border);
border-radius: var(--r-2);
padding: var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-3);
position: relative;
}
.wn-card--inset { background: var(--paper-2); border-color: var(--border); }
.wn-card--recessed { background: var(--paper-3); }
.wn-card--lg { padding: var(--sp-6); border-radius: var(--r-3); }
.wn-card--sm { padding: var(--sp-4); gap: var(--sp-2); }
.wn-card--clickable { cursor: pointer; transition: border-color 120ms ease; }
.wn-card--clickable:hover { border-color: var(--ink); }
.wn-card--clickable:hover::before {
content: ""; position: absolute; left: -1px; top: -1px; bottom: -1px;
width: 2px; background: var(--ink); border-radius: 2px 0 0 2px;
}
.wn-card__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); }
.wn-card__title { font: 500 17px/1.35 var(--ff-sans); margin: 4px 0 8px; color: var(--fg-1); }
.wn-card__foot {
display: flex; justify-content: space-between; gap: var(--sp-3);
padding-top: var(--sp-3); margin-top: 4px;
border-top: 1px solid var(--border-soft);
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-3);
}
/* ====== Field row (label + value, 3-col grid) ====== */
.wn-field-row {
display: grid;
grid-template-columns: 200px 1fr auto;
gap: var(--sp-4) var(--sp-5);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border-soft);
align-items: baseline;
}
.wn-field-row:last-child { border-bottom: 0; }
.wn-field-row__label {
font: 500 11px/1.5 var(--ff-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-field-row__value { font: 400 15px/1.55 var(--ff-sans); color: var(--fg-1); }
.wn-field-row__aside { font: 400 12px var(--ff-mono); color: var(--fg-3); text-align: right; }
.wn-field-row--stacked { grid-template-columns: 1fr; gap: 6px; }
.wn-field-row--narrow { grid-template-columns: 120px 1fr; }
/* ====== Form inputs ====== */
.wn-form-label {
font: 500 11px/1 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
display: block;
margin-bottom: 6px;
}
.wn-input, .wn-textarea, .wn-select {
font: 400 14px var(--ff-sans);
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--paper);
border-radius: var(--r-1);
color: var(--fg-1);
outline: none;
width: 100%;
transition: border-color 120ms ease;
box-sizing: border-box;
}
.wn-input:hover, .wn-textarea:hover, .wn-select:hover { border-color: var(--border-strong); }
.wn-input:focus, .wn-textarea:focus, .wn-select:focus { border-color: var(--ink); }
.wn-input::placeholder, .wn-textarea::placeholder { color: var(--ink-5); }
.wn-input[disabled], .wn-textarea[disabled], .wn-select[disabled] {
background: var(--paper-2); color: var(--fg-3); cursor: not-allowed;
}
.wn-textarea { resize: vertical; min-height: 96px; font-family: var(--ff-sans); }
.wn-select {
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%235C5C5C' stroke-width='1.5'><path d='M1 1l4 4 4-4'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
.wn-input--error, .wn-textarea--error, .wn-select--error {
border-color: var(--ink); border-bottom-width: 2px;
}
.wn-form-help { font: 400 11px var(--ff-mono); color: var(--fg-3); margin-top: 6px; display: block; }
.wn-form-error { font: 400 11px var(--ff-mono); color: var(--ink); margin-top: 6px; display: block; }
/* Search input — extracted from TopNav, also usable standalone */
.wn-search {
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: var(--r-1);
background: var(--paper);
color: var(--fg-3);
font: 400 12px var(--ff-mono);
min-width: 200px;
transition: border-color 120ms ease;
}
.wn-search:focus-within { border-color: var(--ink); }
.wn-search input {
border: 0; outline: 0; background: none; flex: 1;
font: inherit; color: var(--fg-1); padding: 0;
}
.wn-search input::placeholder { color: var(--ink-5); }
.wn-search__kbd {
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: 2px;
font-size: 10px;
color: var(--fg-3);
}
/* ====== Breadcrumb ====== */
.wn-breadcrumb {
display: flex; flex-wrap: wrap; align-items: center;
gap: 6px;
font: 400 12px/1.5 var(--ff-mono);
color: var(--fg-3);
margin-bottom: var(--sp-4);
}
.wn-breadcrumb a {
color: var(--fg-2);
text-decoration: none;
padding: 2px 0;
border-bottom: 1px solid transparent;
transition: border-color 120ms ease, color 120ms ease;
}
.wn-breadcrumb a:hover { color: var(--fg-1); border-bottom-color: var(--border-strong); }
.wn-breadcrumb__sep { color: var(--ink-5); user-select: none; }
.wn-breadcrumb__current { color: var(--fg-1); }
/* ====== Modal / Dialog ====== */
.wn-modal__backdrop {
position: fixed; inset: 0;
background: rgba(10, 10, 10, 0.40);
display: flex; align-items: center; justify-content: center;
z-index: 100;
padding: var(--sp-5);
}
.wn-modal__panel {
background: var(--paper);
border-radius: var(--r-3);
box-shadow: var(--shadow-3);
max-width: 560px; width: 100%;
max-height: calc(100vh - 64px);
display: flex; flex-direction: column;
overflow: hidden;
}
.wn-modal__head {
padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border);
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4);
}
.wn-modal__title { font: 500 20px/1.25 var(--ff-sans); margin: 0; color: var(--fg-1); }
.wn-modal__close {
background: none; border: 0; cursor: pointer; padding: 4px;
color: var(--fg-3); border-radius: var(--r-1);
transition: color 120ms ease;
}
.wn-modal__close:hover { color: var(--fg-1); }
.wn-modal__body {
padding: var(--sp-5) var(--sp-6);
overflow-y: auto;
flex: 1;
font: 400 15px/1.6 var(--ff-sans);
color: var(--fg-1);
}
.wn-modal__foot {
padding: var(--sp-4) var(--sp-6) var(--sp-5);
border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: var(--sp-2);
}
/* ====== Table ======
* Note: shadow-DOM-rendered rows can't be children of a real <table> (the
* HTML table model rejects unknown elements between <table> and <tr>). The
* <wn-table> component therefore renders a CSS-grid imitation. For real
* <table> markup (Django QuerySet rendering, etc.) use these classes
* directly on <table>/<tr>/<td> elements see also the .wn-table--native
* variant below.
*/
/* CSS-grid imitation (default <wn-table>) */
.wn-table {
width: 100%;
font-size: var(--fs-sm);
display: flex;
flex-direction: column;
}
.wn-table__thead { border-bottom: 1px solid var(--border); }
.wn-table__tbody { display: flex; flex-direction: column; }
.wn-table__tr {
display: grid;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-soft);
align-items: baseline;
}
.wn-table__tr:last-child { border-bottom: 0; }
.wn-table__tr--head { border-bottom: 0; padding: var(--sp-3) var(--sp-4); }
.wn-table__th {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-table__td {
color: var(--fg-1);
line-height: 1.5;
font-size: var(--fs-sm);
}
.wn-table--compact .wn-table__tr { padding: var(--sp-2) var(--sp-3); }
.wn-table__cell--mono { font-family: var(--ff-mono); color: var(--fg-2); font-size: 12px; }
.wn-table__cell--meta { color: var(--fg-3); font: 400 12px var(--ff-mono); }
.wn-table__cell--right { text-align: right; }
/* Native <table> variant — for Django QuerySet rendering etc. */
.wn-table--native {
border-collapse: collapse;
display: table;
}
.wn-table--native thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-table--native tbody td {
padding: var(--sp-4);
border-bottom: 1px solid var(--border-soft);
vertical-align: top;
color: var(--fg-1);
font-size: var(--fs-sm);
line-height: 1.5;
}
.wn-table--native tbody tr:hover { background: var(--paper-2); }
.wn-table--native tbody tr:last-child td { border-bottom: 0; }
/* ====== Banner / Toast (success / info / warn) ====== */
.wn-banner {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border);
background: var(--paper);
border-radius: var(--r-2);
font: 400 14px/1.5 var(--ff-sans);
color: var(--fg-1);
position: relative;
}
.wn-banner__icon { color: var(--fg-2); flex: none; padding-top: 2px; }
.wn-banner__body { flex: 1; }
.wn-banner__title {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
margin: 0 0 4px;
}
.wn-banner__dismiss {
background: none; border: 0; cursor: pointer;
color: var(--fg-3); padding: 4px;
}
.wn-banner__dismiss:hover { color: var(--fg-1); }
.wn-banner--success { border-left: 2px solid var(--ink); }
.wn-banner--warn { border-left: 2px solid var(--hi-2); background: #FFFCEB; }
.wn-banner--error { border-left: 2px solid var(--ink); background: var(--paper); }
.wn-banner--info { border-left: 2px solid var(--border-strong); }
.wn-toast-region {
position: fixed;
bottom: var(--sp-5); right: var(--sp-5);
display: flex; flex-direction: column; gap: var(--sp-2);
z-index: 200;
max-width: 380px;
}
.wn-toast { box-shadow: var(--shadow-3); }
/* ====== Empty state ====== */
.wn-empty {
border: 1px dashed var(--border-strong);
border-radius: var(--r-2);
padding: var(--sp-7);
display: flex; flex-direction: column; align-items: center;
gap: var(--sp-2);
text-align: center;
color: var(--fg-3);
}
.wn-empty__icon { color: var(--fg-3); margin-bottom: var(--sp-2); }
.wn-empty__title { font: 500 14px var(--ff-sans); color: var(--fg-2); margin: 0; }
.wn-empty__body { font: 400 13px/1.5 var(--ff-sans); color: var(--fg-3); max-width: 40ch; margin: 0; }
.wn-empty__cta { margin-top: var(--sp-2); }
/* ====== Top navigation ====== */
.wn-topnav {
height: 56px;
background: rgba(255, 255, 255, 0.92);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
gap: var(--sp-6);
padding: 0 var(--sp-5);
position: sticky; top: 0; z-index: 10;
}
.wn-topnav__brand { display: flex; align-items: center; gap: 10px; font: 500 14px var(--ff-sans); }
.wn-topnav__brand img { width: 22px; height: 22px; }
.wn-topnav__brand-slug { font-family: var(--ff-mono); font-size: 12px; color: var(--fg-3); letter-spacing: 0.04em; }
.wn-topnav__links { display: flex; gap: 22px; }
.wn-topnav__link {
font: 500 13px var(--ff-sans);
color: var(--fg-2);
text-decoration: none;
padding: 6px 0;
border-bottom: 1px solid transparent;
transition: color 120ms ease, border-color 120ms ease;
}
.wn-topnav__link:hover { color: var(--fg-1); }
.wn-topnav__link--active { color: var(--fg-1); border-bottom-color: var(--ink); }
.wn-topnav__right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
/* ====== Sidebar ====== */
.wn-sidebar {
width: 240px;
flex: none;
background: var(--paper-2);
border-right: 1px solid var(--border);
padding: var(--sp-5) var(--sp-4);
display: flex; flex-direction: column; gap: var(--sp-5);
height: calc(100vh - 56px);
position: sticky; top: 56px;
overflow-y: auto;
}
.wn-sidebar__group { display: flex; flex-direction: column; gap: 8px; }
.wn-sidebar__group-label { padding-left: 12px; }
.wn-sidebar__item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
border-radius: 4px;
color: var(--fg-2);
font: 500 13px var(--ff-sans);
cursor: pointer; text-decoration: none;
transition: background 120ms ease, color 120ms ease;
}
.wn-sidebar__item:hover { color: var(--fg-1); }
.wn-sidebar__item--active {
color: var(--fg-1); background: var(--paper);
box-shadow: 0 0 0 1px var(--border) inset;
}
.wn-sidebar__item--doc { font-family: var(--ff-mono); font-size: 12px; }
.wn-sidebar__count { margin-left: auto; font: 400 11px var(--ff-mono); color: var(--fg-3); }
.wn-sidebar__footer { margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--border); }
.wn-sidebar__activation {
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-2);
}
.wn-sidebar__activation-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--hi-2); }
/* ====== Page header ====== */
.wn-page-header {
margin-bottom: var(--sp-6);
display: flex; flex-direction: column; gap: 8px;
}
.wn-page-header__row { display: flex; align-items: flex-end; gap: var(--sp-5); }
.wn-page-header__title {
font: 500 32px/1.15 var(--ff-sans);
letter-spacing: -0.015em;
margin: 0; flex: 1; color: var(--fg-1);
}
.wn-page-header__actions { display: flex; gap: 8px; flex-wrap: wrap; }
.wn-page-header__lede {
font: 400 16px/1.55 var(--ff-sans);
color: var(--fg-2);
margin: 0;
max-width: 60ch;
}
/* ====== Pipeline ====== */
.wn-pipeline {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0;
position: relative;
margin: 0 0 var(--sp-6);
}
.wn-pipeline__stage {
padding: 10px 12px 14px;
border-top: 2px solid var(--border);
display: flex; flex-direction: column; gap: 4px;
position: relative;
}
.wn-pipeline__stage--done { border-top-color: var(--ink); }
.wn-pipeline__stage--active { border-top-color: var(--hi-2); }
.wn-pipeline__num {
font: 500 10px/1 var(--ff-mono); letter-spacing: 0.1em; text-transform: uppercase;
color: var(--fg-3);
}
.wn-pipeline__stage--done .wn-pipeline__num,
.wn-pipeline__stage--active .wn-pipeline__num { color: var(--fg-1); }
.wn-pipeline__name { font: 500 14px/1.25 var(--ff-sans); color: var(--fg-1); }
.wn-pipeline__stage--pending .wn-pipeline__name { color: var(--fg-3); }
.wn-pipeline__meta { font: 400 11px/1.35 var(--ff-mono); color: var(--fg-3); }
.wn-pipeline__arrow {
position: absolute; top: -8px; right: -7px;
font: 400 14px var(--ff-mono); color: var(--ink-5);
}
.wn-pipeline__stage--done .wn-pipeline__arrow,
.wn-pipeline__stage--active .wn-pipeline__arrow { color: var(--ink); }
/* ====== Prototype card (combined card variant) ====== */
.wn-prototype-card { /* extends .wn-card */ }
.wn-prototype-card__qrow {
display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px;
font-size: 13px; color: var(--fg-1);
}
.wn-prototype-card__qkey {
font: 500 11px/1.5 var(--ff-mono);
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-3);
}
.wn-prototype-card__qval { line-height: 1.45; }
/* ====== Layout helpers ====== */
.wn-main { padding: 40px 48px 80px; max-width: 1180px; }
.wn-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
`;
let _sheet = null;
export function getSharedSheet() {
if (!_sheet) {
_sheet = new CSSStyleSheet();
_sheet.replaceSync(SHARED_CSS);
}
return _sheet;
}

View file

@ -0,0 +1,164 @@
/* =============================================================
* @whynot/design atoms.js
* ------------------------------------------------------------
* <wn-button>, <wn-tag>, <wn-eyebrow>, <wn-stamp>,
* <wn-stage-dot>, <wn-phase-dot>, <wn-icon>
*
* Shadow-DOM components. Each adopts the shared component
* stylesheet so utility classes inside the shadow root work.
* Token CSS variables cascade through shadow boundaries
* because they're inherited properties.
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { getSharedSheet } from "./_styles.js";
import { ICON_PATHS } from "./icons.js";
class WnBase extends LitElement {
static styles = [];
connectedCallback() {
super.connectedCallback();
// Adopt the shared sheet on first connect, after super() has built the shadow root.
const root = this.shadowRoot;
if (root && !root.adoptedStyleSheets.includes(getSharedSheet())) {
root.adoptedStyleSheets = [...root.adoptedStyleSheets, getSharedSheet()];
}
}
}
/* ---------- <wn-button> ---------- */
export class WnButton extends WnBase {
static properties = {
variant: { type: String, reflect: true },
size: { type: String, reflect: true },
icon: { type: String },
iconEnd: { type: String, attribute: "icon-end" },
type: { type: String },
disabled: { type: Boolean, reflect: true },
href: { type: String },
};
constructor() {
super();
this.variant = "secondary";
this.size = "md";
this.type = "button";
this.disabled = false;
}
render() {
const cls = [
"wn-btn",
this.variant && this.variant !== "secondary" ? `wn-btn--${this.variant}` : "",
this.size === "sm" ? "wn-btn--sm" : this.size === "lg" ? "wn-btn--lg" : "",
].filter(Boolean).join(" ");
const iconStart = this.icon
? html`<wn-icon name=${this.icon} size="sm" class="wn-btn__icon"></wn-icon>`
: nothing;
const iconEnd = this.iconEnd
? html`<wn-icon name=${this.iconEnd} size="sm" class="wn-btn__icon"></wn-icon>`
: nothing;
if (this.href) {
return html`<a class=${cls} href=${this.href} part="button"
aria-disabled=${this.disabled ? "true" : "false"}>${iconStart}<slot></slot>${iconEnd}</a>`;
}
return html`<button class=${cls} part="button"
type=${this.type} ?disabled=${this.disabled}>${iconStart}<slot></slot>${iconEnd}</button>`;
}
}
/* ---------- <wn-tag> ---------- */
export class WnTag extends WnBase {
static properties = {
active: { type: Boolean, reflect: true },
draft: { type: Boolean, reflect: true },
};
render() {
const cls = ["wn-tag",
this.active ? "wn-tag--active" : "",
this.draft ? "wn-tag--draft" : "",
].filter(Boolean).join(" ");
return html`<span class=${cls} part="tag"><slot></slot></span>`;
}
}
/* ---------- <wn-eyebrow> ---------- */
export class WnEyebrow extends WnBase {
static properties = { strong: { type: Boolean, reflect: true } };
render() {
const cls = "wn-eyebrow" + (this.strong ? " wn-eyebrow--strong" : "");
return html`<span class=${cls} part="eyebrow"><slot></slot></span>`;
}
}
/* ---------- <wn-stamp> ---------- */
export class WnStamp extends WnBase {
render() { return html`<span class="wn-stamp" part="stamp"><slot></slot></span>`; }
}
/* ---------- <wn-stage-dot> ---------- */
export class WnStageDot extends WnBase {
static properties = {
level: { type: String, reflect: true },
label: { type: String },
};
constructor() { super(); this.level = "S2"; }
render() {
const lvl = String(this.level || "S2").toLowerCase();
const cls = `wn-dot wn-stage-dot wn-stage-dot--${lvl}`;
return html`
<span class=${cls} part="root">
<span class="wn-dot__bullet"></span>
<slot>${this.label || this.level}</slot>
</span>`;
}
}
/* ---------- <wn-phase-dot> ---------- */
export class WnPhaseDot extends WnBase {
static properties = {
state: { type: String, reflect: true },
num: { type: String, reflect: true },
};
constructor() { super(); this.state = "todo"; this.num = ""; }
render() {
const cls = `wn-phase-dot wn-phase-dot--${this.state}`;
const glyph = this.state === "done" ? "✓" : this.num;
return html`
<span class=${cls} part="root">
<span class="wn-phase-dot__bullet">${glyph}</span>
<slot></slot>
</span>`;
}
}
/* ---------- <wn-icon> ---------- */
export class WnIcon extends WnBase {
static properties = {
name: { type: String, reflect: true },
size: { type: String, reflect: true },
};
constructor() { super(); this.size = "md"; }
render() {
const path = ICON_PATHS[this.name];
const cls = `wn-icon wn-icon--${this.size || "md"}`;
if (!path) {
return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true" part="svg">
<rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor"/>
</svg>`;
}
return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true" part="svg">${
path.map(d => html`<path d=${d}></path>`)
}</svg>`;
}
}
export function defineAtoms() {
if (!customElements.get("wn-button")) customElements.define("wn-button", WnButton);
if (!customElements.get("wn-tag")) customElements.define("wn-tag", WnTag);
if (!customElements.get("wn-eyebrow")) customElements.define("wn-eyebrow", WnEyebrow);
if (!customElements.get("wn-stamp")) customElements.define("wn-stamp", WnStamp);
if (!customElements.get("wn-stage-dot")) customElements.define("wn-stage-dot", WnStageDot);
if (!customElements.get("wn-phase-dot")) customElements.define("wn-phase-dot", WnPhaseDot);
if (!customElements.get("wn-icon")) customElements.define("wn-icon", WnIcon);
}
export { WnBase };

View file

@ -0,0 +1,206 @@
/* =============================================================
* @whynot/design chrome.js
* ------------------------------------------------------------
* <wn-top-nav>, <wn-sidebar> / <wn-sidebar-group> /
* <wn-sidebar-item>, <wn-page-header>, <wn-pipeline>,
* <wn-prototype-card>
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { WnBase } from "./atoms.js";
/* ---------- <wn-top-nav> ---------- */
export class WnTopNav extends WnBase {
static properties = {
logoSrc: { type: String, attribute: "logo-src" },
brand: { type: String },
slug: { type: String },
};
constructor() { super(); this.brand = "whynot"; this.slug = "control"; }
render() {
return html`
<nav class="wn-topnav" part="nav">
<div class="wn-topnav__brand">
${this.logoSrc ? html`<img src=${this.logoSrc} alt="">` : nothing}
<span>${this.brand}</span>
${this.slug ? html`<span class="wn-topnav__brand-slug">/ ${this.slug}</span>` : nothing}
</div>
<div class="wn-topnav__links"><slot name="links"></slot></div>
<div class="wn-topnav__right"><slot name="right"></slot></div>
</nav>
`;
}
}
/* ---------- <wn-sidebar> ---------- */
export class WnSidebar extends WnBase {
static properties = { activation: { type: String } };
render() {
return html`
<aside class="wn-sidebar" part="sidebar">
<slot></slot>
${this.activation
? html`<div class="wn-sidebar__footer">
<div class="wn-sidebar__activation">
<span class="wn-sidebar__activation-dot"></span>
<span>${this.activation}</span>
</div>
</div>`
: nothing}
</aside>
`;
}
}
export class WnSidebarGroup extends WnBase {
static properties = { label: { type: String } };
render() {
return html`
<div class="wn-sidebar__group" part="group">
${this.label ? html`<wn-eyebrow class="wn-sidebar__group-label">${this.label}</wn-eyebrow>` : nothing}
<slot></slot>
</div>
`;
}
}
export class WnSidebarItem extends WnBase {
static properties = {
href: { type: String },
icon: { type: String },
active: { type: Boolean, reflect: true },
count: { type: String },
variant: { type: String, reflect: true },
};
render() {
const cls = ["wn-sidebar__item",
this.active ? "wn-sidebar__item--active" : "",
this.variant === "doc" ? "wn-sidebar__item--doc" : "",
].filter(Boolean).join(" ");
const inner = html`
${this.icon ? html`<wn-icon name=${this.icon} size=${this.variant === "doc" ? "sm" : "md"}></wn-icon>` : nothing}
<slot></slot>
${this.count ? html`<span class="wn-sidebar__count">${this.count}</span>` : nothing}
`;
return this.href
? html`<a class=${cls} href=${this.href} part="item">${inner}</a>`
: html`<div class=${cls} part="item">${inner}</div>`;
}
}
/* ---------- <wn-page-header> ---------- */
export class WnPageHeader extends WnBase {
static properties = {
eyebrow: { type: String },
title: { type: String },
lede: { type: String },
hasActions: { state: true },
};
constructor() { super(); this.hasActions = false; }
_onSlot() { this.hasActions = !!this.querySelector('[slot="actions"]'); }
render() {
return html`
<header class="wn-page-header" part="root">
${this.eyebrow ? html`<wn-eyebrow>${this.eyebrow}</wn-eyebrow>` : nothing}
<slot name="eyebrow"></slot>
<div class="wn-page-header__row">
<h1 class="wn-page-header__title">${this.title}<slot name="title"></slot></h1>
<div class="wn-page-header__actions" ?hidden=${!this.hasActions}>
<slot name="actions" @slotchange=${this._onSlot}></slot>
</div>
</div>
${this.lede ? html`<p class="wn-page-header__lede">${this.lede}</p>` : nothing}
<slot name="lede"></slot>
</header>
`;
}
}
/* ---------- <wn-pipeline> ---------- */
export class WnPipeline extends WnBase {
static properties = {
stages: { type: Array },
activeIdx: { type: Number, attribute: "active-idx" },
};
constructor() {
super();
this.stages = [
{ num: "Stage 0", name: "Raw idea", meta: "inbox/" },
{ num: "Stage 1", name: "Triage", meta: "" },
{ num: "Stage 2", name: "Prototype card", meta: "prototypes/" },
{ num: "Stage 3", name: "Experiment", meta: "" },
{ num: "Stage 4", name: "Signal review", meta: "" },
];
this.activeIdx = 0;
}
render() {
return html`
<div class="wn-pipeline" part="root">
${this.stages.map((s, i) => {
const state = i < this.activeIdx ? "done" : i === this.activeIdx ? "active" : "pending";
const cls = `wn-pipeline__stage wn-pipeline__stage--${state}`;
return html`
<div class=${cls}>
<span class="wn-pipeline__num">${s.num}</span>
<span class="wn-pipeline__name">${s.name}</span>
${s.meta ? html`<span class="wn-pipeline__meta">${s.meta}</span>` : nothing}
${i > 0 ? html`<span class="wn-pipeline__arrow">→</span>` : nothing}
</div>
`;
})}
</div>
`;
}
}
/* ---------- <wn-prototype-card> ---------- */
export class WnPrototypeCard extends WnBase {
static properties = {
cardId: { type: String, attribute: "card-id", reflect: true },
signal: { type: String, reflect: true },
stageLabel: { type: String, attribute: "stage-label" },
href: { type: String },
};
constructor() { super(); this.signal = "S1"; }
_onClick() {
if (this.href) window.location.href = this.href;
this.dispatchEvent(new CustomEvent("wn-open", { detail: { id: this.cardId }, bubbles: true, composed: true }));
}
render() {
const clickable = !!this.href;
const cls = "wn-card wn-prototype-card" + (clickable ? " wn-card--clickable" : "");
return html`
<article class=${cls}
role=${clickable ? "button" : nothing}
tabindex=${clickable ? "0" : nothing}
@click=${clickable ? this._onClick : nothing}
part="card">
<header class="wn-card__head">
<wn-eyebrow>${this.cardId ? this.cardId + " · " : ""}Prototype</wn-eyebrow>
<wn-stage-dot level=${this.signal}>${this.stageLabel || this.signal}</wn-stage-dot>
</header>
<h3 class="wn-card__title"><slot name="pitch"></slot></h3>
<div class="wn-prototype-card__qrow">
<span class="wn-prototype-card__qkey">Learning q.</span>
<span class="wn-prototype-card__qval"><slot name="learning"></slot></span>
<span class="wn-prototype-card__qkey">Smallest test</span>
<span class="wn-prototype-card__qval"><slot name="test"></slot></span>
</div>
<footer class="wn-card__foot">
<span><slot name="target"></slot></span>
<span>${this.signal} signal</span>
</footer>
</article>
`;
}
}
export function defineChrome() {
if (!customElements.get("wn-top-nav")) customElements.define("wn-top-nav", WnTopNav);
if (!customElements.get("wn-sidebar")) customElements.define("wn-sidebar", WnSidebar);
if (!customElements.get("wn-sidebar-group")) customElements.define("wn-sidebar-group", WnSidebarGroup);
if (!customElements.get("wn-sidebar-item")) customElements.define("wn-sidebar-item", WnSidebarItem);
if (!customElements.get("wn-page-header")) customElements.define("wn-page-header", WnPageHeader);
if (!customElements.get("wn-pipeline")) customElements.define("wn-pipeline", WnPipeline);
if (!customElements.get("wn-prototype-card")) customElements.define("wn-prototype-card", WnPrototypeCard);
}

View file

@ -0,0 +1,205 @@
/* =============================================================
* @whynot/design form.js
* ------------------------------------------------------------
* <wn-input>, <wn-textarea>, <wn-select>,
* <wn-search-input>, <wn-field-row>
*
* Each wraps a real native element. Form participation works
* because the native input is part of the light DOM via the
* `name` attribute being copied through; for richer integration
* use ElementInternals (deferred see CHANGELOG).
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { WnBase } from "./atoms.js";
/* ---------- <wn-input> ---------- */
export class WnInput extends WnBase {
static properties = {
name: { type: String, reflect: true },
type: { type: String, reflect: true },
value: { type: String },
placeholder: { type: String },
required: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
readonly: { type: Boolean, reflect: true },
autocomplete:{ type: String },
error: { type: Boolean, reflect: true },
help: { type: String },
errorText: { type: String, attribute: "error-text" },
};
constructor() {
super();
this.type = "text";
this.value = "";
this.required = false;
this.disabled = false;
this.readonly = false;
this.error = false;
}
_onInput(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
const cls = "wn-input" + (this.error ? " wn-input--error" : "");
return html`
<input class=${cls}
part="input"
name=${this.name ?? nothing}
type=${this.type}
.value=${this.value}
placeholder=${this.placeholder ?? nothing}
?required=${this.required}
?disabled=${this.disabled}
?readonly=${this.readonly}
autocomplete=${this.autocomplete ?? nothing}
@input=${this._onInput}>
${this.error && this.errorText
? html`<span class="wn-form-error">${this.errorText}</span>`
: this.help
? html`<span class="wn-form-help">${this.help}</span>`
: nothing}
`;
}
}
/* ---------- <wn-textarea> ---------- */
export class WnTextarea extends WnBase {
static properties = {
name: { type: String, reflect: true },
value: { type: String },
placeholder: { type: String },
rows: { type: Number },
required: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
error: { type: Boolean, reflect: true },
help: { type: String },
errorText: { type: String, attribute: "error-text" },
};
constructor() { super(); this.value = ""; this.rows = 4; }
_onInput(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
const cls = "wn-textarea" + (this.error ? " wn-textarea--error" : "");
return html`
<textarea class=${cls}
part="textarea"
name=${this.name ?? nothing}
rows=${this.rows}
placeholder=${this.placeholder ?? nothing}
?required=${this.required}
?disabled=${this.disabled}
@input=${this._onInput}
.value=${this.value}></textarea>
${this.error && this.errorText
? html`<span class="wn-form-error">${this.errorText}</span>`
: this.help
? html`<span class="wn-form-help">${this.help}</span>`
: nothing}
`;
}
}
/* ---------- <wn-select> ----------
* Slot <option> elements; they're cloned into the inner <select>. */
export class WnSelect extends WnBase {
static properties = {
name: { type: String, reflect: true },
value: { type: String },
required: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
error: { type: Boolean, reflect: true },
help: { type: String },
};
_onSlotChange(e) {
const slot = e.target;
const select = this.shadowRoot?.querySelector("select.wn-select");
if (!select) return;
const options = slot.assignedElements({ flatten: true }).filter(el => el.tagName === "OPTION");
select.innerHTML = "";
for (const opt of options) select.appendChild(opt.cloneNode(true));
if (this.value != null) select.value = this.value;
}
_onChange(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-change", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
const cls = "wn-select" + (this.error ? " wn-select--error" : "");
return html`
<select class=${cls}
part="select"
name=${this.name ?? nothing}
?required=${this.required}
?disabled=${this.disabled}
@change=${this._onChange}></select>
<slot @slotchange=${this._onSlotChange} style="display:none"></slot>
${this.help ? html`<span class="wn-form-help">${this.help}</span>` : nothing}
`;
}
}
/* ---------- <wn-search-input> ---------- */
export class WnSearchInput extends WnBase {
static properties = {
placeholder: { type: String },
kbd: { type: String },
value: { type: String },
name: { type: String, reflect: true },
};
constructor() { super(); this.placeholder = "Search…"; this.kbd = "⌘ K"; this.value = ""; }
_onInput(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
return html`
<label class="wn-search" part="root">
<wn-icon name="search" size="sm"></wn-icon>
<input type="search"
name=${this.name ?? nothing}
.value=${this.value}
placeholder=${this.placeholder}
@input=${this._onInput}>
${this.kbd ? html`<span class="wn-search__kbd">${this.kbd}</span>` : nothing}
</label>
`;
}
}
/* ---------- <wn-field-row> ---------- */
export class WnFieldRow extends WnBase {
static properties = {
label: { type: String },
aside: { type: String },
stacked: { type: Boolean, reflect: true },
narrow: { type: Boolean, reflect: true },
htmlFor: { type: String, attribute: "for" },
};
render() {
const cls = ["wn-field-row",
this.stacked ? "wn-field-row--stacked" : "",
this.narrow ? "wn-field-row--narrow" : "",
].filter(Boolean).join(" ");
return html`
<div class=${cls} part="root">
<label class="wn-field-row__label" for=${this.htmlFor ?? nothing}>${this.label}</label>
<div class="wn-field-row__value"><slot></slot></div>
${this.aside
? html`<div class="wn-field-row__aside">${this.aside}<slot name="aside"></slot></div>`
: html`<div class="wn-field-row__aside"><slot name="aside"></slot></div>`}
</div>
`;
}
}
export function defineForm() {
if (!customElements.get("wn-input")) customElements.define("wn-input", WnInput);
if (!customElements.get("wn-textarea")) customElements.define("wn-textarea", WnTextarea);
if (!customElements.get("wn-select")) customElements.define("wn-select", WnSelect);
if (!customElements.get("wn-search-input")) customElements.define("wn-search-input", WnSearchInput);
if (!customElements.get("wn-field-row")) customElements.define("wn-field-row", WnFieldRow);
}

View file

@ -0,0 +1,45 @@
/* =============================================================
* @whynot/design icons.js
* ------------------------------------------------------------
* Inline Lucide-style icon paths (24×24, stroke 1.5, fill none).
* Only the icons used by the system ship here; consumers can
* extend by importing extras directly from `lucide`.
*
* Each value is an array of `d` attributes multi-path icons
* are rendered as multiple <path> elements.
*
* Paths derived from Lucide (ISC). If you need an icon not in
* this list, add it here, not in a consuming repo.
* ============================================================= */
/* prettier-ignore */
export const ICON_PATHS = {
/* Navigation */
"inbox": ["M22 12h-6l-2 3h-4l-2-3H2", "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"],
"lightbulb": ["M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5", "M9 18h6", "M10 22h4"],
"flask-conical": ["M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2", "M6.453 15h11.094", "M8.5 2h7"],
"activity": ["M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.5.5 0 0 1-.96 0L9.24 2.18a.5.5 0 0 0-.96 0l-2.35 8.36A2 2 0 0 1 4 12H2"],
"users": ["M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", "M22 21v-2a4 4 0 0 0-3-3.87", "M16 3.13a4 4 0 0 1 0 7.75", "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"],
"git-branch": ["M6 3v12", "M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M15 6a9 9 0 0 0-9 9"],
"check-square": ["M9 11l3 3L22 4", "M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"],
"archive": ["M21 8v13H3V8", "M1 3h22v5H1z", "M10 12h4"],
"file-text": ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z", "M14 2v5h6", "M16 13H8", "M16 17H8", "M10 9H8"],
"folder": ["M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"],
/* Actions / signals */
"arrow-right": ["M5 12h14", "M12 5l7 7-7 7"],
"arrow-left": ["M19 12H5", "M12 19l-7-7 7-7"],
"plus": ["M12 5v14", "M5 12h14"],
"x": ["M18 6L6 18", "M6 6l12 12"],
"check": ["M20 6 9 17l-5-5"],
"search": ["M21 21l-4.34-4.34", "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"],
"filter": ["M22 3H2l8 9.46V19l4 2v-8.54L22 3z"],
"circle-help": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3", "M12 17h.01"],
"circle-alert": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M12 8v4", "M12 16h.01"],
"circle-check": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M9 12l2 2 4-4"],
"circle-info": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M12 16v-4", "M12 8h.01"],
"settings": ["M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"],
"more-horizontal": ["M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"],
"chevron-down": ["M6 9l6 6 6-6"],
"chevron-right": ["M9 18l6-6-6-6"],
};

View file

@ -0,0 +1,287 @@
/* =============================================================
* @whynot/design layout.js
* ------------------------------------------------------------
* <wn-card>, <wn-modal>, <wn-table> / <wn-table-row> /
* <wn-table-cell>, <wn-banner>, <wn-toast>, <wn-toast-region>,
* <wn-empty-state>, <wn-breadcrumb>
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { WnBase } from "./atoms.js";
/* ---------- <wn-card> ---------- */
export class WnCard extends WnBase {
static properties = {
variant: { type: String, reflect: true },
size: { type: String, reflect: true },
clickable: { type: Boolean, reflect: true },
hasHeader: { state: true },
hasFooter: { state: true },
};
constructor() {
super();
this.hasHeader = false;
this.hasFooter = false;
}
_onSlotChange() {
this.hasHeader = !!this.querySelector('[slot="header"]');
this.hasFooter = !!this.querySelector('[slot="footer"]');
}
render() {
const cls = ["wn-card",
this.variant && this.variant !== "default" ? `wn-card--${this.variant}` : "",
this.size === "sm" ? "wn-card--sm" : this.size === "lg" ? "wn-card--lg" : "",
this.clickable ? "wn-card--clickable" : "",
].filter(Boolean).join(" ");
return html`
<div class=${cls}
role=${this.clickable ? "button" : nothing}
tabindex=${this.clickable ? "0" : nothing}
part="card">
<header class="wn-card__head" ?hidden=${!this.hasHeader}>
<slot name="header" @slotchange=${this._onSlotChange}></slot>
</header>
<slot @slotchange=${this._onSlotChange}></slot>
<footer class="wn-card__foot" ?hidden=${!this.hasFooter}>
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
</footer>
</div>
`;
}
}
/* ---------- <wn-modal> ---------- */
export class WnModal extends WnBase {
static properties = {
open: { type: Boolean, reflect: true },
title: { type: String },
dismissible: { type: Boolean, reflect: true },
hasFooter: { state: true },
};
constructor() {
super();
this.open = false;
this.dismissible = true;
this.hasFooter = false;
}
_onSlotChange() {
this.hasFooter = !!this.querySelector('[slot="footer"]');
}
_onBackdrop(e) {
if (e.target === e.currentTarget && this.dismissible) this._dismiss();
}
_dismiss() {
this.open = false;
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
}
_bindKey = (e) => {
if (e.key === "Escape" && this.dismissible && this.open) this._dismiss();
};
connectedCallback() { super.connectedCallback(); document.addEventListener("keydown", this._bindKey); }
disconnectedCallback() { document.removeEventListener("keydown", this._bindKey); super.disconnectedCallback(); }
render() {
if (!this.open) return nothing;
return html`
<div class="wn-modal__backdrop" @click=${this._onBackdrop} role="presentation" part="backdrop">
<div class="wn-modal__panel" role="dialog" aria-modal="true" aria-label=${this.title ?? nothing} part="panel">
<header class="wn-modal__head">
<h2 class="wn-modal__title">${this.title}<slot name="title"></slot></h2>
${this.dismissible
? html`<button class="wn-modal__close" type="button" aria-label="Close" @click=${this._dismiss}>
<wn-icon name="x" size="md"></wn-icon>
</button>`
: nothing}
</header>
<div class="wn-modal__body"><slot @slotchange=${this._onSlotChange}></slot></div>
<footer class="wn-modal__foot" ?hidden=${!this.hasFooter}>
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
</footer>
</div>
</div>
`;
}
}
/* ---------- <wn-table>, <wn-table-row>, <wn-table-cell> ----------
* Tables in shadow DOM can't render real <table>/<tr> with slotted rows
* the table model requires the row to be a child of <table>. So these
* components use CSS grid + flexbox to imitate a table visually. For real
* <table> + Django QuerySet rendering, write raw <table class="wn-table">
* markup directly using utility classes.
*/
export class WnTable extends WnBase {
static properties = {
columns: { type: Array },
compact: { type: Boolean, reflect: true },
};
constructor() { super(); this.columns = []; }
render() {
const cols = this.columns || [];
const cls = "wn-table" + (this.compact ? " wn-table--compact" : "");
return html`
<div class=${cls} part="table" role="table">
${cols.length
? html`<div class="wn-table__thead" role="rowgroup">
<div class="wn-table__tr wn-table__tr--head" role="row"
style=${`grid-template-columns: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`}>
${cols.map(c => html`<div class="wn-table__th" role="columnheader">${typeof c === "string" ? c : c.label}</div>`)}
</div>
</div>`
: nothing}
<div class="wn-table__tbody" role="rowgroup"
style=${cols.length
? `--wn-cols: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`
: nothing}>
<slot></slot>
</div>
</div>
`;
}
}
export class WnTableRow extends WnBase {
render() {
return html`<div class="wn-table__tr" role="row" part="row"
style="grid-template-columns: var(--wn-cols, repeat(auto-fit, minmax(80px, 1fr)));">
<slot></slot>
</div>`;
}
}
export class WnTableCell extends WnBase {
static properties = { variant: { type: String, reflect: true } };
render() {
const cls = "wn-table__td" + (this.variant ? ` wn-table__cell--${this.variant}` : "");
return html`<div class=${cls} role="cell" part="cell"><slot></slot></div>`;
}
}
/* ---------- <wn-banner> ---------- */
export class WnBanner extends WnBase {
static properties = {
variant: { type: String, reflect: true },
title: { type: String },
icon: { type: String },
dismissible: { type: Boolean, reflect: true },
};
constructor() { super(); this.variant = "info"; }
_dismiss() {
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
this.remove();
}
render() {
const iconName = this.icon || ({
info: "circle-info", success: "circle-check",
warn: "circle-alert", error: "circle-alert",
}[this.variant]);
const cls = `wn-banner wn-banner--${this.variant}`;
return html`
<div class=${cls} role=${this.variant === "error" || this.variant === "warn" ? "alert" : "status"} part="banner">
${iconName ? html`<span class="wn-banner__icon"><wn-icon name=${iconName} size="md"></wn-icon></span>` : nothing}
<div class="wn-banner__body">
${this.title ? html`<p class="wn-banner__title">${this.title}</p>` : nothing}
<slot></slot>
</div>
${this.dismissible
? html`<button class="wn-banner__dismiss" type="button" aria-label="Dismiss" @click=${this._dismiss}>
<wn-icon name="x" size="sm"></wn-icon>
</button>`
: nothing}
</div>
`;
}
}
/* ---------- <wn-toast> / <wn-toast-region> ---------- */
export class WnToast extends WnBanner {
constructor() { super(); this.dismissible = true; }
render() {
const base = super.render();
return html`<div class="wn-toast" part="toast">${base}</div>`;
}
}
export class WnToastRegion extends WnBase {
render() {
return html`<div class="wn-toast-region" role="region" aria-label="Notifications" part="root">
<slot></slot>
</div>`;
}
}
/* ---------- <wn-empty-state> ---------- */
export class WnEmptyState extends WnBase {
static properties = {
icon: { type: String },
title: { type: String },
hasCta: { state: true },
};
constructor() { super(); this.hasCta = false; }
_onSlot() { this.hasCta = !!this.querySelector('[slot="cta"]'); }
render() {
return html`
<div class="wn-empty" part="empty">
${this.icon ? html`<wn-icon class="wn-empty__icon" name=${this.icon} size="lg"></wn-icon>` : nothing}
${this.title ? html`<p class="wn-empty__title">${this.title}</p>` : nothing}
<p class="wn-empty__body"><slot @slotchange=${this._onSlot}></slot></p>
<div class="wn-empty__cta" ?hidden=${!this.hasCta}>
<slot name="cta" @slotchange=${this._onSlot}></slot>
</div>
</div>
`;
}
}
/* ---------- <wn-breadcrumb> ---------- */
export class WnBreadcrumb extends WnBase {
_onSlot(e) {
const slot = e.target;
// Separators are inserted into the LIGHT DOM (so they sit in document order
// between the slotted items), which re-fires this slotchange. We must
// therefore be idempotent: exclude our own separators when reading items,
// and skip all mutation once the separators are already correct — otherwise
// each insertion retriggers slotchange and the main thread loops forever.
const items = slot.assignedElements({ flatten: true })
.filter((el) => !el.classList.contains("wn-breadcrumb__sep"));
const existing = [...this.querySelectorAll(":scope > .wn-breadcrumb__sep")];
if (existing.length === Math.max(0, items.length - 1)) {
// Structure already correct — only refresh the "current" marker, do not
// touch the child list (no mutation ⇒ no slotchange re-fire ⇒ loop ends).
items.forEach((el, i) => el.classList.toggle("wn-breadcrumb__current", i === items.length - 1));
return;
}
existing.forEach((s) => s.remove());
items.forEach((el, i) => {
el.classList.toggle("wn-breadcrumb__current", i === items.length - 1);
if (i > 0) {
const sep = document.createElement("span");
sep.className = "wn-breadcrumb__sep";
sep.setAttribute("aria-hidden", "true");
sep.textContent = "/";
el.parentNode.insertBefore(sep, el);
}
});
}
render() {
return html`
<nav class="wn-breadcrumb" aria-label="Breadcrumb" part="root">
<span class="wn-breadcrumb__list"><slot @slotchange=${this._onSlot}></slot></span>
</nav>
`;
}
}
export function defineLayout() {
if (!customElements.get("wn-card")) customElements.define("wn-card", WnCard);
if (!customElements.get("wn-modal")) customElements.define("wn-modal", WnModal);
if (!customElements.get("wn-table")) customElements.define("wn-table", WnTable);
if (!customElements.get("wn-table-row")) customElements.define("wn-table-row", WnTableRow);
if (!customElements.get("wn-table-cell")) customElements.define("wn-table-cell", WnTableCell);
if (!customElements.get("wn-banner")) customElements.define("wn-banner", WnBanner);
if (!customElements.get("wn-toast")) customElements.define("wn-toast", WnToast);
if (!customElements.get("wn-toast-region")) customElements.define("wn-toast-region", WnToastRegion);
if (!customElements.get("wn-empty-state")) customElements.define("wn-empty-state", WnEmptyState);
if (!customElements.get("wn-breadcrumb")) customElements.define("wn-breadcrumb", WnBreadcrumb);
}

View file

@ -0,0 +1,35 @@
/* =============================================================
* @whynot/design entry point
* ------------------------------------------------------------
* Side-effect import that registers every <wn-*> custom element.
*
* import "@whynot/design";
*
* If you only need a subset, import the per-group files instead:
*
* import "@whynot/design/atoms";
* import "@whynot/design/form";
* import "@whynot/design/layout";
* import "@whynot/design/chrome";
*
* CSS is imported separately:
*
* import "@whynot/design/styles/colors_and_type.css";
* import "@whynot/design/styles/components.css";
* ============================================================= */
import { defineAtoms } from "./elements/atoms.js";
import { defineForm } from "./elements/form.js";
import { defineLayout } from "./elements/layout.js";
import { defineChrome } from "./elements/chrome.js";
defineAtoms();
defineForm();
defineLayout();
defineChrome();
// Re-export classes for consumers that want to extend or reference them.
export * from "./elements/atoms.js";
export * from "./elements/form.js";
export * from "./elements/layout.js";
export * from "./elements/chrome.js";

View file

@ -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); }

View file

@ -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 (S0S4) */
.wn-stage-dot--s0 .wn-dot__bullet { background: var(--status-raw); }
.wn-stage-dot--s1 .wn-dot__bullet { background: var(--status-weak); }
.wn-stage-dot--s2 .wn-dot__bullet { background: var(--status-medium); }
.wn-stage-dot--s3 .wn-dot__bullet { background: var(--status-strong); }
.wn-stage-dot--s4 .wn-dot__bullet { background: var(--status-commercial); }
/* phase states (todo / active / done / warn) — numbered phases, distinct from signal */
.wn-phase-dot__bullet {
width: 18px; height: 18px; border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--paper);
display: inline-flex; align-items: center; justify-content: center;
font: 500 10px/1 var(--ff-mono); color: var(--fg-3);
flex: none;
}
.wn-phase-dot--todo .wn-phase-dot__bullet { border-color: var(--border-strong); color: var(--fg-3); background: var(--paper); }
.wn-phase-dot--active .wn-phase-dot__bullet { border-color: var(--ink); color: var(--ink); background: var(--paper); box-shadow: 0 0 0 3px rgba(10,10,10,0.06); }
.wn-phase-dot--done .wn-phase-dot__bullet { border-color: var(--ink); color: var(--paper); background: var(--ink); }
.wn-phase-dot--warn .wn-phase-dot__bullet { border-color: var(--hi-2); color: var(--hi-ink); background: var(--hi); }
/* ====== Stamp ====== */
.wn-stamp {
display: inline-block;
background: var(--hi);
color: var(--hi-ink);
padding: 5px 10px 3px;
font: 500 10px/1 var(--ff-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
transform: rotate(-1.5deg);
}
/* ====== Icon ====== */
.wn-icon { stroke-width: 1.5; stroke: currentColor; fill: none; display: inline-block; vertical-align: middle; }
.wn-icon--sm { width: 14px; height: 14px; }
.wn-icon--md { width: 16px; height: 16px; }
.wn-icon--lg { width: 20px; height: 20px; }
.wn-icon--xl { width: 24px; height: 24px; }
/* ====== Card ====== */
.wn-card {
background: var(--paper);
border: 1px solid var(--border);
border-radius: var(--r-2);
padding: var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-3);
position: relative;
}
.wn-card--inset { background: var(--paper-2); border-color: var(--border); }
.wn-card--recessed { background: var(--paper-3); }
.wn-card--lg { padding: var(--sp-6); border-radius: var(--r-3); }
.wn-card--sm { padding: var(--sp-4); gap: var(--sp-2); }
.wn-card--clickable { cursor: pointer; transition: border-color 120ms ease; }
.wn-card--clickable:hover { border-color: var(--ink); }
.wn-card--clickable:hover::before {
content: ""; position: absolute; left: -1px; top: -1px; bottom: -1px;
width: 2px; background: var(--ink); border-radius: 2px 0 0 2px;
}
.wn-card__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); }
.wn-card__title { font: 500 17px/1.35 var(--ff-sans); margin: 4px 0 8px; color: var(--fg-1); }
.wn-card__foot {
display: flex; justify-content: space-between; gap: var(--sp-3);
padding-top: var(--sp-3); margin-top: 4px;
border-top: 1px solid var(--border-soft);
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-3);
}
/* ====== Field row (label + value, 3-col grid) ====== */
.wn-field-row {
display: grid;
grid-template-columns: 200px 1fr auto;
gap: var(--sp-4) var(--sp-5);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border-soft);
align-items: baseline;
}
.wn-field-row:last-child { border-bottom: 0; }
.wn-field-row__label {
font: 500 11px/1.5 var(--ff-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-field-row__value { font: 400 15px/1.55 var(--ff-sans); color: var(--fg-1); }
.wn-field-row__aside { font: 400 12px var(--ff-mono); color: var(--fg-3); text-align: right; }
.wn-field-row--stacked { grid-template-columns: 1fr; gap: 6px; }
.wn-field-row--narrow { grid-template-columns: 120px 1fr; }
/* ====== Form inputs ====== */
.wn-form-label {
font: 500 11px/1 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
display: block;
margin-bottom: 6px;
}
.wn-input, .wn-textarea, .wn-select {
font: 400 14px var(--ff-sans);
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--paper);
border-radius: var(--r-1);
color: var(--fg-1);
outline: none;
width: 100%;
transition: border-color 120ms ease;
box-sizing: border-box;
}
.wn-input:hover, .wn-textarea:hover, .wn-select:hover { border-color: var(--border-strong); }
.wn-input:focus, .wn-textarea:focus, .wn-select:focus { border-color: var(--ink); }
.wn-input::placeholder, .wn-textarea::placeholder { color: var(--ink-5); }
.wn-input[disabled], .wn-textarea[disabled], .wn-select[disabled] {
background: var(--paper-2); color: var(--fg-3); cursor: not-allowed;
}
.wn-textarea { resize: vertical; min-height: 96px; font-family: var(--ff-sans); }
.wn-select {
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%235C5C5C' stroke-width='1.5'><path d='M1 1l4 4 4-4'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
.wn-input--error, .wn-textarea--error, .wn-select--error {
border-color: var(--ink); border-bottom-width: 2px;
}
.wn-form-help { font: 400 11px var(--ff-mono); color: var(--fg-3); margin-top: 6px; display: block; }
.wn-form-error { font: 400 11px var(--ff-mono); color: var(--ink); margin-top: 6px; display: block; }
/* Search input — extracted from TopNav, also usable standalone */
.wn-search {
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: var(--r-1);
background: var(--paper);
color: var(--fg-3);
font: 400 12px var(--ff-mono);
min-width: 200px;
transition: border-color 120ms ease;
}
.wn-search:focus-within { border-color: var(--ink); }
.wn-search input {
border: 0; outline: 0; background: none; flex: 1;
font: inherit; color: var(--fg-1); padding: 0;
}
.wn-search input::placeholder { color: var(--ink-5); }
.wn-search__kbd {
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: 2px;
font-size: 10px;
color: var(--fg-3);
}
/* ====== Breadcrumb ====== */
.wn-breadcrumb {
display: flex; flex-wrap: wrap; align-items: center;
gap: 6px;
font: 400 12px/1.5 var(--ff-mono);
color: var(--fg-3);
margin-bottom: var(--sp-4);
}
.wn-breadcrumb a {
color: var(--fg-2);
text-decoration: none;
padding: 2px 0;
border-bottom: 1px solid transparent;
transition: border-color 120ms ease, color 120ms ease;
}
.wn-breadcrumb a:hover { color: var(--fg-1); border-bottom-color: var(--border-strong); }
.wn-breadcrumb__sep { color: var(--ink-5); user-select: none; }
.wn-breadcrumb__current { color: var(--fg-1); }
/* ====== Modal / Dialog ====== */
.wn-modal__backdrop {
position: fixed; inset: 0;
background: rgba(10, 10, 10, 0.40);
display: flex; align-items: center; justify-content: center;
z-index: 100;
padding: var(--sp-5);
}
.wn-modal__panel {
background: var(--paper);
border-radius: var(--r-3);
box-shadow: var(--shadow-3);
max-width: 560px; width: 100%;
max-height: calc(100vh - 64px);
display: flex; flex-direction: column;
overflow: hidden;
}
.wn-modal__head {
padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border);
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4);
}
.wn-modal__title { font: 500 20px/1.25 var(--ff-sans); margin: 0; color: var(--fg-1); }
.wn-modal__close {
background: none; border: 0; cursor: pointer; padding: 4px;
color: var(--fg-3); border-radius: var(--r-1);
transition: color 120ms ease;
}
.wn-modal__close:hover { color: var(--fg-1); }
.wn-modal__body {
padding: var(--sp-5) var(--sp-6);
overflow-y: auto;
flex: 1;
font: 400 15px/1.6 var(--ff-sans);
color: var(--fg-1);
}
.wn-modal__foot {
padding: var(--sp-4) var(--sp-6) var(--sp-5);
border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: var(--sp-2);
}
/* ====== Table ======
* Note: shadow-DOM-rendered rows can't be children of a real <table> (the
* HTML table model rejects unknown elements between <table> and <tr>). The
* <wn-table> component therefore renders a CSS-grid imitation. For real
* <table> markup (Django QuerySet rendering, etc.) use these classes
* directly on <table>/<tr>/<td> elements see also the .wn-table--native
* variant below.
*/
/* CSS-grid imitation (default <wn-table>) */
.wn-table {
width: 100%;
font-size: var(--fs-sm);
display: flex;
flex-direction: column;
}
.wn-table__thead { border-bottom: 1px solid var(--border); }
.wn-table__tbody { display: flex; flex-direction: column; }
.wn-table__tr {
display: grid;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-soft);
align-items: baseline;
}
.wn-table__tr:last-child { border-bottom: 0; }
.wn-table__tr--head { border-bottom: 0; padding: var(--sp-3) var(--sp-4); }
.wn-table__th {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-table__td {
color: var(--fg-1);
line-height: 1.5;
font-size: var(--fs-sm);
}
.wn-table--compact .wn-table__tr { padding: var(--sp-2) var(--sp-3); }
.wn-table__cell--mono { font-family: var(--ff-mono); color: var(--fg-2); font-size: 12px; }
.wn-table__cell--meta { color: var(--fg-3); font: 400 12px var(--ff-mono); }
.wn-table__cell--right { text-align: right; }
/* Native <table> variant — for Django QuerySet rendering etc. */
.wn-table--native {
border-collapse: collapse;
display: table;
}
.wn-table--native thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-table--native tbody td {
padding: var(--sp-4);
border-bottom: 1px solid var(--border-soft);
vertical-align: top;
color: var(--fg-1);
font-size: var(--fs-sm);
line-height: 1.5;
}
.wn-table--native tbody tr:hover { background: var(--paper-2); }
.wn-table--native tbody tr:last-child td { border-bottom: 0; }
/* ====== Banner / Toast (success / info / warn) ====== */
.wn-banner {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border);
background: var(--paper);
border-radius: var(--r-2);
font: 400 14px/1.5 var(--ff-sans);
color: var(--fg-1);
position: relative;
}
.wn-banner__icon { color: var(--fg-2); flex: none; padding-top: 2px; }
.wn-banner__body { flex: 1; }
.wn-banner__title {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
margin: 0 0 4px;
}
.wn-banner__dismiss {
background: none; border: 0; cursor: pointer;
color: var(--fg-3); padding: 4px;
}
.wn-banner__dismiss:hover { color: var(--fg-1); }
.wn-banner--success { border-left: 2px solid var(--ink); }
.wn-banner--warn { border-left: 2px solid var(--hi-2); background: #FFFCEB; }
.wn-banner--error { border-left: 2px solid var(--ink); background: var(--paper); }
.wn-banner--info { border-left: 2px solid var(--border-strong); }
.wn-toast-region {
position: fixed;
bottom: var(--sp-5); right: var(--sp-5);
display: flex; flex-direction: column; gap: var(--sp-2);
z-index: 200;
max-width: 380px;
}
.wn-toast { box-shadow: var(--shadow-3); }
/* ====== Empty state ====== */
.wn-empty {
border: 1px dashed var(--border-strong);
border-radius: var(--r-2);
padding: var(--sp-7);
display: flex; flex-direction: column; align-items: center;
gap: var(--sp-2);
text-align: center;
color: var(--fg-3);
}
.wn-empty__icon { color: var(--fg-3); margin-bottom: var(--sp-2); }
.wn-empty__title { font: 500 14px var(--ff-sans); color: var(--fg-2); margin: 0; }
.wn-empty__body { font: 400 13px/1.5 var(--ff-sans); color: var(--fg-3); max-width: 40ch; margin: 0; }
.wn-empty__cta { margin-top: var(--sp-2); }
/* ====== Top navigation ====== */
.wn-topnav {
height: 56px;
background: rgba(255, 255, 255, 0.92);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
gap: var(--sp-6);
padding: 0 var(--sp-5);
position: sticky; top: 0; z-index: 10;
}
.wn-topnav__brand { display: flex; align-items: center; gap: 10px; font: 500 14px var(--ff-sans); }
.wn-topnav__brand img { width: 22px; height: 22px; }
.wn-topnav__brand-slug { font-family: var(--ff-mono); font-size: 12px; color: var(--fg-3); letter-spacing: 0.04em; }
.wn-topnav__links { display: flex; gap: 22px; }
.wn-topnav__link {
font: 500 13px var(--ff-sans);
color: var(--fg-2);
text-decoration: none;
padding: 6px 0;
border-bottom: 1px solid transparent;
transition: color 120ms ease, border-color 120ms ease;
}
.wn-topnav__link:hover { color: var(--fg-1); }
.wn-topnav__link--active { color: var(--fg-1); border-bottom-color: var(--ink); }
.wn-topnav__right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
/* ====== Sidebar ====== */
.wn-sidebar {
width: 240px;
flex: none;
background: var(--paper-2);
border-right: 1px solid var(--border);
padding: var(--sp-5) var(--sp-4);
display: flex; flex-direction: column; gap: var(--sp-5);
height: calc(100vh - 56px);
position: sticky; top: 56px;
overflow-y: auto;
}
.wn-sidebar__group { display: flex; flex-direction: column; gap: 8px; }
.wn-sidebar__group-label { padding-left: 12px; }
.wn-sidebar__item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
border-radius: 4px;
color: var(--fg-2);
font: 500 13px var(--ff-sans);
cursor: pointer; text-decoration: none;
transition: background 120ms ease, color 120ms ease;
}
.wn-sidebar__item:hover { color: var(--fg-1); }
.wn-sidebar__item--active {
color: var(--fg-1); background: var(--paper);
box-shadow: 0 0 0 1px var(--border) inset;
}
.wn-sidebar__item--doc { font-family: var(--ff-mono); font-size: 12px; }
.wn-sidebar__count { margin-left: auto; font: 400 11px var(--ff-mono); color: var(--fg-3); }
.wn-sidebar__footer { margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--border); }
.wn-sidebar__activation {
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-2);
}
.wn-sidebar__activation-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--hi-2); }
/* ====== Page header ====== */
.wn-page-header {
margin-bottom: var(--sp-6);
display: flex; flex-direction: column; gap: 8px;
}
.wn-page-header__row { display: flex; align-items: flex-end; gap: var(--sp-5); }
.wn-page-header__title {
font: 500 32px/1.15 var(--ff-sans);
letter-spacing: -0.015em;
margin: 0; flex: 1; color: var(--fg-1);
}
.wn-page-header__actions { display: flex; gap: 8px; flex-wrap: wrap; }
.wn-page-header__lede {
font: 400 16px/1.55 var(--ff-sans);
color: var(--fg-2);
margin: 0;
max-width: 60ch;
}
/* ====== Pipeline ====== */
.wn-pipeline {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0;
position: relative;
margin: 0 0 var(--sp-6);
}
.wn-pipeline__stage {
padding: 10px 12px 14px;
border-top: 2px solid var(--border);
display: flex; flex-direction: column; gap: 4px;
position: relative;
}
.wn-pipeline__stage--done { border-top-color: var(--ink); }
.wn-pipeline__stage--active { border-top-color: var(--hi-2); }
.wn-pipeline__num {
font: 500 10px/1 var(--ff-mono); letter-spacing: 0.1em; text-transform: uppercase;
color: var(--fg-3);
}
.wn-pipeline__stage--done .wn-pipeline__num,
.wn-pipeline__stage--active .wn-pipeline__num { color: var(--fg-1); }
.wn-pipeline__name { font: 500 14px/1.25 var(--ff-sans); color: var(--fg-1); }
.wn-pipeline__stage--pending .wn-pipeline__name { color: var(--fg-3); }
.wn-pipeline__meta { font: 400 11px/1.35 var(--ff-mono); color: var(--fg-3); }
.wn-pipeline__arrow {
position: absolute; top: -8px; right: -7px;
font: 400 14px var(--ff-mono); color: var(--ink-5);
}
.wn-pipeline__stage--done .wn-pipeline__arrow,
.wn-pipeline__stage--active .wn-pipeline__arrow { color: var(--ink); }
/* ====== Prototype card (combined card variant) ====== */
.wn-prototype-card { /* extends .wn-card */ }
.wn-prototype-card__qrow {
display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px;
font-size: 13px; color: var(--fg-1);
}
.wn-prototype-card__qkey {
font: 500 11px/1.5 var(--ff-mono);
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-3);
}
.wn-prototype-card__qval { line-height: 1.45; }
/* ====== Layout helpers ====== */
.wn-main { padding: 40px 48px 80px; max-width: 1180px; }
.wn-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }

View file

@ -0,0 +1,289 @@
"""HTTP-level integration tests for the Control Plane interactive UI
(WP-0009-T04), via FastAPI's TestClient.
Same ephemeral, disposable Postgres-via-Docker pattern as
`test_control_plane.py` (never the shared state-hub instance).
Scope disclosure: these tests exercise the FastAPI app's routing, form
handling, session auth, and rights gating at the HTTP layer they do not
render or interact with the pages in a real browser, since no
browser-automation tool is available in this environment. The
whynot-design web components (`<wn-*>` custom elements) are not
themselves exercised; only the server-rendered HTML/session/redirect
behavior around them is.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
import uuid
from pathlib import Path
import pytest
psycopg = pytest.importorskip("psycopg")
pytest.importorskip("jinja2")
pytest.importorskip("itsdangerous")
REPO_ROOT = Path(__file__).resolve().parents[1]
MIGRATIONS = [
REPO_ROOT / "migrations" / "0001_registries.sql",
REPO_ROOT / "migrations" / "0002_ledger.sql",
REPO_ROOT / "migrations" / "0003_attestations.sql",
REPO_ROOT / "migrations" / "0004_breach_records.sql",
REPO_ROOT / "migrations" / "0005_licensor_credentials.sql",
REPO_ROOT / "migrations" / "0006_control_plane.sql",
]
pytestmark = pytest.mark.skipif(
shutil.which("docker") is None, reason="docker not available"
)
@pytest.fixture(scope="module")
def pg_container():
name = f"trf-test-pg-cpapp-{uuid.uuid4().hex[:8]}"
subprocess.run(
[
"docker", "run", "--rm", "-d",
"--name", name,
"-e", "POSTGRES_PASSWORD=postgres",
"-e", "POSTGRES_DB=target_revenue_test",
"-p", "127.0.0.1::5432",
"postgres:16-alpine",
],
check=True, capture_output=True,
)
try:
port_out = subprocess.run(
["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True
).stdout.strip()
host_port = port_out.split(":")[-1]
dsn = f"host=127.0.0.1 port={host_port} dbname=target_revenue_test user=postgres password=postgres"
for _ in range(60):
try:
with psycopg.connect(dsn, connect_timeout=1):
break
except psycopg.OperationalError:
time.sleep(0.5)
else:
raise RuntimeError("postgres container did not become ready in time")
with psycopg.connect(dsn) as admin_conn:
for migration in MIGRATIONS:
admin_conn.execute(migration.read_text(encoding="utf-8"))
admin_conn.commit()
admin_conn.execute(
"INSERT INTO licensors (token, licensor_id, credential_label, rights, issued_by) "
"VALUES (%s, %s, %s, %s, %s)",
("founding-admin-token", "binky", "founder", "admin", "bootstrap"),
)
admin_conn.commit()
app_dsn = (
f"host=127.0.0.1 port={host_port} dbname=target_revenue_test "
f"user=trf_app password=changeme-in-deployment"
)
yield {"admin_dsn": dsn, "app_dsn": app_dsn, "admin_token": "founding-admin-token"}
finally:
subprocess.run(["docker", "stop", name], capture_output=True)
@pytest.fixture()
def client(pg_container, monkeypatch):
monkeypatch.setenv("TRF_DATABASE_URL", pg_container["app_dsn"])
monkeypatch.setenv("TRF_CONTROL_PLANE_SECRET_KEY", "test-secret-key")
monkeypatch.setenv("TRF_SIGNING_KEY_HEX", "11" * 32)
import importlib
from target_revenue.service import control_plane_app as mod
importlib.reload(mod)
if hasattr(mod.app.state, "pool"):
mod.app.state.pool.close()
del mod.app.state.pool
if hasattr(mod.app.state, "signing_key"):
del mod.app.state.signing_key
from fastapi.testclient import TestClient
with TestClient(mod.app) as test_client:
yield test_client
if hasattr(mod.app.state, "pool"):
mod.app.state.pool.close()
@pytest.fixture()
def conn(pg_container):
with psycopg.connect(pg_container["app_dsn"]) as connection:
yield connection
@pytest.fixture()
def credentials(conn, pg_container):
from target_revenue import registry
admin = registry.authenticate(conn, pg_container["admin_token"])
suffix = uuid.uuid4().hex[:8]
tiers = {}
for label, rights in [
("viewer-user", "viewer"),
("contributor-user", "contributor"),
("operator-user", "operator"),
]:
cred = registry.issue_sub_credential(
conn, licensor_id="binky", credential_label=f"{label}-{suffix}", rights=rights,
issued_by="founder",
)
tiers[rights] = cred
conn.commit()
tiers["admin"] = admin
return tiers
def _login(client, token):
resp = client.post("/login", data={"token": token}, follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/"
def test_root_requires_login_redirects(client):
resp = client.get("/", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/login"
def test_invalid_token_rejected(client):
resp = client.post("/login", data={"token": "not-a-real-token"}, follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/login"
def test_valid_login_then_dashboard(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/")
assert resp.status_code == 200
assert "Phases for binky" in resp.text
def test_viewer_cannot_reach_phase_new(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/phases/new", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/"
def test_operator_registers_phase_and_appends_entry(client, credentials):
_login(client, credentials["operator"].token)
phase_id = "trsl:phase:cpapp-test-" + uuid.uuid4().hex[:8]
resp = client.post(
"/phases/new",
data={
"phase_id": phase_id,
"milestone_release_name": "CP UI smoke test release",
"source_revision": "abc123",
"initial_target_amount": "1000",
"currency": "USD",
"future_license": "MIT",
"degeneration_policy": "trsl:policy:linear-longstop-v0@1.0",
"longstop_at": "2027-01-01T00:00:00Z",
"ledger": f"examples/{phase_id}/ledger.json",
},
follow_redirects=False,
)
assert resp.status_code == 303
assert resp.headers["location"] == f"/phases/{phase_id}"
detail = client.get(f"/phases/{phase_id}")
assert detail.status_code == 200
assert phase_id in detail.text
ledger_resp = client.post(
f"/phases/{phase_id}/ledger",
data={
"entry_id": "trsl:entry:cpappledger0001",
"amount": "500",
"currency": "USD",
"recognized_at": "2026-08-01T00:00:00Z",
"evidence_reference": "confidential:evidence:cpappledger0001",
"extension_id": "trsl:extension:development-license",
"extension_version": "1.0",
},
follow_redirects=False,
)
assert ledger_resp.status_code == 303
detail_after = client.get(f"/phases/{phase_id}")
assert "trsl:entry:cpappledger0001" in detail_after.text
def test_contributor_proposes_operator_approves(client, credentials):
_login(client, credentials["operator"].token)
phase_id = "trsl:phase:cpapp-propose-" + uuid.uuid4().hex[:8]
client.post(
"/phases/new",
data={
"phase_id": phase_id,
"milestone_release_name": "CP UI propose-flow release",
"source_revision": "def456",
"initial_target_amount": "1000",
"currency": "USD",
"future_license": "MIT",
"degeneration_policy": "trsl:policy:linear-longstop-v0@1.0",
"longstop_at": "2027-01-01T00:00:00Z",
"ledger": f"examples/{phase_id}/ledger.json",
},
)
client.post("/logout")
_login(client, credentials["contributor"].token)
propose_resp = client.post(
f"/phases/{phase_id}/ledger",
data={
"entry_id": "trsl:entry:cpappprop0001",
"amount": "250",
"currency": "USD",
"recognized_at": "2026-08-01T00:00:00Z",
"evidence_reference": "confidential:evidence:cpappprop0001",
"extension_id": "trsl:extension:development-license",
"extension_version": "1.0",
},
follow_redirects=False,
)
assert propose_resp.status_code == 303
client.post("/logout")
_login(client, credentials["operator"].token)
proposals_page = client.get("/proposals")
assert proposals_page.status_code == 200
assert phase_id in proposals_page.text
def test_admin_issues_and_revokes_credential(client, credentials):
_login(client, credentials["admin"].token)
issue_resp = client.post(
"/admin/credentials",
data={"credential_label": f"dana-{uuid.uuid4().hex[:6]}", "rights": "viewer"},
)
assert issue_resp.status_code == 200
assert "New credential issued" in issue_resp.text
def test_non_admin_cannot_reach_admin_credentials(client, credentials):
_login(client, credentials["operator"].token)
resp = client.get("/admin/credentials", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/"
def test_audit_log_visible_to_signed_in_user(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/audit")
assert resp.status_code == 200

View file

@ -4,7 +4,7 @@ type: workplan
title: "Target Revenue Control Plane"
domain: infotech
repo: target-revenue
status: active
status: finished
owner: claude
topic_slug: infotech
created: "2026-07-30"
@ -191,7 +191,7 @@ containers left running.
```task
id: TREV-WP-0009-T04
status: todo
status: done
priority: high
state_hub_task_id: "01b295f2-8f97-4fc4-a14e-6f68aa85458d"
```
@ -212,3 +212,46 @@ sub-credential extension as the backend:
- read-only views (Phase status, Ledger, Attestation, Breach/Compliance
Record history) — already public/unauthenticated at the Trust Service
layer, so these need no new backend work, only UI.
**Result:** Built on `whynot-design` (vendored, source commit
`4b62cffc86496d587ac8d48a8e199624bc4a5c1f`, v0.4.1 — see
`src/target_revenue/service/static/whynot-design/VENDORED.md` for what is
vendored versus resolved via an `esm.sh` CDN import map for the `lit`
peer dependency, a disclosed v0 trade-off) rather than from scratch, per
the explicit direction to check feasibility first. New FastAPI app
`src/target_revenue/service/control_plane_app.py`, session-based sign-in
(paste-a-credential-token, `itsdangerous`-signed cookie via Starlette's
`SessionMiddleware`, re-authenticated against the database on every
request so a mid-session revocation takes effect immediately, never
trusted from the cookie alone), seven Jinja2 templates under
`service/control_plane_templates/` (`base`, `login`, `dashboard`,
`phase_new`, `phase_detail`, `proposals`, `admin_credentials`, `audit`),
all wired to real `control_plane.py`/`registry.py`/`ledger.py`/`metrics.py`
calls — no mock data. Routes cover: login/logout; dashboard (list Phases
for the signed-in tenant); Phase registration (Operator+); Phase detail
(status/metrics + Ledger table); the priority flow — append a
Development Credit entry directly (Operator+/Admin) or submit it for
review (Contributor), same form, button label switches on rights;
proposal review (approve/reject, Operator+); credential issue/revoke
(Admin); and the Control Plane's own audit log (any signed-in user).
Rights gating is enforced identically to T03's backend — the UI layer
adds no new authorization logic, it only redirects with a flash message
when a route requires more than the signed-in credential's tier.
Tests: `tests/test_control_plane_app.py`, 9 Docker-gated tests via
FastAPI's `TestClient` (same disposable-Postgres-per-module pattern as
`test_control_plane.py`) — covers unauthenticated redirect to `/login`,
invalid-token rejection, successful login/dashboard, rights-gated route
redirects (viewer blocked from Phase registration and admin pages), the
full Operator register-Phase-then-append-entry flow, the
Contributor-proposes/Operator-reviews flow, and admin credential
issuance. Explicit scope disclosure: these are HTTP-level tests against
the FastAPI app, not real-browser tests — no browser-automation tool is
available in this environment, so the `<wn-*>` web components' actual
rendering/interactivity was never visually verified, only the
server-rendered HTML/session/redirect behavior around them. Full suite:
84 passing offline (unchanged), 146 passing with Docker (up from 53); no
stray containers left running.
All four WP-0009 tasks (T01T04) are now done — this workplan is
finished.