From 9f00ae0521babd6d663273db0bce02064b6de07e Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:42:38 +1000 Subject: [PATCH 1/4] fix(web): operator UI UX + a11y overhaul, adopt site-kit tokens Resolves the 9 Major findings (+ folded Minors) from the 2026-06-28 operator web-UI design review of the server-rendered Starlette/HTMX surface: - Adopt site-kit linen/ink/brass tokens (closes brand drift); style banners, queue cards, type badges, primary vs secondary buttons, .muted/.warn/.big-number. - Full-page error chrome on navigation, bare fragment on HX-Request; the global pending-badge context processor is failure-safe so a launch-time ctx failure can no longer naked-500 the error page (regression caught in review, now tested). - 44px action targets; eliminate 320px horizontal reflow; drift badge 3.39->5.11:1. - Global pending-review badge on every page (Jinja context processor). - Intent orphans as linked titles (zero-count sections hidden); Corpus New-requirement affordance; visible auto-dismissing success toast; confidence low/med/high chips; explicit focus-visible ring. - Tests for the new behavior. make ci green (91.14% coverage), wardline clean. Also includes pre-existing working-tree edits bundled per request: legis instructions marker bump in AGENTS.md/CLAUDE.md (v1.1.1->v1.3.0) and .gitignore ignoring .wardline/. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + AGENTS.md | 2 +- CLAUDE.md | 2 +- src/plainweave/web/app.py | 39 +- src/plainweave/web/context.py | 19 + src/plainweave/web/routes/goals.py | 7 +- src/plainweave/web/routes/intent.py | 21 +- src/plainweave/web/routes/requirements.py | 15 +- src/plainweave/web/routes/review.py | 15 +- src/plainweave/web/static/app.css | 589 +++++++++++++++++- .../_partials/draft_approve_confirm.html | 2 +- .../templates/_partials/edit_conflict.html | 2 +- .../link_accept_drifted_confirm.html | 2 +- .../templates/_partials/link_reject_form.html | 4 +- .../_partials/queue_action_result.html | 6 + .../templates/_partials/queue_item_link.html | 4 +- .../web/templates/_partials/req_inline.html | 2 +- src/plainweave/web/templates/base.html | 4 +- src/plainweave/web/templates/corpus.html | 19 +- src/plainweave/web/templates/error.html | 11 + src/plainweave/web/templates/goals.html | 4 +- src/plainweave/web/templates/intent.html | 8 +- .../web/templates/requirement_detail.html | 6 +- .../web/templates/requirement_form.html | 2 +- src/plainweave/web/templates/review.html | 17 +- src/plainweave/web/views.py | 48 +- tests/web/test_app.py | 56 +- tests/web/test_intent.py | 52 ++ tests/web/test_requirements.py | 20 + tests/web/test_review.py | 53 +- 30 files changed, 953 insertions(+), 79 deletions(-) create mode 100644 src/plainweave/web/templates/error.html diff --git a/.gitignore b/.gitignore index 8ece282..d4a9915 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ findings.jsonl # Superpowers brainstorming scratch (visual-companion mockups) .superpowers/ +.wardline \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 96659c5..6a7c2f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ clean/allowed state. warpline facts are advisory and never gate. See the `warpline-workflow` skill for the full loop. - + ## Legis (git/CI + governance) Legis is the git/CI and governance layer of the Weft suite. Reach for it when a policy fires at the CI/git boundary and a change needs a *recordable* override or human sign-off, when you need governance attestations keyed to stable code identity (SEI), or when you need git/CI context — branches, commits, pull requests, check outcomes, and the Loomweave-bound rename feed — around the work. Enforcement is graded: agent-programmable policy cells decide whether a violation self-clears with an audit trail, is judged inline, or escalates to a human; every decision lands in an append-only, SEI-keyed audit trail that survives rename/move. diff --git a/CLAUDE.md b/CLAUDE.md index 96659c5..6a7c2f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,7 +180,7 @@ clean/allowed state. warpline facts are advisory and never gate. See the `warpline-workflow` skill for the full loop. - + ## Legis (git/CI + governance) Legis is the git/CI and governance layer of the Weft suite. Reach for it when a policy fires at the CI/git boundary and a change needs a *recordable* override or human sign-off, when you need governance attestations keyed to stable code identity (SEI), or when you need git/CI context — branches, commits, pull requests, check outcomes, and the Loomweave-bound rename feed — around the work. Enforcement is graded: agent-programmable policy cells decide whether a violation self-clears with an audit trail, is judged inline, or escalates to a human; every decision lands in an append-only, SEI-keyed audit trail that survives rename/move. diff --git a/src/plainweave/web/app.py b/src/plainweave/web/app.py index 4197378..36fe1e6 100644 --- a/src/plainweave/web/app.py +++ b/src/plainweave/web/app.py @@ -13,15 +13,45 @@ from starlette.templating import Jinja2Templates from plainweave.errors import PlainweaveError -from plainweave.web.context import RequestContext, csrf_ok, new_csrf_token +from plainweave.web import views +from plainweave.web.context import RequestContext, csrf_ok, new_csrf_token, request_ctx from plainweave.web.errors import error_to_status _HERE = Path(__file__).parent _CSRF_COOKIE = "pw_csrf" +def _global_context(request: Request) -> dict[str, object]: + """Inject the operator identity and the global pending-review count into every + full-page render so the nav "Review N" badge is populated on EVERY page (M6). + + HTMX swaps return base-less fragments that never read these values, so the + expensive ``pending_items`` walk is skipped for them. + + This runs on the error path too (``error.html`` extends ``base.html``), so it + must never itself raise: if the error being rendered was raised *during* + ``RequestContext`` construction (e.g. a launch-time ``POLICY_REQUIRED`` operator + or a DB-open failure), re-building the context here would raise a second time and + collapse the actionable error page into an opaque 500. Failable work is therefore + guarded and degrades to chrome-only defaults so the helpful message still renders. + """ + if request.headers.get("HX-Request"): + return {} + try: + ctx = request_ctx(request) + return { + "operator": ctx.operator, + "pending_count": len(views.pending_items(ctx.service)), + } + except Exception: # noqa: BLE001 — last-resort render path; must never double-fault. + # A successful route caches its ctx on request.state *before* the template + # renders, so the only renders that reach an uncached (failable) build here are + # the error page and ctx-less routes — never a healthy page whose error we'd mask. + return {"operator": None, "pending_count": 0} + + def create_app(*, actor: str | None, root: Path | None) -> Starlette: - templates = Jinja2Templates(directory=str(_HERE / "templates")) + templates = Jinja2Templates(directory=str(_HERE / "templates"), context_processors=[_global_context]) def ctx_factory() -> RequestContext: return RequestContext.from_root(root, actor=actor) @@ -32,9 +62,12 @@ async def healthz(request: Request) -> Response: async def on_error(request: Request, exc: Exception) -> Response: if isinstance(exc, PlainweaveError): status = error_to_status(exc.code) + # HTMX swaps a bare fragment into the live page; a normal navigation + # gets a full, navigable page with the nav + stylesheet chrome (M2). + template = "_partials/error.html" if request.headers.get("HX-Request") else "error.html" return templates.TemplateResponse( request, - "_partials/error.html", + template, {"code": exc.code.value, "message": exc.message, "hint": exc.hint}, status_code=status, ) diff --git a/src/plainweave/web/context.py b/src/plainweave/web/context.py index 93d6074..54170ad 100644 --- a/src/plainweave/web/context.py +++ b/src/plainweave/web/context.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from pathlib import Path +from starlette.requests import Request + from plainweave.errors import ErrorCode, PlainweaveError from plainweave.paths import plainweave_db_path from plainweave.service import PlainweaveService @@ -51,6 +53,23 @@ def _ensure_operator(service: PlainweaveService, actor_id: str, display: str) -> return OperatorIdentity(actor_id=actor_id, display_name=display, kind="human") +def request_ctx(request: Request) -> RequestContext: + """Return a per-request :class:`RequestContext`, building it once and caching it + on ``request.state``. + + Routes and the global Jinja context processor both reach for the context within a + single request; memoising on ``request.state`` keeps the work to one + ``PlainweaveService`` construction (and one operator self-registration) per request + instead of one per call site. + """ + cached = getattr(request.state, "ctx", None) + if isinstance(cached, RequestContext): + return cached + ctx: RequestContext = request.app.state.ctx_factory() + request.state.ctx = ctx + return ctx + + def new_csrf_token() -> str: return secrets.token_urlsafe(32) diff --git a/src/plainweave/web/routes/goals.py b/src/plainweave/web/routes/goals.py index 01e928e..17b7bb3 100644 --- a/src/plainweave/web/routes/goals.py +++ b/src/plainweave/web/routes/goals.py @@ -7,10 +7,11 @@ from starlette.templating import Jinja2Templates from plainweave.intent_graph import IntentLevel +from plainweave.web.context import request_ctx async def goals_page(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) goals = ctx.service.list_goals() orphan_goal_ids = {n.node_id for n in ctx.service.intent_orphans(IntentLevel.GOAL)} templates: Jinja2Templates = request.app.state.templates @@ -22,14 +23,14 @@ async def goals_page(request: Request) -> Response: async def goals_new(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) form = await request.form() ctx.service.create_goal(str(form["title"]), str(form["statement"]), actor=ctx.operator.actor_id) return RedirectResponse("/goals", status_code=303) async def req_ladder(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) req_id = request.path_params["req_id"] form = await request.form() ctx.service.link_goal_to_requirement(str(form["goal_id"]), req_id, actor=ctx.operator.actor_id) diff --git a/src/plainweave/web/routes/intent.py b/src/plainweave/web/routes/intent.py index d0f5e68..fae76b9 100644 --- a/src/plainweave/web/routes/intent.py +++ b/src/plainweave/web/routes/intent.py @@ -8,15 +8,32 @@ from plainweave.intent_graph import IntentLevel from plainweave.web import views +from plainweave.web.context import request_ctx async def intent_dashboard(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) cov = ctx.service.intent_coverage() orphans = { level.value: ctx.service.intent_orphans(level) for level in (IntentLevel.CODE, IntentLevel.REQUIREMENT, IntentLevel.GOAL) } + # Resolve human titles so orphans read as titles, not raw node ids (M7). Only the + # draft-only requirement orphans need a dossier lookup; approved ones carry a title + # on the record and goal titles come from the goal list. + records_by_id = {r.requirement_id: r for r in ctx.service.search_requirements()} + req_titles: dict[str, str] = {} + for node in orphans[IntentLevel.REQUIREMENT.value]: + rec = records_by_id.get(node.node_id) + if rec is None: + continue + if rec.current_version_record is not None: + req_titles[node.node_id] = rec.current_version_record.title + else: + draft = ctx.service.requirement_dossier(node.node_id).requirement.active_draft + req_titles[node.node_id] = draft.title if draft is not None else rec.id + goal_titles = {g.goal_id: g.title for g in ctx.service.list_goals()} + orphan_sections = views.build_orphan_sections(orphans, req_titles, goal_titles) templates: Jinja2Templates = request.app.state.templates return templates.TemplateResponse( request, @@ -24,7 +41,7 @@ async def intent_dashboard(request: Request) -> Response: { "cov": cov, "banner": views.coverage_banner(cov), - "orphans": orphans, + "orphan_sections": orphan_sections, "operator": ctx.operator, "active_page": "intent", }, diff --git a/src/plainweave/web/routes/requirements.py b/src/plainweave/web/routes/requirements.py index 40673f9..bb8e20a 100644 --- a/src/plainweave/web/routes/requirements.py +++ b/src/plainweave/web/routes/requirements.py @@ -11,6 +11,7 @@ from plainweave.models import RequirementRecord from plainweave.service import PlainweaveService from plainweave.web import views +from plainweave.web.context import request_ctx def _require_str(form: FormData, field: str) -> str: @@ -63,7 +64,7 @@ def _resolve_titles(svc: PlainweaveService, records: list[RequirementRecord]) -> async def corpus(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) q = request.query_params.get("q", "") status = request.query_params.get("status", "") orphan = request.query_params.get("orphan", "") @@ -86,7 +87,7 @@ async def corpus(request: Request) -> Response: async def req_inline(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) req_id = request.path_params["req_id"] dossier = ctx.service.requirement_dossier(req_id) section = dossier.requirement @@ -108,7 +109,7 @@ async def req_inline_collapsed(request: Request) -> Response: async def req_detail(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) req_id = request.path_params["req_id"] dossier = ctx.service.requirement_dossier(req_id) goals = ctx.service.list_goals() @@ -121,7 +122,7 @@ async def req_detail(request: Request) -> Response: async def req_new_get(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) templates: Jinja2Templates = request.app.state.templates return templates.TemplateResponse( request, @@ -138,7 +139,7 @@ async def req_new_get(request: Request) -> Response: async def req_new_post(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) form = await request.form() title = _require_str(form, "title") statement = _require_str(form, "statement") @@ -147,7 +148,7 @@ async def req_new_post(request: Request) -> Response: async def req_edit_get(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) req_id = request.path_params["req_id"] draft = ctx.service.requirement_dossier(req_id).requirement.active_draft templates: Jinja2Templates = request.app.state.templates @@ -166,7 +167,7 @@ async def req_edit_get(request: Request) -> Response: async def req_edit_post(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) req_id = request.path_params["req_id"] form = await request.form() title = _require_str(form, "title") diff --git a/src/plainweave/web/routes/review.py b/src/plainweave/web/routes/review.py index 20281ff..925bfa7 100644 --- a/src/plainweave/web/routes/review.py +++ b/src/plainweave/web/routes/review.py @@ -12,6 +12,7 @@ from plainweave.errors import ErrorCode, PlainweaveError from plainweave.models import RequirementDraft, RequirementRecord from plainweave.web import views +from plainweave.web.context import request_ctx if TYPE_CHECKING: from plainweave.service import PlainweaveService @@ -56,7 +57,7 @@ def _draft_ctx(service: PlainweaveService, req_id: str) -> tuple[RequirementReco async def review(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) items = views.pending_items(ctx.service) templates: Jinja2Templates = request.app.state.templates return templates.TemplateResponse( @@ -72,7 +73,7 @@ async def review(request: Request) -> Response: async def approve_confirm(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) req_id: str = request.path_params["req_id"] rec, draft = _draft_ctx(ctx.service, req_id) templates: Jinja2Templates = request.app.state.templates @@ -90,7 +91,7 @@ async def approve_confirm(request: Request) -> Response: async def approve_post(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) req_id: str = request.path_params["req_id"] form = await request.form() expected = _require_int(form, "expected_version") @@ -126,7 +127,7 @@ async def approve_post(request: Request) -> Response: async def draft_card(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) req_id: str = request.path_params["req_id"] rec, draft = _draft_ctx(ctx.service, req_id) item = views.DraftItem( @@ -177,7 +178,7 @@ async def reject_form(request: Request) -> Response: async def reject_post(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) link_id: str = request.path_params["link_id"] form = await request.form() reason = str(form.get("reason", "")).strip() @@ -208,7 +209,7 @@ async def reject_post(request: Request) -> Response: async def accept_post(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) link_id: str = request.path_params["link_id"] item = _link_item(ctx.service, link_id) ctx.service.accept_trace_link(link_id, actor=ctx.operator.actor_id) @@ -226,7 +227,7 @@ async def accept_post(request: Request) -> Response: async def link_card(request: Request) -> Response: - ctx = request.app.state.ctx_factory() + ctx = request_ctx(request) item = _link_item(ctx.service, request.path_params["link_id"]) templates: Jinja2Templates = request.app.state.templates return templates.TemplateResponse( diff --git a/src/plainweave/web/static/app.css b/src/plainweave/web/static/app.css index da03f0a..63581af 100644 --- a/src/plainweave/web/static/app.css +++ b/src/plainweave/web/static/app.css @@ -1,19 +1,570 @@ -:root { --amber: #c47b1a; --warn-bg: #fdf3e3; --line: #d9d9d9; } -body { font-family: system-ui, sans-serif; margin: 0; color: #1c1c1c; font-size: 16px; } -.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -.skip-link { position: absolute; left: -999px; } -.skip-link:focus { left: 1rem; top: 0.5rem; background: #fff; padding: 0.5rem; } -.topnav { display: flex; gap: 1rem; align-items: center; padding: 0.6rem 1rem; border-bottom: 1px solid var(--line); } -.topnav a[aria-current="page"] { font-weight: 700; text-decoration: underline; } -.nav-badge:not(:empty) { background: #b00; color: #fff; border-radius: 8px; padding: 0 6px; font-size: 0.75rem; } -.operator { margin-left: auto; opacity: 0.7; font-size: 0.85rem; } -main { padding: 1rem; } -table { border-collapse: collapse; width: 100%; font-size: 14px; } -th, td { text-align: left; padding: 6px 8px; border-top: 1px solid var(--line); } -.htmx-indicator { opacity: 0; transition: opacity 0.1s; } -.htmx-request .htmx-indicator, .htmx-indicator.htmx-request { opacity: 1; } -.queue-item--drifted { border: 1px solid var(--amber); border-left-width: 4px; background: var(--warn-bg); padding: 0.6rem; } -.drift-badge { display: inline-block; background: var(--amber); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 1px 7px; border-radius: 4px; } -.toggle-btn { border: 1px solid var(--line); border-radius: 4px; padding: 3px 9px; font-size: 0.8rem; } -.toggle-btn--active { border-width: 2px; font-weight: 700; } -.toggle-btn--active::before { content: "✓ "; } +/* ============================================================ + Plainweave operator UI — self-contained stylesheet. + Adopts the Weft site-kit design language ("natural dyes on + unbleached cloth": warm linen surfaces, ink text, brass/woad + accents) by INLINING the token values read from + site/vendor/site-kit/src/tokens/*.css and the relevant plain + component rules from components.css. + NO build step, NO @import, NO external font — light theme only. + ============================================================ */ + +/* ---- Token layer (inlined from site-kit) ------------------- */ +:root { + /* neutrals — linen → ink (warm) */ + --linen-50: #FBF8F1; + --linen-100: #F5F0E5; + --linen-200: #EBE4D4; + --linen-300: #DDD3BF; + --thread-400:#C2B69E; + --ink-400: #8C8470; + --ink-500: #6E6857; + --ink-600: #585241; + --ink-700: #464134; + --loom-800: #2C281F; + --loom-900: #1E1B15; + + /* brass / weld — work + WARNING + primary accent here */ + --brass-50: #F7EFD8; + --brass-100: #EEDFB4; + --brass-400: #C99A2E; + --brass-500: #AC8222; + --brass-600: #8C681A; + --brass-700: #6B4F14; + + /* woad — Plainweave's own thread (OK / success) */ + --woad-50: #E5F0EB; + --woad-100: #C6E0D5; + --woad-600: #235E4D; + --woad-700: #1A463A; + + /* madder — DANGER / missing / reject */ + --madder-50: #F8ECE7; + --madder-100: #F0D6CC; + --madder-600: #993C26; + --madder-700: #7A2F1E; + + /* indigo — federation spine: links + focus ring */ + --indigo-50: #ECEFF6; + --indigo-100: #D3DAEA; + --indigo-600: #35487B; + --indigo-700: #283861; + + /* ---- semantic aliases ---- */ + --bg-page: var(--linen-100); + --surface-card: #FFFDF8; /* raised card; chosen so .muted (ink-500) keeps 5.46:1 on it */ + --surface-raised: var(--linen-200); + --surface-sunken: var(--linen-50); + + --text-strong: var(--loom-900); + --text-heading:var(--loom-800); + --text-body: var(--ink-700); + --text-muted: var(--ink-500); + --text-faint: var(--ink-400); + --text-on-accent: #FBF8F1; + + /* lines — see border-contrast note below */ + --border-hairline: var(--thread-400); /* structural rules (table/nav); decorative, darker than the old #d9d9d9 */ + --border-control: var(--ink-400); /* interactive control outlines — 3.27:1 on linen-100 (WCAG 1.4.11) */ + --border-focus: var(--indigo-600); + + --link: var(--indigo-600); + --link-strong: var(--indigo-700); + + /* status */ + --ok: var(--woad-600); + --ok-tint: var(--woad-50); + --warn: var(--brass-600); + --warn-tint: var(--brass-50); + --danger: var(--madder-600); + --danger-tint:var(--madder-50); + + /* the work accent (primary actions, badges) */ + --accent: var(--brass-600); + --accent-strong: var(--brass-700); + + /* radius */ + --radius-sm: 3px; + --radius-md: 5px; + --radius-lg: 8px; + --radius-pill: 999px; + + /* spacing (4px grid) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 32px; + + /* type — system fonts only (no external face) */ + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --fw-regular: 400; + --fw-medium: 500; + --fw-semibold:600; + --fw-bold: 700; + + /* elevation */ + --shadow-sm: 0 1px 2px rgba(30, 27, 21, 0.07), 0 0 0 1px rgba(30, 27, 21, 0.04); + --shadow-md: 0 2px 6px rgba(30, 27, 21, 0.08), 0 1px 2px rgba(30, 27, 21, 0.06); + --shadow-lg: 0 8px 24px rgba(30, 27, 21, 0.12), 0 2px 6px rgba(30, 27, 21, 0.07); + --ring: 0 0 0 3px rgba(53, 72, 123, 0.30); +} + +/* border-contrast note: the task asks for "faint #d9d9d9 borders + darkened to >=3:1". Interactive control outlines use + --border-control (--ink-400 #8C8470 = 3.27:1 on the linen page), + satisfying WCAG 1.4.11 where a boundary identifies a control. + Purely decorative table/nav rules use --border-hairline + (--thread-400, 1.76:1) — non-essential per 1.4.11, but still + meaningfully darker than the original #d9d9d9 (1.24:1). */ + +/* ---- reset + element defaults ------------------------------ */ +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.5; + color: var(--text-body); + background: var(--bg-page); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +h1, h2, h3, h4 { + margin: 0 0 var(--space-3); + color: var(--text-heading); + font-weight: var(--fw-semibold); + line-height: 1.2; + letter-spacing: -0.01em; +} +h1 { font-size: 1.9rem; } +h2 { font-size: 1.35rem; } +h3 { font-size: 1.1rem; } + +p { margin: 0 0 var(--space-3); text-wrap: pretty; } + +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; text-underline-offset: 2px; } +a:visited { color: var(--link); } + +code, kbd, samp, pre { + font-family: var(--font-mono); + font-size: 0.9em; +} + +main { padding: var(--space-5) var(--space-6); } +section { margin: 0 0 var(--space-5); } + +/* Global focus ring (>=2px) so focus never relies on the UA default. */ +:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; + border-radius: 2px; +} + +::selection { background: var(--indigo-100); color: var(--loom-900); } + +/* ---- accessibility helpers (PRESERVED) --------------------- */ +.visually-hidden { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} +.skip-link { + position: absolute; left: -999px; + background: var(--surface-card); color: var(--link-strong); + padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); + box-shadow: var(--shadow-md); z-index: 100; font-weight: var(--fw-semibold); +} +.skip-link:focus { left: var(--space-4); top: var(--space-2); } + +/* ---- top navigation (PRESERVED + re-tokenised) ------------- */ +.topnav { + display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: center; + padding: var(--space-3) var(--space-6); + background: var(--surface-card); + border-bottom: 1px solid var(--border-hairline); +} +.topnav a { color: var(--text-body); font-weight: var(--fw-medium); } +.topnav a:hover { color: var(--link-strong); } +.topnav a[aria-current="page"] { + color: var(--link-strong); font-weight: var(--fw-bold); + text-decoration: underline; text-underline-offset: 4px; + text-decoration-thickness: 2px; +} +.nav-badge:not(:empty) { + display: inline-block; margin-left: 4px; + background: var(--accent); color: var(--text-on-accent); + border-radius: var(--radius-pill); padding: 1px 8px; + font-size: 0.72rem; font-weight: var(--fw-bold); + font-family: var(--font-mono); line-height: 1.5; vertical-align: middle; +} +.operator { + margin-left: auto; color: var(--text-muted); + font-size: 0.85rem; font-family: var(--font-mono); +} + +/* ---- htmx loader (PRESERVED) ------------------------------- */ +.htmx-indicator { opacity: 0; transition: opacity 0.12s; } +.htmx-request .htmx-indicator, +.htmx-indicator.htmx-request { opacity: 1; } +#global-loader { + position: fixed; top: var(--space-3); right: var(--space-4); z-index: 50; +} +.loader-spinner { + display: inline-block; width: 18px; height: 18px; + border: 2px solid var(--brass-100); + border-top-color: var(--accent); + border-radius: 50%; + animation: pw-spin 0.7s linear infinite; +} +@keyframes pw-spin { to { transform: rotate(360deg); } } + +/* ---- buttons ----------------------------------------------- */ +/* Base/secondary = quiet outline-ghost. Primary = filled brass. */ +.btn, +button, +input[type="submit"], +input[type="button"] { + display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); + min-height: 44px; /* SC 2.5.8 target size (comfortable touch) */ + padding: var(--space-2) var(--space-4); + font-family: var(--font-sans); font-size: 0.95rem; font-weight: var(--fw-semibold); + line-height: 1.1; white-space: nowrap; cursor: pointer; text-decoration: none; + color: var(--text-body); + background: var(--surface-card); + border: 1px solid var(--border-control); + border-radius: var(--radius-md); + transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} +.btn:hover, +button:hover, +input[type="submit"]:hover, +input[type="button"]:hover { + background: var(--linen-200); border-color: var(--ink-500); text-decoration: none; +} +.btn:focus-visible, +button:focus-visible, +input[type="submit"]:focus-visible, +input[type="button"]:focus-visible { + outline: 2px solid var(--border-focus); outline-offset: 2px; box-shadow: var(--ring); +} +button[disabled], .btn[disabled] { opacity: 0.5; cursor: not-allowed; } + +/* secondary == explicit quiet variant (same as base) */ +.btn--secondary { + background: var(--surface-card); color: var(--text-body); + border: 1px solid var(--border-control); +} +.btn--secondary:hover { background: var(--linen-200); border-color: var(--ink-500); } + +/* PRIMARY — filled brass, visually dominant. + .queue-action-primary is styled identically to .btn--primary. */ +.btn--primary, +.queue-action-primary { + background: var(--accent); color: var(--text-on-accent); + border: 1px solid var(--accent); + font-weight: var(--fw-bold); + box-shadow: var(--shadow-sm); +} +.btn--primary:hover, +.queue-action-primary:hover { + background: var(--accent-strong); border-color: var(--accent-strong); + color: var(--text-on-accent); text-decoration: none; +} +.btn--primary:focus-visible, +.queue-action-primary:focus-visible { + outline: 2px solid var(--border-focus); outline-offset: 2px; box-shadow: var(--ring); +} + +/* ---- forms ------------------------------------------------- */ +form { margin: 0 0 var(--space-4); } +label { + display: block; margin-bottom: var(--space-3); + font-weight: var(--fw-semibold); color: var(--text-body); font-size: 0.95rem; +} +input[type="text"], +input[type="search"], +input:not([type]), +textarea, +select { + display: block; width: 100%; max-width: 42rem; margin-top: var(--space-1); + min-height: 44px; padding: var(--space-2) var(--space-3); + font-family: var(--font-sans); font-size: 1rem; color: var(--text-strong); + background: var(--surface-sunken); + border: 1px solid var(--border-control); border-radius: var(--radius-md); + box-shadow: inset 0 1px 2px rgba(30, 27, 21, 0.06); +} +textarea { min-height: 6rem; resize: vertical; line-height: 1.5; } +input::placeholder, textarea::placeholder { color: var(--text-faint); } +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: 2px solid var(--border-focus); outline-offset: 1px; + border-color: var(--border-focus); box-shadow: var(--ring); +} + +/* ---- corpus filter / search + toggles (PRESERVED behaviour) */ +search { display: block; margin-bottom: var(--space-5); } +search form { display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: flex-end; margin: 0; } +search label[for="req-search"] { margin-bottom: var(--space-1); } +#req-search { max-width: 22rem; } + +.filter-toggles { + display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; + margin: 0; padding: 0 0 0 var(--space-2); + border: 0; border-left: 2px solid var(--border-hairline); +} +.filter-toggles legend { + float: left; width: 100%; padding: 0; margin-bottom: var(--space-1); + font-size: 0.78rem; font-weight: var(--fw-semibold); + text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); +} +.toggle-btn { + display: inline-flex; align-items: center; min-height: 36px; + margin-bottom: 0; padding: var(--space-1) var(--space-3); + font-size: 0.82rem; font-weight: var(--fw-medium); color: var(--text-body); + background: var(--surface-card); + border: 1px solid var(--border-control); border-radius: var(--radius-pill); + cursor: pointer; user-select: none; +} +.toggle-btn:hover { background: var(--linen-200); } +.toggle-btn--active { + border-width: 2px; border-color: var(--accent); + background: var(--brass-50); color: var(--accent-strong); font-weight: var(--fw-bold); +} +.toggle-btn--active::before { content: "\2713\00a0"; } +/* keyboard focus surfaces on the visually-hidden radio inside the label */ +.toggle-btn:focus-within { outline: 2px solid var(--border-focus); outline-offset: 2px; } + +/* ---- corpus data table (M4 horizontal-scroll wrapper) ------ */ +.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } +table { border-collapse: collapse; width: 100%; font-size: 0.9rem; } +caption { text-align: left; } +th, td { + text-align: left; padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-hairline); vertical-align: top; +} +thead th { + border-top: 0; border-bottom: 2px solid var(--border-hairline); + color: var(--text-heading); font-weight: var(--fw-semibold); + white-space: nowrap; +} +.corpus-row { cursor: pointer; } +.corpus-row:hover td { background: var(--surface-card); } +.corpus-row:focus-within td { background: var(--brass-50); } +.corpus-row-detail td { padding: 0; border-top: 0; } +.corpus-row-detail [id^="req-detail-"]:not(:empty) { + padding: var(--space-3) var(--space-4); + background: var(--surface-sunken); + border-bottom: 1px solid var(--border-hairline); +} + +/* ---- inline requirement panel (corpus expand) -------------- */ +.req-inline { + display: flex; flex-direction: column; gap: var(--space-3); +} +.req-inline__statement { margin: 0; color: var(--text-body); } +.req-inline__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; } + +/* ---- helpers: muted / warn / big-number -------------------- */ +.muted { color: var(--text-muted); } /* ink-500: 5.46:1 on cards, 4.89:1 on page */ +.warn { color: var(--danger); font-weight: var(--fw-semibold); } /* madder-600: 6.10:1 on linen */ +.big-number { + display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-3); + margin: 0 0 var(--space-5); + font-size: 2.5rem; font-weight: var(--fw-bold); line-height: 1.05; + color: var(--text-strong); font-variant-numeric: tabular-nums; +} +.big-number .muted { font-size: 1rem; font-weight: var(--fw-regular); } + +/* ---- banners (tinted bg + left accent + padding) ----------- */ +.banner { + margin: 0 0 var(--space-4); + padding: var(--space-3) var(--space-4); + background: var(--surface-card); color: var(--text-body); + border: 1px solid var(--border-hairline); + border-left: 4px solid var(--text-faint); + border-radius: var(--radius-md); + font-size: 0.95rem; line-height: 1.5; +} +.banner--warn { + background: var(--warn-tint); color: var(--text-body); + border-color: var(--brass-100); border-left-color: var(--warn); +} + +/* ---- review queue cards ------------------------------------ */ +#queue-list { display: flex; flex-direction: column; gap: var(--space-4); } +.queue-item { + position: relative; + padding: var(--space-4); + background: var(--surface-card); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} +.queue-item header { + display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); + margin-bottom: var(--space-2); +} +.queue-item h2 { + margin: 0; font-size: 1.05rem; font-weight: var(--fw-semibold); + overflow-wrap: anywhere; /* M4: long ids don't force page scroll */ +} +.queue-item p { margin: 0 0 var(--space-3); } +.queue-item p:last-child { margin-bottom: 0; } +/* long unbreakable identifiers in review link labels (M4) */ +.queue-item code, .queue-item em, .queue-item h2 code, .queue-item h2 em { + overflow-wrap: anywhere; word-break: break-word; +} +.queue-item code { + font-family: var(--font-mono); font-size: 0.85em; + background: var(--surface-sunken); padding: 1px 5px; border-radius: var(--radius-sm); +} + +/* drifted card — amber treatment (PRESERVED, re-tokenised) */ +.queue-item--drifted { + background: var(--warn-tint); + border: 1px solid var(--brass-400); + border-left-width: 4px; border-left-color: var(--brass-500); +} +.drift-notice { + margin: 0 0 var(--space-3); font-size: 0.88rem; + color: var(--brass-700); /* 6.6:1 on the warn-tint card */ +} + +/* ---- badges: type-badge, drift-badge ----------------------- */ +.type-badge { + display: inline-flex; align-items: center; + font-family: var(--font-mono); font-size: 0.68rem; font-weight: var(--fw-semibold); + letter-spacing: 0.04em; + padding: 3px 8px; border-radius: var(--radius-pill); + background: var(--linen-200); color: var(--ink-700); + border: 1px solid var(--linen-300); +} +/* DRIFT-BADGE CONTRAST (M5): + fg #FFFFFF on bg #8C681A (brass-600) = 5.11:1 (>= 4.5:1 AA). + The previous white-on-#c47b1a pairing was only 3.39:1. */ +.drift-badge { + display: inline-block; + background: var(--brass-600); color: #FFFFFF; + font-family: var(--font-mono); font-size: 0.7rem; font-weight: var(--fw-bold); + letter-spacing: 0.04em; + padding: 2px 8px; border-radius: var(--radius-sm); +} + +/* ---- confidence chip (AI calibration encoding) ------------- */ +.conf { + display: inline-flex; align-items: center; gap: 5px; + font-family: var(--font-mono); font-size: 0.7rem; font-weight: var(--fw-semibold); + letter-spacing: 0.02em; text-transform: uppercase; + padding: 2px 8px; border-radius: var(--radius-pill); + border: 1px solid transparent; vertical-align: baseline; +} +.conf--low { background: var(--danger-tint); color: var(--madder-700); border-color: var(--madder-100); } +.conf--med { background: var(--warn-tint); color: var(--brass-700); border-color: var(--brass-100); } +.conf--high { background: var(--ok-tint); color: var(--woad-700); border-color: var(--woad-100); } + +/* ---- queue item action rows -------------------------------- */ +.qi-actions { + display: flex; flex-wrap: wrap; gap: var(--space-3); + align-items: center; margin-top: var(--space-3); +} +.qi-actions a { font-weight: var(--fw-medium); } + +/* dossier approve slot + draft section */ +#dossier-approve-slot { margin-top: var(--space-3); } +section.draft { + padding: var(--space-4); background: var(--surface-card); + border: 1px solid var(--border-hairline); border-left: 4px solid var(--accent); + border-radius: var(--radius-md); +} + +/* ---- empty queue ------------------------------------------- */ +.empty-queue { + padding: var(--space-7) var(--space-6); text-align: center; + background: var(--surface-card); + border: 1px dashed var(--border-control); border-radius: var(--radius-lg); +} +.empty-queue h2 { color: var(--text-heading); } +.empty-queue h2:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 4px; } + +/* ---- edit conflict panel ----------------------------------- */ +.conflict-panel { + padding: var(--space-4); background: var(--surface-card); + border: 1px solid var(--border-hairline); border-radius: var(--radius-lg); +} +.conflict-columns { + display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-5); + align-items: start; +} +.conflict-columns > div, +.conflict-columns > form { + min-width: 0; padding: var(--space-3); + background: var(--surface-sunken); + border: 1px solid var(--border-hairline); border-radius: var(--radius-md); +} +.conflict-columns h3 { margin-top: 0; font-size: 0.95rem; color: var(--text-muted); } +.conflict-columns p { overflow-wrap: anywhere; } + +/* ---- error page (M2 full-page chrome) ---------------------- */ +.error-page { max-width: 42rem; margin: 0 auto; padding: var(--space-7) var(--space-6); } +.error-page h1 { color: var(--text-heading); } +.error-code { + display: inline-block; font-family: var(--font-mono); font-size: 0.85rem; + font-weight: var(--fw-semibold); letter-spacing: 0.04em; + padding: 3px 9px; border-radius: var(--radius-sm); + background: var(--danger-tint); color: var(--madder-700); + border: 1px solid var(--madder-100); +} +.error-hint { color: var(--text-muted); } + +/* ---- success toast (M9) — VISIBLE mirror of #sr-status ------ */ +/* #toast is decorative (aria-hidden); the SR live region is #sr-status. + Empty by default → hidden; OOB innerHTML swap reveals it. */ +#toast.pw-toast, +.pw-toast { display: none; } +/* Reveal must match the id-anchored hide above on specificity, else display:none wins. */ +#toast.pw-toast:not(:empty), +.pw-toast:not(:empty) { + display: block; position: fixed; z-index: 60; + left: 50%; bottom: var(--space-6); transform: translateX(-50%); + max-width: min(92vw, 30rem); + padding: var(--space-3) var(--space-4); + background: var(--ok-tint); color: var(--woad-700); + border: 1px solid var(--woad-100); border-left: 4px solid var(--ok); + border-radius: var(--radius-md); box-shadow: var(--shadow-lg); + font-size: 0.95rem; font-weight: var(--fw-medium); line-height: 1.4; + animation: pw-toast-in 0.18s ease-out; +} +@keyframes pw-toast-in { + from { opacity: 0; transform: translate(-50%, 8px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +/* ---- lists (goals, intent orphans) ------------------------- */ +main ul { padding-left: var(--space-5); margin: 0 0 var(--space-4); } +main li { margin-bottom: var(--space-2); } + +/* ---- reduced motion ---------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ---- narrow viewport (M4 reflow @ 320px) ------------------- */ +@media (max-width: 480px) { + main { padding: var(--space-4) var(--space-3); } + .topnav { gap: var(--space-3); padding: var(--space-3) var(--space-4); } + .operator { width: 100%; margin-left: 0; order: 99; } + .conflict-columns { grid-template-columns: 1fr; } + .qi-actions { flex-direction: column; align-items: stretch; } + .qi-actions .btn, + .qi-actions button { width: 100%; } +} diff --git a/src/plainweave/web/templates/_partials/draft_approve_confirm.html b/src/plainweave/web/templates/_partials/draft_approve_confirm.html index cbd5609..81163e9 100644 --- a/src/plainweave/web/templates/_partials/draft_approve_confirm.html +++ b/src/plainweave/web/templates/_partials/draft_approve_confirm.html @@ -5,6 +5,6 @@ {% include "_partials/csrf.html" %} - + diff --git a/src/plainweave/web/templates/_partials/edit_conflict.html b/src/plainweave/web/templates/_partials/edit_conflict.html index fcb8774..41b44b7 100644 --- a/src/plainweave/web/templates/_partials/edit_conflict.html +++ b/src/plainweave/web/templates/_partials/edit_conflict.html @@ -7,7 +7,7 @@

Your edits (not saved)

- +

Current draft

{{ current_title }}

{{ current_statement }}

Discard mine — start from current
diff --git a/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html b/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html index bec8ea0..b82d49d 100644 --- a/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html +++ b/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html @@ -5,6 +5,6 @@ {% include "_partials/csrf.html" %} - + diff --git a/src/plainweave/web/templates/_partials/link_reject_form.html b/src/plainweave/web/templates/_partials/link_reject_form.html index ed98b85..4c1274b 100644 --- a/src/plainweave/web/templates/_partials/link_reject_form.html +++ b/src/plainweave/web/templates/_partials/link_reject_form.html @@ -4,7 +4,7 @@ {% if error %}{% endif %} - - + + diff --git a/src/plainweave/web/templates/_partials/queue_action_result.html b/src/plainweave/web/templates/_partials/queue_action_result.html index e344036..b9e7c63 100644 --- a/src/plainweave/web/templates/_partials/queue_action_result.html +++ b/src/plainweave/web/templates/_partials/queue_action_result.html @@ -4,6 +4,12 @@ {% if remaining_count > 0 %}{{ remaining_count }} item{{ 's' if remaining_count != 1 }} remaining in queue. {% else %}Queue is now empty.{% endif %} +{# Visible toast mirrors the SR announcement above (M9). Host is aria-hidden; SR users hear #sr-status. #} +
+ {{ action_label }}: {{ item_desc }}. + {% if remaining_count > 0 %}{{ remaining_count }} item{{ 's' if remaining_count != 1 }} remaining in queue. + {% else %}Queue is now empty.{% endif %} +
{% if remaining_count > 0 %}{{ remaining_count }}{% endif %} {% if remaining_count == 0 %}
{% include "_partials/queue_empty.html" %}
diff --git a/src/plainweave/web/templates/_partials/queue_item_link.html b/src/plainweave/web/templates/_partials/queue_item_link.html index 9f07e49..6ff3a89 100644 --- a/src/plainweave/web/templates/_partials/queue_item_link.html +++ b/src/plainweave/web/templates/_partials/queue_item_link.html @@ -3,7 +3,7 @@ {% endif %}
LINK
-

{{ item.proposing_actor }}{% if item.confidence is not none %} · conf {{ item.confidence }}{% endif %}

+

{{ item.proposing_actor }}{% if item.confidence is not none %} · conf {{ item.confidence }}{% set band = 'low' if item.confidence < 0.5 else ('high' if item.confidence >= 0.8 else 'med') %} {{ band }}{% endif %}

{% if item.drifted %} {% endif %} -
diff --git a/src/plainweave/web/templates/_partials/req_inline.html b/src/plainweave/web/templates/_partials/req_inline.html index 65c4605..646c88e 100644 --- a/src/plainweave/web/templates/_partials/req_inline.html +++ b/src/plainweave/web/templates/_partials/req_inline.html @@ -2,6 +2,6 @@

{{ statement }}

Full dossier → - +
diff --git a/src/plainweave/web/templates/base.html b/src/plainweave/web/templates/base.html index 8277c71..0b62379 100644 --- a/src/plainweave/web/templates/base.html +++ b/src/plainweave/web/templates/base.html @@ -18,11 +18,13 @@ Intent Goals - operator: {{ operator.display_name }} · {{ operator.kind }} + {% if operator %}operator: {{ operator.display_name }} · {{ operator.kind }}{% endif %} {# Permanent SR status live region — NEVER replaced via outerHTML; innerHTML-OOB only. #}
+ {# Visible confirmation toast mirroring #sr-status. Decorative: SR users hear #sr-status. #} + {# Decorative global loader; status comes from #sr-status, so this is aria-hidden. #} diff --git a/src/plainweave/web/templates/corpus.html b/src/plainweave/web/templates/corpus.html index c2619bc..fbb5e94 100644 --- a/src/plainweave/web/templates/corpus.html +++ b/src/plainweave/web/templates/corpus.html @@ -3,13 +3,16 @@ {% block title %}Corpus · Plainweave{% endblock %} {% block main %}

Corpus

+

New requirement

{% include "_partials/corpus_filter.html" %} - - - - - - {% include "_partials/corpus_rows.html" %} - -
RequirementStatusGoalCode links
+
+ + + + + + {% include "_partials/corpus_rows.html" %} + +
RequirementStatusGoalCode links
+
{% endblock %} diff --git a/src/plainweave/web/templates/error.html b/src/plainweave/web/templates/error.html new file mode 100644 index 0000000..47372ec --- /dev/null +++ b/src/plainweave/web/templates/error.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Error · Plainweave{% endblock %} +{% block main %} +
+

Something went wrong

+

{{ code }}

+

{{ message }}

+ {% if hint %}

{{ hint }}

{% endif %} +

Back to corpus

+
+{% endblock %} diff --git a/src/plainweave/web/templates/goals.html b/src/plainweave/web/templates/goals.html index 2fcb2f3..b970124 100644 --- a/src/plainweave/web/templates/goals.html +++ b/src/plainweave/web/templates/goals.html @@ -6,8 +6,8 @@

Goals

{% include "_partials/csrf.html" %} - - + +