Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/agents-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@62ed0a86b5d57062ac3d04f4519e3998858e2d96" # v1
uses: "stranske/Workflows/.github/actions/setup-api-client@a335f1af2c35b8f35d2278f56e9af78792a09bf1" # v1
with:
secrets: ${{ toJSON(secrets) }}
github_token: ${{ github.token }}
Expand Down Expand Up @@ -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@62ed0a86b5d57062ac3d04f4519e3998858e2d96" # v1
uses: "stranske/Workflows/.github/actions/setup-api-client@a335f1af2c35b8f35d2278f56e9af78792a09bf1" # v1
with:
secrets: ${{ toJSON(secrets) }}
github_token: ${{ github.token }}
Expand Down
9 changes: 8 additions & 1 deletion design-system/PRESENTATION_PATTERNS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ everywhere, beats N one-off PRs — and it's why this is a design-system rollout
## 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`).
Expand All @@ -28,6 +30,7 @@ NEVER a bare prompt sitting above already-rendered content, and NEVER an interna
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).
Expand All @@ -37,21 +40,24 @@ user. Translate at the boundary to a plain message + a recovery action.
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 `<details>`/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.
- **Streamlit:** `ds_streamlit.availability_badge(label)` in the tab title / disabled control caption; use `plain=False` only in trusted HTML containers.
- **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`).
Expand All @@ -75,6 +81,7 @@ rather than opening into a silent empty/disabled surface.
| 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`.
Expand Down
7 changes: 3 additions & 4 deletions design-system/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Shared Design System (staging)
# Shared Design System

Two themes on one token foundation, plus an orthogonal density axis.

Expand Down Expand Up @@ -38,8 +38,7 @@ That's the "default with per-app customization" model: the base is canonical; an
|---|---|
| `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`). |
| `ds_streamlit.py` | Streamlit-native helpers for the same presentation patterns. |

## Theme → app mapping (proposed)

Expand All @@ -48,4 +47,4 @@ That's the "default with per-app customization" model: the base is canonical; an

## 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.
This kit is managed from `Workflows/templates/consumer-repo/design-system/` and distributed by the Maint 68 Sync Consumer Repos GitHub Actions workflow through `.github/sync-manifest.yml`. Update it here first, then let the sync workflow replace consumer copies.
4 changes: 4 additions & 0 deletions design-system/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
.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 a:focus-visible, .ds button:focus-visible, .ds input:focus-visible, .ds select:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }

.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; }
Expand Down Expand Up @@ -93,3 +94,6 @@
/* 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} }
@media (prefers-reduced-motion: reduce) {
.ds .skeleton { animation:none; background:var(--panel); }
}
73 changes: 57 additions & 16 deletions design-system/ds_streamlit.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

import logging
from collections.abc import Callable, Mapping
from contextlib import contextmanager
from html import escape
from typing import Any

logger = logging.getLogger("ds")
Expand Down Expand Up @@ -88,9 +90,12 @@ def empty_state(
clicked. NEVER pass an internal filename/path as `desc`."""
import streamlit as st

safe_icon = escape(str(icon))
safe_title = escape(str(title))
safe_desc = escape(str(desc))
st.markdown(
f"<div class='ds-empty'><div style='font-size:22px;opacity:.6'>{icon}</div>"
f"<div class='t'>{title}</div><div class='d'>{desc}</div></div>",
f"<div class='ds-empty'><div style='font-size:22px;opacity:.6'>{safe_icon}</div>"
f"<div class='t'>{safe_title}</div><div class='d'>{safe_desc}</div></div>",
unsafe_allow_html=True,
)
if cta_label:
Expand All @@ -103,16 +108,17 @@ def empty_state(

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)."""
{error,warn,info,ok}. `action` is optional literal remediation text."""
import streamlit as st

color, bg, ic = _NOTICE_STYLE.get(kind, _NOTICE_STYLE["info"])
head = f"<strong>{title}</strong><br>" if title else ""
act = f"<div style='margin-top:6px'>{action}</div>" if action else ""
head = f"<strong>{escape(str(title))}</strong><br>" if title else ""
act = f"<div style='margin-top:6px'>{escape(str(action))}</div>" if action else ""
safe_body = escape(str(body))
st.markdown(
f"<div class='ds-notice' style='background:{bg};border-color:{color}33'>"
f"<span class='ic' style='color:{color}'>{ic}</span>"
f"<div>{head}{body}{act}</div></div>",
f"<div>{head}{safe_body}{act}</div></div>",
unsafe_allow_html=True,
)

Expand All @@ -128,18 +134,19 @@ def translate_error(exc: Exception) -> tuple[str, str | None]:
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)
text_lower = text.lower()
# Known field-required cases (extend per app as needed).
if "financing_mode" in text:
if "financing_mode" in text_lower:
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:
if "exceeds total capital" in text_lower or "capital buffer" in text_lower:
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:
if "no investable funds" in text_lower or "no_funds" in text_lower:
return (
"No funds matched the selection filters.",
"Try another preset or relax the selection settings.",
Expand All @@ -155,16 +162,50 @@ def dev_note(msg: str) -> None:
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"<span class='ds-badge'>{label}</span>"
@contextmanager
def diagnostics_expander(label: str = "Diagnostics", *, expanded: bool = False):
"""P4 — explicit opt-in container for diagnostics that must be visible."""
import streamlit as st

with st.expander(label, expanded=expanded):
yield


def availability_badge(label: str, *, plain: bool = True) -> str:
"""P5 — availability marker for tabs/captions.

The default is safe for Streamlit surfaces that render labels as literal
text. Use `plain=False` only inside containers rendered as trusted HTML.
"""
text = str(label).strip()
if plain:
return f" · {text}"
return f"<span class='ds-badge'>{escape(text)}</span>"


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"
# Best-effort: prefer the most specific non-opaque namespace segment.
segments = str(raw).replace("/", ":").split(":")
for segment in reversed(segments):
label = _human_label_segment(segment)
if label:
return label
return "item"


def _human_label_segment(segment: str) -> str:
words = [part for part in segment.replace("-", "_").split("_") if part]
meaningful = []
for word in words:
lowered = word.lower()
if lowered.isdigit():
continue
if len(lowered) >= 8 and all(ch in "0123456789abcdef" for ch in lowered):
continue
Comment on lines +206 to +207

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat UUID chunks as opaque in labels

For IDs whose most-specific segment is a UUID, e.g. fund:550e8400-e29b-41d4-a716-446655440000, this filter only drops the 8+ character hex chunks and keeps the shorter UUID pieces (e29b, 41d4, a716), so humanize_id returns raw identifier fragments instead of continuing to the fund segment or item. That breaks the P6 helper specifically in the common UUID-backed record-key case it is meant to hide.

Useful? React with 👍 / 👎.

meaningful.append(word)
if not meaningful:
return ""
return " ".join(meaningful).strip()
4 changes: 2 additions & 2 deletions design-system/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
--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-body: system-ui, -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", "Helvetica", "Arial", sans-serif;
--font-heading: var(--font-body);
--heading-weight: 700;
}
Expand Down Expand Up @@ -71,6 +71,6 @@
--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;
--font-heading: "Georgia", "Times New Roman", serif;
--heading-weight:600;
}
Loading