From 8339c3635e766bf67558ce558c28a9756fdd0e5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 23 Jun 2026 04:42:33 +0000 Subject: [PATCH] chore: sync workflow templates from Workflows repo Automated sync from stranske/Workflows Template hash: 0ab0029407c1 Changes synced from sync-manifest.yml --- .github/workflows/agents-guard.yml | 4 +- design-system/PRESENTATION_PATTERNS.md | 99 ++++++++++++++ design-system/README.md | 51 ++++++++ design-system/components.css | 95 ++++++++++++++ design-system/ds_streamlit.py | 170 +++++++++++++++++++++++++ design-system/tokens.css | 76 +++++++++++ 6 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 design-system/PRESENTATION_PATTERNS.md create mode 100644 design-system/README.md create mode 100644 design-system/components.css create mode 100644 design-system/ds_streamlit.py create mode 100644 design-system/tokens.css diff --git a/.github/workflows/agents-guard.yml b/.github/workflows/agents-guard.yml index 7035b86ece..90bf8f4e1f 100644 --- a/.github/workflows/agents-guard.yml +++ b/.github/workflows/agents-guard.yml @@ -111,7 +111,7 @@ jobs: github.event_name == 'pull_request_target' && steps.eligibility.outputs.should-run == 'true' && steps.api_client_base.outputs.available != 'true' - uses: "stranske/Workflows/.github/actions/setup-api-client@c2537cc959f2ce05926c4639d25b90678abc97bc" # v1 + uses: "stranske/Workflows/.github/actions/setup-api-client@62ed0a86b5d57062ac3d04f4519e3998858e2d96" # v1 with: secrets: ${{ toJSON(secrets) }} github_token: ${{ github.token }} @@ -180,7 +180,7 @@ jobs: steps.eligibility.outputs.should-run == 'true' && github.event_name == 'pull_request' && steps.api_client_head.outputs.available != 'true' - uses: "stranske/Workflows/.github/actions/setup-api-client@c2537cc959f2ce05926c4639d25b90678abc97bc" # v1 + uses: "stranske/Workflows/.github/actions/setup-api-client@62ed0a86b5d57062ac3d04f4519e3998858e2d96" # v1 with: secrets: ${{ toJSON(secrets) }} github_token: ${{ github.token }} diff --git a/design-system/PRESENTATION_PATTERNS.md b/design-system/PRESENTATION_PATTERNS.md new file mode 100644 index 0000000000..77bf1062da --- /dev/null +++ b/design-system/PRESENTATION_PATTERNS.md @@ -0,0 +1,99 @@ +# Presentation Patterns — the fleet design-system rollout standard + +The cross-repo UX-Review baseline (2026-06-22, 8 apps) found that **every app's engine works; the +failures are presentation + deployment.** Deployment is a separate program. This doc covers the +**presentation** class: it collapses the ~12 recurring presentation findings into **6 reusable +patterns**, each with a rule, a design-system component (`components.css` / `tokens.css`), a +per-app-type application (Streamlit / web-SPA / static-PWA), and the fleet findings it closes. + +**Principle:** apps should consume a *pattern*, not invent a bespoke fix. One pattern, applied +everywhere, beats N one-off PRs — and it's why this is a design-system rollout, not a bug list. + +--- + +## The 6 patterns + +### P1 — Light, understated theme by default +**Rule:** never ship a default dark theme; default to `theme-air` (Ink & Air). Dark is opt-in only. +- **web:** load `tokens.css` + `components.css`; root element `class="ds theme-air"` (`theme-paper` for friendlier apps). +- **Streamlit:** `.streamlit/config.toml` `[theme] base="light"` + the shared `ds_streamlit.inject_theme()` (maps the `theme-air` tokens to Streamlit's theme). *Already prototyped for TMP.* +- **Fixes:** default-dark on Trend_Model_Project / Portable-Alpha / Manager-Database / Inv-Man-Intake. + +### P2 — Empty state = title + reason + next action +**Rule:** a "no data yet" surface ALWAYS shows a title, a one-line reason, and a **next-action CTA**. +NEVER a bare prompt sitting above already-rendered content, and NEVER an internal filename/path. +- **web:** `.ds .empty-state` (`.es-icon/.es-title/.es-desc/.es-cta`). +- **Streamlit:** `ds_streamlit.empty_state(title, desc, cta_label, on_click)`. +- **Fixes:** TMP #5628 (Run-analysis CTA above results), PA #2021 (Results "Outputs.xlsx not found"), + Manager-Database #1214 (empty default date / "Recent Activity"), LMS #351 (root `/` JSON 404). + +### P3 — Errors are human messages + remediation +**Rule:** NEVER surface a raw exception, pydantic/validation dump, internal field name, or stack to a +user. Translate at the boundary to a plain message + a recovery action. +- **web:** `.ds .notice--error` (`.ic` + `.body strong` headline + `.body .act` remediation). +- **Streamlit:** `ds_streamlit.error(message, remediation)` backed by `translate_error(exc)` (maps + known fields, e.g. `financing_mode` → "Financing mode is required"). +- **Fixes:** PA #2021 (raw `ModelConfig financing_mode` / margin pydantic errors), Inv-Man-Intake (item_id), + the broad "raw error" class across the fleet. + +### P4 — Dev/diagnostic notices go to logs, not the UI +**Rule:** auth-bypass / trace-sink / observability / "dev mode" notices NEVER render in the main +content. Use logging, or at most a collapsed "Diagnostics" expander. +- **web:** n/a in the main flow; use a `
`/debug panel. +- **Streamlit:** `ds_streamlit.dev_note(msg)` → `logging` (not `st.warning`/`st.write`); diagnostics behind `st.expander("Diagnostics")`. +- **Fixes:** Manager-Database #1215 (auth-bypass `st.warning`), Inv-Man-Intake #630 (trace-sink banner). + +### P5 — Feature-availability markers (no silent dead-ends) +**Rule:** a tab/control that isn't applicable in the current mode states so up front (a badge/label), +rather than opening into a silent empty/disabled surface. +- **web:** `.ds .badge` on the tab/control (e.g. "multi-period only", "needs setup"). +- **Streamlit:** `ds_streamlit.availability_badge(label)` in the tab title / disabled control caption. +- **Fixes:** TMP #5629 (4/6 Results tabs empty — fixed by labelling, the canonical example), PA #2026 + (upload-only pages with no sample path → mark/offer the sample). + +### P6 — No raw internal identifiers in user surfaces +**Rule:** decode internal IDs / fixture filenames / record keys into human-readable labels before display. +- **web/Streamlit:** a display-name mapping; keep the raw id as secondary/`title=` metadata if useful. +- **Fixes:** Inv-Man-Intake #629 (opaque `item_id`), #630 (raw fixture filename in selector), PA Results (`Outputs.xlsx`). + +--- + +## Finding → pattern map (presentation class) + +| Finding | Pattern(s) | +|---|---| +| TMP #5628 empty-state CTA above results | P2 | +| TMP #5629 unmarked empty tabs *(fixed)* | P5 | +| Manager-Database #1214 empty default views | P2 | +| Manager-Database #1215 auth notice in UI | P4 | +| Inv-Man-Intake #629 non-actionable raw-JSON queue | P6 (+ a real action, app-specific) | +| Inv-Man-Intake #630 trace-sink + raw filenames | P4, P6 | +| Portable-Alpha #2021 raw errors / empty states | P2, P3 | +| Portable-Alpha #2026 upload-only, no sample | P5 | +| Pension-Data #594 *(deployment program — offline)* | — | +| LMS #351 root JSON 404 / empty surfaces | P2 (+ deployment) | +| Default dark theme (TMP/PA/MD/IMI) | P1 | + +## Streamlit design kit (most of the fleet is Streamlit) +The CSS components above cover the web apps (Pension-Data, trip-planner, LMS). The four Streamlit +Tier-A apps need a Streamlit-native equivalent — ship a shared `ds_streamlit.py` alongside the CSS: +- `inject_theme()` — applies the `theme-air` palette (P1); pairs with `.streamlit/config.toml`. +- `empty_state(title, desc, cta_label=None, on_click=None)` (P2) +- `notice(kind, title, body, action=None)` and `error(message, remediation=None)` + `translate_error(exc)` (P3) +- `dev_note(msg)` → logging; `diagnostics_expander()` (P4) +- `availability_badge(label)` (P5) +- `humanize_id(raw, mapping)` (P6) +Graduate this kit + the CSS into `Workflows/templates/consumer-repo/design-system/` so maint-68 syncs it fleet-wide. + +## Rollout sequence +1. **Graduate** `tokens.css` + `components.css` (with the new patterns) + `ds_streamlit.py` into the Workflows consumer-repo design-system; let the existing sync (maint-68) distribute it. +2. **Apply per app, highest-ROI first** — close each open finding by adopting its pattern (not a bespoke fix), one small PR per app with the pattern's named test gate: + - TMP #5628 → P2 (closest to a clean pass) + - Manager-Database #1214 → P2, #1215 → P4 + - Inv-Man-Intake #630 → P4/P6 (#629 also needs a real queue action) + - Portable-Alpha #2021 → P2/P3, #2026 → P5 + - Pension-Data / LMS web surfaces → P2/P3 via the CSS components +3. **Theme pass (P1)** across the Streamlit apps once the kit is synced. + +_Authored 2026-06-22 from the UX-Review fleet baseline. Components live in `components.css`/`tokens.css` +(this dir); see each repo's `docs/ux-review/REVIEW_LOG.md` for its findings + scores._ diff --git a/design-system/README.md b/design-system/README.md new file mode 100644 index 0000000000..aa50075a08 --- /dev/null +++ b/design-system/README.md @@ -0,0 +1,51 @@ +# Shared Design System (staging) + +Two themes on one token foundation, plus an orthogonal density axis. + +- **`theme-air`** (Ink & Air) — default; work / outward-facing tools. +- **`theme-paper`** (Warm Paper) — friendlier apps (Reader, LMS). +- **`density-compact`** — tightens spacing for data-dense screens (e.g. Trend tables) **without** changing the theme. + +## Use + +```html + + + + +
+ ...components (.panel, .kpi, .appbar, table, .btns, .callout, ...)... +
+ +``` + +- Components are scoped under `.ds` so they never leak into a host app's styles. +- `components.css` is theme-agnostic — it only reads tokens. Don't fork it. + +## Per-app customization + +Override any token in an app stylesheet loaded **after** `tokens.css`: + +```css +.theme-air { --accent: #0f6f6a; } /* this app wants a teal accent */ +``` + +That's the "default with per-app customization" model: the base is canonical; an app changes only the tokens it needs. + +## Files + +| File | Role | +|---|---| +| `tokens.css` | Variables — themes + density. **Source of truth for the look.** | +| `components.css` | Component styles, token-driven and theme-agnostic. | +| `index.html` | Link-based reference page. Open locally, or import into Claude Design. | +| `preview.html` | Generated self-contained snapshot for sharing (regenerate with `build_preview.py`). | + +## Theme → app mapping (proposed) + +- **Ink & Air:** Trend_Model_Project, Counter_Risk, Manager-Database, Inv-Man-Intake, Pension-Data, Portable-Alpha-Extension-Model, trip-planner, Travel-Plan-Permission. Use `density-compact` on Trend/Counter_Risk data-dense screens. +- **Warm Paper:** Reader, learning-management-system. + +## Status & next step + +**STAGED** here in the tracker. Graduates to `Workflows/templates/consumer-repo/design-system/` with a `sync-manifest.yml` entry (`is_directory: true`; base tokens `template_sync: exact`) as a deliberate Workflows PR — see [`../PLAN.md`](../PLAN.md) §3.3. Claude Design imports from the Workflows path once graduated, enabling the prototype-in-Design → land-in-repo round-trip. diff --git a/design-system/components.css b/design-system/components.css new file mode 100644 index 0000000000..42a2b36828 --- /dev/null +++ b/design-system/components.css @@ -0,0 +1,95 @@ +/* components.css — component layer. Token-driven and theme-AGNOSTIC. + * Requires tokens.css + a theme class (.theme-air | .theme-paper) on an ancestor. + * Everything is scoped under .ds so it never leaks into a host app's own styles. + */ +.ds { color:var(--text); font-family:var(--font-body); font-size:var(--fs-base); line-height:1.5; background:var(--bg); } +.ds *, .ds *::before, .ds *::after { box-sizing:border-box; } +.ds h3 { font-family:var(--font-heading); font-weight:var(--heading-weight); margin:0 0 var(--space-3); font-size:15px; letter-spacing:-.005em; } +.ds .sub { color:var(--muted); font-size:var(--fs-sm); margin:calc(-1 * var(--space-2)) 0 var(--space-3); } + +.ds .panel { background:var(--surface); border:var(--card-border); border-radius:var(--radius); box-shadow:var(--shadow); padding:var(--card-pad); margin-bottom:var(--space-4); } + +.ds .appbar { display:flex; align-items:center; gap:var(--space-4); padding:var(--control-pad-y) var(--card-pad); background:var(--surface); border:var(--card-border); border-radius:var(--radius); box-shadow:var(--shadow); margin-bottom:var(--space-4); } +.ds .appbar .brand { font-family:var(--font-heading); font-weight:700; font-size:15px; } +.ds .appbar nav { display:flex; gap:var(--space-1); flex-wrap:wrap; } +.ds .appbar nav a { font-size:var(--fs-sm); text-decoration:none; color:var(--muted); padding:5px 10px; border-radius:var(--radius-sm); cursor:pointer; } +.ds .appbar nav a.active { color:var(--accent); background:var(--accent-weak); font-weight:600; } +.ds .appbar .spacer { flex:1; } + +.ds .kpis { display:flex; flex-wrap:wrap; gap:var(--space-3); margin-bottom:var(--space-4); } +.ds .kpi { flex:1; min-width:150px; background:var(--surface); border:var(--card-border); border-radius:var(--radius); box-shadow:var(--shadow); padding:var(--space-3) var(--space-4); } +.ds .kpi .label { font-size:var(--fs-sm); color:var(--muted); } +.ds .kpi .val { font-size:var(--kpi-size); font-weight:700; letter-spacing:-.02em; margin-top:4px; font-family:var(--font-heading); } +.ds .kpi .delta { font-size:var(--fs-sm); font-weight:600; margin-top:2px; } +.ds .up { color:var(--pos); } .ds .down { color:var(--neg); } + +.ds .grid2 { display:grid; grid-template-columns:1.3fr 1fr; gap:var(--space-4); } +@media (max-width:720px){ .ds .grid2 { grid-template-columns:1fr; } } + +.ds table { width:100%; border-collapse:collapse; font-size:var(--fs-sm); } +.ds thead th { text-align:left; color:var(--muted); font-weight:600; font-size:var(--fs-xs); text-transform:uppercase; letter-spacing:.04em; padding:var(--row-pad-y) var(--row-pad-x); border-bottom:1px solid var(--border); } +.ds tbody td { padding:var(--row-pad-y) var(--row-pad-x); border-bottom:1px solid var(--row-border); } +.ds tbody tr:nth-child(even) { background:var(--zebra); } +.ds td.num, .ds th.num { text-align:right; font-variant-numeric:tabular-nums; } + +.ds .field { margin-bottom:var(--space-3); } +.ds label { display:block; font-size:var(--fs-sm); font-weight:600; margin-bottom:5px; } +.ds label .help { font-weight:400; color:var(--muted); } +.ds .q { display:inline-flex; width:15px; height:15px; border-radius:50%; border:1px solid var(--border); color:var(--muted); font-size:10px; align-items:center; justify-content:center; vertical-align:1px; margin-left:5px; cursor:help; } +.ds input[type=text], .ds select { width:100%; font:inherit; font-size:var(--fs-base); padding:var(--control-pad-y) var(--control-pad-x); color:var(--text); background:var(--input-bg); border:1px solid var(--border); border-radius:var(--input-radius); } +.ds input[type=range] { width:100%; accent-color:var(--accent); } + +.ds .btns { display:flex; flex-wrap:wrap; gap:var(--space-2); margin-top:var(--space-1); } +.ds button { font:inherit; font-size:var(--fs-sm); font-weight:600; padding:var(--control-pad-y) 15px; border-radius:var(--btn-radius); cursor:pointer; border:1px solid transparent; } +.ds .b-primary { background:var(--btn-primary-bg); color:var(--btn-primary-contrast); border-color:var(--btn-primary-bg); } +.ds .b-secondary { background:var(--surface); color:var(--text); border:1px solid var(--border); } +.ds .b-ghost { background:transparent; color:var(--accent); } +.ds .b-danger { background:transparent; color:var(--neg); border:1px solid var(--neg-border); } +.ds button[disabled] { opacity:.45; cursor:not-allowed; } + +.ds .callout { display:flex; gap:10px; padding:11px 13px; border-radius:var(--radius); background:var(--accent-weak); border:1px solid var(--accent-line); font-size:var(--fs-sm); } +.ds .callout .ic { color:var(--accent); font-weight:700; } +.ds .empty { text-align:center; color:var(--muted); border:1px dashed var(--border); border-radius:var(--radius); padding:20px; font-size:var(--fs-sm); } +.ds .chart { color:var(--accent); display:block; } + +/* ============================================================================ + * PRESENTATION-STATE PATTERNS (added 2026-06-22 from the cross-fleet UX-Review + * baseline). These standardize the recurring failure classes: empty/dead-end + * views, raw errors, dev/diagnostic leaks, and unavailable features. + * See PRESENTATION_PATTERNS.md for the rule behind each + per-app-type usage. + * ========================================================================== */ + +/* notice — the ONE container for user-facing messages. Never render a raw + * exception / internal field name / filename / CLI flag; translate to a human + * message + remediation and put it here. Variants by semantics. */ +.ds .notice { display:flex; gap:10px; align-items:flex-start; padding:11px 13px; border-radius:var(--radius); font-size:var(--fs-sm); border:1px solid var(--border); background:var(--panel); } +.ds .notice .ic { font-weight:700; flex:none; line-height:1.4; } +.ds .notice .body { flex:1; } +.ds .notice .body strong { display:block; margin-bottom:2px; } +.ds .notice .body .act { margin-top:6px; } /* remediation link/button slot */ +.ds .notice--info { background:var(--info-weak); border-color:var(--accent-line); } +.ds .notice--info .ic { color:var(--info); } +.ds .notice--warn { background:var(--warn-weak); border-color:var(--warn); } +.ds .notice--warn .ic { color:var(--warn); } +.ds .notice--error { background:var(--neg-weak); border-color:var(--neg-border); } +.ds .notice--error .ic { color:var(--neg); } +.ds .notice--ok { background:var(--pos-weak); border-color:var(--pos); } +.ds .notice--ok .ic { color:var(--pos); } + +/* empty-state — for "no data yet" surfaces. ALWAYS a title + one-line reason + + * a next-action; NEVER a bare prompt sitting above already-rendered results, + * and NEVER an internal filename/path. */ +.ds .empty-state { text-align:center; border:1px dashed var(--border); border-radius:var(--radius); padding:28px 20px; } +.ds .empty-state .es-icon { font-size:22px; opacity:.6; } +.ds .empty-state .es-title { font-family:var(--font-heading); font-weight:var(--heading-weight); font-size:15px; margin:8px 0 4px; } +.ds .empty-state .es-desc { color:var(--muted); font-size:var(--fs-sm); max-width:42ch; margin:0 auto 12px; } +.ds .empty-state .es-cta { display:inline-flex; gap:8px; } + +/* badge — small availability/status marker (e.g. "multi-period only", + * "needs setup") so a tab/control states its applicability up front. */ +.ds .badge { display:inline-block; font-size:var(--fs-xs); font-weight:600; padding:1px 7px; border-radius:999px; background:var(--panel); color:var(--muted); border:1px solid var(--border); vertical-align:middle; } +.ds .badge--muted { opacity:.8; } + +/* skeleton — loading placeholder so a pending surface never looks empty/broken. */ +.ds .skeleton { background:linear-gradient(90deg,var(--panel) 25%,var(--border) 37%,var(--panel) 63%); background-size:400% 100%; border-radius:var(--radius-sm); animation:ds-shimmer 1.3s ease infinite; min-height:14px; } +@keyframes ds-shimmer { 0%{background-position:100% 0} 100%{background-position:0 0} } diff --git a/design-system/ds_streamlit.py b/design-system/ds_streamlit.py new file mode 100644 index 0000000000..6ba94ce842 --- /dev/null +++ b/design-system/ds_streamlit.py @@ -0,0 +1,170 @@ +"""ds_streamlit — Streamlit adapter for the shared design system. + +The CSS design system (tokens.css / components.css) covers the web apps. The +Streamlit Tier-A apps can't consume CSS components directly, so this module +ships the same PRESENTATION PATTERNS as small Streamlit helpers. Apps should +call these instead of inventing bespoke empty-states / error displays / dev +banners. See PRESENTATION_PATTERNS.md for the rule behind each (P1..P6). + +Usage: + from ds_streamlit import inject_theme, empty_state, error, dev_note + inject_theme() # P1 — once, near st.set_page_config + ... + if result is None: + empty_state("No results yet", # P2 + "Run the demo to generate diagnostics.", + cta_label="Run demo", on_click=run_demo) + st.stop() + ... + try: + cfg = build_config(...) + except Exception as exc: + error(*translate_error(exc)) # P3 — never show the raw exception +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Mapping +from typing import Any + +logger = logging.getLogger("ds") + +# Ink & Air (theme-air) palette — keep in sync with tokens.css .theme-air. +_INK = "#0a0a0a" +_MUTED = "#737373" +_ACCENT = "#4f46e5" +_BORDER = "#ececec" +_NEG = "#dc2626" +_NEG_WEAK = "#fef2f2" +_WARN = "#b45309" +_WARN_WEAK = "#fffbeb" +_INFO_WEAK = "#f4f3ff" +_POS = "#047857" +_POS_WEAK = "#ecfdf5" + +_NOTICE_STYLE = { + "error": (_NEG, _NEG_WEAK, "✕"), + "warn": (_WARN, _WARN_WEAK, "!"), + "info": (_ACCENT, _INFO_WEAK, "i"), + "ok": (_POS, _POS_WEAK, "✓"), +} + + +def inject_theme() -> None: + """P1 — apply the light/understated theme. Pair with .streamlit/config.toml + ([theme] base=\"light\"); this nudges spacing/headings to match the system.""" + import streamlit as st + + st.markdown( + f"""""", + unsafe_allow_html=True, + ) + + +def empty_state( + title: str, + desc: str = "", + *, + icon: str = "📭", + cta_label: str | None = None, + on_click: Callable[[], Any] | None = None, + cta_key: str | None = None, +) -> bool: + """P2 — title + reason + optional next-action. Returns True if the CTA was + clicked. NEVER pass an internal filename/path as `desc`.""" + import streamlit as st + + st.markdown( + f"
{icon}
" + f"
{title}
{desc}
", + unsafe_allow_html=True, + ) + if cta_label: + clicked = st.button(cta_label, type="primary", key=cta_key) + if clicked and on_click: + on_click() + return clicked + return False + + +def notice(kind: str, title: str = "", body: str = "", action: str | None = None) -> None: + """P3/P4 — the one container for user-facing messages. kind in + {error,warn,info,ok}. `action` is optional remediation (markdown).""" + import streamlit as st + + color, bg, ic = _NOTICE_STYLE.get(kind, _NOTICE_STYLE["info"]) + head = f"{title}
" if title else "" + act = f"
{action}
" if action else "" + st.markdown( + f"
" + f"{ic}" + f"
{head}{body}{act}
", + unsafe_allow_html=True, + ) + + +def error(message: str, remediation: str | None = None) -> None: + """P3 — human error message + optional remediation. NEVER pass a raw + exception/traceback/internal field name; use translate_error() first.""" + notice("error", body=message, action=remediation) + + +def translate_error(exc: Exception) -> tuple[str, str | None]: + """P3 — map a known backend exception to (human_message, remediation). + Falls back to a generic message; the raw text is logged, not shown.""" + logger.warning("ds.translate_error: %s", exc, exc_info=True) + text = str(exc) + # Known field-required cases (extend per app as needed). + if "financing_mode" in text: + return ( + "Financing mode isn't set for this run.", + "Choose a financing mode (e.g. per-path) and run again.", + ) + if "exceeds total capital" in text or "capital buffer" in text: + return ( + "The capital allocation isn't feasible.", + "Reduce the internal allocation or volatility multiple to leave margin headroom.", + ) + if "No investable funds" in text or "NO_FUNDS" in text: + return ( + "No funds matched the selection filters.", + "Try another preset or relax the selection settings.", + ) + return ( + "Something went wrong running this step.", + "Adjust the inputs and try again; if it persists, check the run logs.", + ) + + +def dev_note(msg: str) -> None: + """P4 — dev/diagnostic state goes to logs, NEVER the main UI.""" + logger.info("ds.dev_note: %s", msg) + + +def availability_badge(label: str) -> str: + """P5 — markup for a small availability marker (use in a tab title/caption), + e.g. tab label f"Export {availability_badge('multi-period only')}".""" + return f"{label}" + + +def humanize_id(raw: str, mapping: Mapping[str, str] | None = None) -> str: + """P6 — decode an internal id to a human label; never show raw keys.""" + if mapping and raw in mapping: + return mapping[raw] + # Best-effort: take a trailing human-ish segment, strip hashes. + tail = str(raw).replace("_", " ").split(":")[0].strip() + return tail or "item" diff --git a/design-system/tokens.css b/design-system/tokens.css new file mode 100644 index 0000000000..830d30524c --- /dev/null +++ b/design-system/tokens.css @@ -0,0 +1,76 @@ +/* tokens.css — shared design-system tokens. + * + * One set of variable NAMES; two THEMES supply the values (.theme-air, .theme-paper); + * an orthogonal DENSITY axis (.density-compact) tightens spacing for data-dense screens + * WITHOUT changing the theme's identity. + * + * Apply on a root element: + * work / outward-facing (default) + * dense data screens (e.g. Trend tables) + * friendlier apps (Reader, LMS) + * + * Per-app customization: override any token in an app stylesheet loaded AFTER this file + * (e.g. redefine --accent). Never fork components.css. + */ + +:root { + /* ---- spacing / density: comfortable (default) ---- */ + --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-6: 24px; + --card-pad: 18px; + --control-pad-y: 8px; --control-pad-x: 10px; + --row-pad-y: 9px; --row-pad-x: 10px; + --fs-base: 14px; --fs-sm: 12.5px; --fs-xs: 11.5px; + --kpi-size: 26px; + + /* ---- shape (a theme may override) ---- */ + --radius: 10px; --radius-sm: 8px; --btn-radius: 8px; --input-radius: 8px; + + /* ---- type ---- */ + --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-heading: var(--font-body); + --heading-weight: 700; +} + +/* ---- density axis (orthogonal to theme) ---- */ +.density-compact { + --space-3: 8px; --space-4: 10px; --space-6: 14px; + --card-pad: 12px; + --control-pad-y: 6px; --control-pad-x: 8px; + --row-pad-y: 6px; --row-pad-x: 8px; + --fs-base: 13px; --fs-sm: 12px; + --kpi-size: 22px; +} + +/* ===== THEME: Ink & Air — default, outward-facing work ===== */ +.theme-air { + --bg:#ffffff; --surface:#ffffff; --panel:#fafafa; + --text:#0a0a0a; --muted:#737373; + --border:#ececec; --row-border:#f2f2f2; --zebra:transparent; + --input-bg:#ffffff; + --accent:#4f46e5; --accent-contrast:#ffffff; --accent-weak:#f4f3ff; --accent-line:#e7e5fb; + --btn-primary-bg:#0a0a0a; --btn-primary-contrast:#ffffff; /* ink buttons, indigo highlights */ + --pos:#047857; --neg:#dc2626; --neg-border:#f2cccc; + /* ---- notice/state semantics (errors, warnings, info, empty) ---- */ + --warn:#b45309; --info:#4f46e5; + --pos-weak:#ecfdf5; --neg-weak:#fef2f2; --warn-weak:#fffbeb; --info-weak:var(--accent-weak); + --card-border: none; --shadow: none; /* borderless, whitespace-led */ + --heading-weight:700; +} + +/* ===== THEME: Warm Paper — friendlier apps ===== */ +.theme-paper { + --bg:#faf7f1; --surface:#fffdf9; --panel:#f3efe7; + --text:#2b2622; --muted:#857a6c; + --border:#e4ddcf; --row-border:#efe9dd; --zebra:#fbf8f1; + --input-bg:#fffdf9; + --accent:#a85a34; --accent-contrast:#ffffff; --accent-weak:#f6ece4; --accent-line:#e9d4c4; + --btn-primary-bg: var(--accent); --btn-primary-contrast:#ffffff; + --pos:#5a7d2f; --neg:#a3402e; --neg-border:#e3c3bb; + /* ---- notice/state semantics ---- */ + --warn:#9a6a16; --info:var(--accent); + --pos-weak:#eef3e6; --neg-weak:#f6e9e6; --warn-weak:#f7efe1; --info-weak:var(--accent-weak); + --card-border: 1px solid var(--border); --shadow: 0 1px 2px rgba(80,60,30,.06); + --radius:8px; --radius-sm:7px; --btn-radius:7px; --input-radius:7px; + --font-heading: Georgia, "Times New Roman", serif; + --heading-weight:600; +}