mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
Consolidate unified-design-system docs into this monorepo
Moves the design-system rules, frozen token table, and annotated component references out of ~/.claude/skills/ and the two Claude Artifacts into a proper subdir here, matching the existing gatelock/crdt-sync/guard-lib pattern of one-subdir-per-shared-library. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
63badeaed5
commit
a50d27688f
11
README.md
11
README.md
@ -51,3 +51,14 @@ published to PyPI). Consumed via pip by `screen-locker`, `diet-guard`, and
|
||||
```
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock
|
||||
```
|
||||
|
||||
## unified-design-system/
|
||||
|
||||
Not a package — the frozen design tokens (colors, spacing, type, radius,
|
||||
shadow policy) and per-stack (Flutter/web/Tkinter) implementation patterns
|
||||
that keep every one of kuhy's repos visually identical, plus annotated
|
||||
component references (`components.html`, `button.html`). Read
|
||||
[`unified-design-system/README.md`](unified-design-system/README.md) and
|
||||
`tokens.md` before touching any theme/CSS/style file in any repo. The
|
||||
`unified-design-system` Claude Code skill is a thin pointer here, not a
|
||||
duplicate.
|
||||
|
||||
158
unified-design-system/README.md
Normal file
158
unified-design-system/README.md
Normal file
@ -0,0 +1,158 @@
|
||||
# unified-design-system
|
||||
|
||||
The concrete, frozen design tokens (colors, spacing, type, radius, shadow
|
||||
policy) that implement `safe-design-rules`' 28 abstract rules identically
|
||||
across every one of kuhy's repos — Flutter, web/React, and Python/Tkinter.
|
||||
One palette everywhere (including Flutter launcher-icon glyphs), not just
|
||||
shared rules. Use whenever building or fixing UI in any of kuhy's repos —
|
||||
this is what a design audit's violations should be fixed *against*, and what
|
||||
new UI should be built from instead of inventing new values. Full frozen
|
||||
value table in [`tokens.md`](tokens.md); annotated component references in
|
||||
[`components.html`](components.html) (9 components: Button/Card/Input/Chip/
|
||||
Dialog/Switch/Divider/Checkbox/SnackBar) and [`button.html`](button.html)
|
||||
(the earlier single-component exemplar that set the doc format).
|
||||
|
||||
Read `tokens.md` for the full frozen value table before touching any
|
||||
theme/CSS/style file.
|
||||
|
||||
## Why one palette, not per-app branding
|
||||
|
||||
Apps are visually unified down to the exact hex values, not just the same
|
||||
rules with different accents. This also means the Flutter launcher-icon
|
||||
family (the `app-icon` skill/generator) uses the same accent for every app's
|
||||
glyph — apps are distinguished by glyph shape, not color.
|
||||
|
||||
## Per-stack implementation pattern
|
||||
|
||||
### Flutter
|
||||
|
||||
Build `ThemeData` from **explicit** `ColorScheme.dark(...)`/`.light(...)`
|
||||
using the exact hex values in `tokens.md` — not `ColorScheme.fromSeed`, which
|
||||
algorithmically derives tones and won't reproduce hand-picked values. Add:
|
||||
|
||||
- A `ThemeExtension` for roles M3 doesn't have (success/warning — `error` is
|
||||
already `danger`), e.g. `AppStatusColors(success:, warning:)`. Only add
|
||||
this if the app actually has success/warning status indicators — grep for
|
||||
existing `Colors.green`/`Colors.orange`-style usage first; don't add it
|
||||
speculatively (it becomes untested dead code otherwise).
|
||||
- `AppSpacing` / `AppRadius` / `AppTextSize` static const classes mirroring
|
||||
the scales in `tokens.md`.
|
||||
- Wire every screen through `Theme.of(context)` — the dominant violation
|
||||
across every Flutter audit was raw `Colors.*` literals bypassing an
|
||||
existing (or newly added) theme. Grep for `Colors\.` / `Color(0x` after
|
||||
wiring the theme and route every hit through a theme role.
|
||||
|
||||
```dart
|
||||
ThemeData buildTheme() => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
surface: Color(0xFF211D1B), // ink
|
||||
surfaceContainerHighest: Color(0xFF38312E), // ink-raised-2
|
||||
onSurface: Color(0xFFECEAE9), // text-on-dark
|
||||
onSurfaceVariant: Color(0xFFAAA09A), // muted-on-dark
|
||||
outline: Color(0xFF463E3A), // line-dark
|
||||
primary: Color(0xFFB8862E), // accent
|
||||
onPrimary: Color(0xFF211D1B), // on-fill — NOT text-on-dark, see below
|
||||
error: Color(0xFFE2585F), // danger
|
||||
onError: Color(0xFF211D1B), // on-fill
|
||||
),
|
||||
extensions: const [AppStatusColors(
|
||||
success: Color(0xFF8A9A3C),
|
||||
onSuccess: Color(0xFF211D1B), // on-fill
|
||||
warning: Color(0xFFE0A63C),
|
||||
onWarning: Color(0xFF211D1B), // on-fill
|
||||
)],
|
||||
);
|
||||
```
|
||||
|
||||
`onPrimary`/`onError`/`onSuccess`/`onWarning` are all `on-fill` (`#211D1B`,
|
||||
dark), never `onSurface`'s near-white — see `tokens.md`'s `on-fill` entry:
|
||||
every filled semantic color under-contrasts with near-white text.
|
||||
|
||||
### Web (CSS custom properties)
|
||||
|
||||
One `:root` block (plus a light/dark override block as needed) with these
|
||||
variable names — kept identical across repos so the vocabulary transfers:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: #211d1b; --surface-1: #2b2624; --surface-2: #38312e;
|
||||
--border: #463e3a; --text: #eceae9; --text-muted: #aaa09a;
|
||||
--accent: #b8862e; --success: #8a9a3c; --warning: #e0a63c; --danger: #e2585f;
|
||||
--on-fill: #211d1b; /* text/icons ON a filled accent/success/warning/danger surface */
|
||||
--on-scrim: #eceae9; /* text/icons on a dark translucent overlay/backdrop — fixed, not theme-tied */
|
||||
--radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px;
|
||||
--space-1: 4px; --space-2: 8px; --space-3: 16px; --space-4: 24px;
|
||||
--space-5: 32px; --space-6: 48px;
|
||||
--font-sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Code", monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f6f4f3; --surface-1: #fcfbfb; --surface-2: #fcfbfb;
|
||||
--border: #e0dad7; --text: #211d1b; --text-muted: #70625b;
|
||||
/* --on-fill / --on-scrim stay fixed — the fills and the scrim don't change with page theme */
|
||||
}
|
||||
}
|
||||
body { font-size: 16px; font-family: var(--font-sans); }
|
||||
button.primary { background: var(--accent); color: var(--on-fill); }
|
||||
```
|
||||
|
||||
Don't reach for a single "on-accent" variable for both cases (an earlier
|
||||
dufs-cloud draft did, and it broke the moment the accent went from blue to
|
||||
gold): a filled button needs `--on-fill` (dark), a caption over a photo-tile
|
||||
scrim needs `--on-scrim` (near-white) — same-looking near-white token, wrong
|
||||
one silently fails contrast on fills.
|
||||
|
||||
Every hardcoded color/spacing/radius/shadow value in component CSS should
|
||||
route through one of these variables. No `#fff`/`#000`/`rgba(0,0,0,…)`
|
||||
literals — use `var(--text)`/`var(--bg)` or an `ink`-tinted rgba.
|
||||
|
||||
### Python/Tkinter
|
||||
|
||||
No `ttk.Style()` in any of these apps currently — introduce (or, for the
|
||||
screen-locker/wake_alarm/diet_guard family, extend) a shared constants
|
||||
module/dataclass with the same token names, and have every `tk.Label`/
|
||||
`tk.Button`/`tk.Frame` read from it instead of inline hex literals:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Tokens:
|
||||
bg: str = "#211D1B"
|
||||
fg: str = "#ECEAE9"
|
||||
muted: str = "#AAA09A"
|
||||
field_bg: str = "#2B2624"
|
||||
accent: str = "#B8862E"
|
||||
success: str = "#8A9A3C"
|
||||
warning: str = "#E0A63C"
|
||||
danger: str = "#E2585F"
|
||||
on_fill: str = "#211D1B" # text on an accent/success/warning/danger bg — NOT fg
|
||||
font_family: str = "Arial"
|
||||
```
|
||||
|
||||
A shared `_button(bg=...)` helper that always sets `fg=self._colors.fg` is
|
||||
the exact bug this caught: `fg` (near-white) is correct when `bg` is
|
||||
`field_bg`/`bg` (a dark neutral surface), but any button whose `bg` is
|
||||
`accent`/`success`/`warning`/`danger` needs `fg=self._colors.on_fill`
|
||||
instead — pick `fg` from `bg`, don't hardcode it per call site.
|
||||
|
||||
For screen-locker/wake_alarm/diet_guard specifically: this lives on
|
||||
`gatelock.LockConfig` (the shared token source already vendored in this same
|
||||
`~/utils` monorepo, see [`../gatelock/`](../gatelock/)) rather than a
|
||||
per-repo duplicate, since all three already depend on it. Extending
|
||||
`LockConfig` requires production verification
|
||||
(`/usr/bin/python3 -c "import gatelock"`) since it backs 3 live systemd
|
||||
services — a dev-venv-only check is not sufficient.
|
||||
|
||||
## Do NOT
|
||||
|
||||
- Don't use `ColorScheme.fromSeed` for the unified palette — it can't
|
||||
reproduce hand-picked hex values exactly.
|
||||
- Don't add shadows to dark surfaces, or mix shadow + border depth on the
|
||||
same element (see `tokens.md`'s shadow policy).
|
||||
- Don't invent a new spacing/radius/font value outside the scales in
|
||||
`tokens.md` — round to the nearest step instead.
|
||||
- Don't add a `ThemeExtension`/status-color role speculatively — check the
|
||||
app actually uses that status (success/warning/etc.) before adding it.
|
||||
- Don't touch `../gatelock` without the production-path verification above;
|
||||
a broken import there takes down 3 services at once.
|
||||
255
unified-design-system/button.html
Normal file
255
unified-design-system/button.html
Normal file
@ -0,0 +1,255 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Button — unified-design-system</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #211d1b; --ink-r1: #2b2624; --ink-r2: #38312e;
|
||||
--line-dark: #463e3a; --text-dark: #ecEae9; --muted-dark: #aaa09a;
|
||||
|
||||
--paper: #f6f4f3; --paper-r: #fcfbfb;
|
||||
--line-light: #e0dad7; --text-light: #211d1b; --muted-light: #70625b;
|
||||
|
||||
--accent: #b8862e; --success: #8a9a3c; --warning: #e0a63c; --danger: #e2585f;
|
||||
--on-fill: #211d1b;
|
||||
|
||||
--bg: var(--paper); --surface-1: var(--paper-r); --surface-2: var(--paper-r);
|
||||
--border: var(--line-light); --text: var(--text-light); --text-muted: var(--muted-light);
|
||||
|
||||
--radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px;
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 16px; --sp-4: 24px; --sp-5: 32px; --sp-6: 48px;
|
||||
|
||||
--sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Code", monospace;
|
||||
|
||||
--arrow: color-mix(in srgb, var(--accent) 55%, var(--text-muted));
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: var(--ink); --surface-1: var(--ink-r1); --surface-2: var(--ink-r2);
|
||||
--border: var(--line-dark); --text: var(--text-dark); --text-muted: var(--muted-dark);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: var(--ink); --surface-1: var(--ink-r1); --surface-2: var(--ink-r2);
|
||||
--border: var(--line-dark); --text: var(--text-dark); --text-muted: var(--muted-dark);
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: var(--paper); --surface-1: var(--paper-r); --surface-2: var(--paper-r);
|
||||
--border: var(--line-light); --text: var(--text-light); --text-muted: var(--muted-light);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: var(--sans); font-size: 16px; line-height: 1.5;
|
||||
}
|
||||
.page { max-width: 40rem; margin: 0 auto; padding: var(--sp-6) var(--sp-4) var(--sp-6); }
|
||||
|
||||
.eyebrow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--text-muted); margin: 0 0 var(--sp-2);
|
||||
}
|
||||
.eyebrow::before {
|
||||
content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px; letter-spacing: -0.2px; text-wrap: balance;
|
||||
margin: 0 0 var(--sp-1); font-weight: 650;
|
||||
}
|
||||
.dek { color: var(--text-muted); margin: 0 0 var(--sp-6); max-width: 34rem; }
|
||||
|
||||
h2 {
|
||||
font-size: 20px; margin: 0 0 var(--sp-3); font-weight: 600;
|
||||
border-top: 1px solid var(--border); padding-top: var(--sp-4);
|
||||
}
|
||||
section + section { margin-top: var(--sp-5); }
|
||||
|
||||
/* ---- annotated diagram ---- */
|
||||
.diagram-wrap { overflow-x: auto; }
|
||||
.diagram {
|
||||
position: relative; min-width: 620px; height: 420px;
|
||||
margin: var(--sp-4) 0;
|
||||
}
|
||||
.diagram svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
.diagram svg path {
|
||||
fill: none; stroke: var(--arrow); stroke-width: 1.5;
|
||||
marker-end: url(#arrowhead);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||
background: var(--accent); color: var(--on-fill); border: none;
|
||||
border-radius: var(--radius-sm); padding: 12px 24px;
|
||||
font: inherit; font-size: 16px; cursor: default;
|
||||
}
|
||||
|
||||
.callout {
|
||||
position: absolute; width: 200px;
|
||||
font-size: 14px; color: var(--text-muted);
|
||||
}
|
||||
.callout b { color: var(--text); font-weight: 600; }
|
||||
.callout code {
|
||||
font-family: var(--mono); font-size: 12.5px; color: var(--accent);
|
||||
}
|
||||
.c-tl { left: 0; top: 0; }
|
||||
.c-tr { right: 0; top: 0; text-align: right; }
|
||||
.c-bl { left: 0; bottom: 0; }
|
||||
.c-br { right: 0; bottom: 0; text-align: right; }
|
||||
|
||||
/* ---- variants row ---- */
|
||||
.variants {
|
||||
display: flex; gap: var(--sp-4); align-items: flex-start; flex-wrap: wrap;
|
||||
margin: var(--sp-3) 0 var(--sp-2);
|
||||
}
|
||||
.variant { display: flex; flex-direction: column; gap: var(--sp-2); align-items: flex-start; }
|
||||
.variant .label { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
|
||||
.btn-ghost {
|
||||
background: var(--surface-1); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 12px 24px; font: inherit; font-size: 16px;
|
||||
}
|
||||
.btn-disabled {
|
||||
background: var(--accent); color: var(--on-fill); opacity: 0.5;
|
||||
border: none; border-radius: var(--radius-sm); padding: 12px 24px; font: inherit; font-size: 16px;
|
||||
}
|
||||
|
||||
.flag {
|
||||
display: flex; gap: var(--sp-2); align-items: flex-start;
|
||||
background: color-mix(in srgb, var(--success) 12%, var(--surface-1));
|
||||
border: 1px solid color-mix(in srgb, var(--success) 40%, var(--border));
|
||||
border-radius: var(--radius-sm); padding: var(--sp-3); font-size: 14px;
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
.flag b { color: var(--text); }
|
||||
|
||||
/* ---- spec table ---- */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { text-align: left; padding: var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border); }
|
||||
th { font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); font-weight: 600; }
|
||||
td code, td span.val { font-family: var(--mono); font-size: 13px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
footer {
|
||||
margin-top: var(--sp-6); padding-top: var(--sp-4); border-top: 1px solid var(--border);
|
||||
font-size: 14px; color: var(--text-muted);
|
||||
}
|
||||
footer strong { color: var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
<p class="eyebrow">unified-design-system · format check</p>
|
||||
<h1>Button</h1>
|
||||
<p class="dek">
|
||||
One component, every decision named, one sentence of why each. This is
|
||||
the format for the exhaustive design doc — confirm it before the
|
||||
remaining components (card, input, chip, dialog, switch, divider,
|
||||
checkbox, snack bar) get written the same way.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Annotated</h2>
|
||||
<div class="diagram-wrap">
|
||||
<div class="diagram" id="diagram">
|
||||
<svg viewBox="0 0 620 420" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="var(--arrow)" />
|
||||
</marker>
|
||||
</defs>
|
||||
<path d="M 150,70 C 220,110 260,150 292,182" />
|
||||
<path d="M 470,70 C 400,110 372,150 350,175" />
|
||||
<path d="M 150,350 C 220,310 260,270 292,240" />
|
||||
<path d="M 470,350 C 400,310 372,270 350,240" />
|
||||
</svg>
|
||||
|
||||
<p class="callout c-tl">
|
||||
<b>Radius: <code>radius-sm</code>, 8px.</b>
|
||||
Same token as inputs and chips — reads as one family of
|
||||
interactive elements, not a size picked for buttons alone.
|
||||
</p>
|
||||
<p class="callout c-tr">
|
||||
<b>Padding: 24px / 12px.</b>
|
||||
Horizontal is exactly 2× vertical (rule 22) — never tuned
|
||||
per component.
|
||||
</p>
|
||||
<button class="btn-primary" type="button">Log meal</button>
|
||||
<p class="callout c-bl">
|
||||
<b>Label: 16px, <code>body</code>.</b>
|
||||
A button is read, not decorative chrome, so it never drops
|
||||
below the 16px floor.
|
||||
</p>
|
||||
<p class="callout c-br">
|
||||
<b>Fill only, no shadow.</b>
|
||||
Dark surfaces never carry a shadow — weight comes from the
|
||||
accent fill itself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Variants</h2>
|
||||
<div class="variants">
|
||||
<div class="variant">
|
||||
<span class="label">Secondary</span>
|
||||
<button class="btn-ghost" type="button">Add item</button>
|
||||
</div>
|
||||
<div class="variant">
|
||||
<span class="label">Disabled</span>
|
||||
<button class="btn-disabled" type="button" disabled>Log meal</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:14px;color:var(--text-muted);margin-top:var(--sp-2)">
|
||||
Fill vs. border-only is the only weight distinction — never two
|
||||
filled buttons on one screen, so <code style="font-family:var(--mono)">Log meal</code>
|
||||
(primary action) and <code style="font-family:var(--mono)">Add item</code>
|
||||
(secondary) read as different weight at a glance.
|
||||
</p>
|
||||
<div class="flag">
|
||||
<span>✓</span>
|
||||
<span><b>Fixed:</b> label is now <code style="font-family:var(--mono)">on-fill</code>
|
||||
(<code style="font-family:var(--mono)">#211d1b</code>, dark), not
|
||||
near-white — near-white measured ≈3.2:1 on this fill, below the 4.5:1
|
||||
body-text target (true for every semantic fill, not just accent: all
|
||||
four cluster in the same mid-light band). Dark text passes 5.8–9.7:1
|
||||
on all four; it's a closed rule, not a per-color pick. <code style="font-family:var(--mono)">on-fill</code>
|
||||
is distinct from the near-white used on dark scrim overlays — see
|
||||
<code style="font-family:var(--mono)">tokens.md</code>.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Spec table</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Corner radius</td><td><span class="val">8px</span></td><td>shared with inputs/chips</td></tr>
|
||||
<tr><td>Padding</td><td><span class="val">12px 24px</span></td><td>rule 22 — 2× ratio</td></tr>
|
||||
<tr><td>Label size</td><td><span class="val">16px</span></td><td>rule 20 — body floor</td></tr>
|
||||
<tr><td>Depth</td><td><span class="val">fill / border only</span></td><td>shadow policy</td></tr>
|
||||
<tr><td>Primary fill</td><td><span class="val">accent #b8862e</span></td><td>tokens.md</td></tr>
|
||||
<tr><td>Primary label</td><td><span class="val">on-fill #211d1b</span></td><td>4.5:1+ on all 4 fills</td></tr>
|
||||
<tr><td>Secondary</td><td><span class="val">surface-1 + 1px border</span></td><td>tokens.md</td></tr>
|
||||
<tr><td>Disabled</td><td><span class="val">opacity 0.5</span></td><td>no separate muted token</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Content is real: <strong>Log meal</strong> / <strong>Add item</strong> are
|
||||
diet-guard's <code style="font-family:var(--mono)">meal_builder_screen.dart</code>
|
||||
buttons — the exact pair flagged earlier as indistinguishable in weight.
|
||||
If this format works, say go and the rest get written the same way.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
483
unified-design-system/components.html
Normal file
483
unified-design-system/components.html
Normal file
@ -0,0 +1,483 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Components — unified-design-system</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #211d1b; --ink-r1: #2b2624; --ink-r2: #38312e;
|
||||
--line-dark: #463e3a; --text-dark: #eceae9; --muted-dark: #aaa09a;
|
||||
|
||||
--paper: #f6f4f3; --paper-r: #fcfbfb;
|
||||
--line-light: #e0dad7; --text-light: #211d1b; --muted-light: #70625b;
|
||||
|
||||
--accent: #b8862e; --success: #8a9a3c; --warning: #e0a63c; --danger: #e2585f;
|
||||
--on-fill: #211d1b; --on-scrim: #eceae9;
|
||||
|
||||
--bg: var(--paper); --surface-1: var(--paper-r); --surface-2: var(--paper-r);
|
||||
--border: var(--line-light); --text: var(--text-light); --text-muted: var(--muted-light);
|
||||
|
||||
--radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px;
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 16px; --sp-4: 24px; --sp-5: 32px; --sp-6: 48px;
|
||||
|
||||
--sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Code", monospace;
|
||||
|
||||
--arrow: color-mix(in srgb, var(--accent) 55%, var(--text-muted));
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: var(--ink); --surface-1: var(--ink-r1); --surface-2: var(--ink-r2);
|
||||
--border: var(--line-dark); --text: var(--text-dark); --text-muted: var(--muted-dark);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: var(--ink); --surface-1: var(--ink-r1); --surface-2: var(--ink-r2);
|
||||
--border: var(--line-dark); --text: var(--text-dark); --text-muted: var(--muted-dark);
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: var(--paper); --surface-1: var(--paper-r); --surface-2: var(--paper-r);
|
||||
--border: var(--line-light); --text: var(--text-light); --text-muted: var(--muted-light);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: var(--sans); font-size: 16px; line-height: 1.5;
|
||||
}
|
||||
.page { max-width: 42rem; margin: 0 auto; padding: var(--sp-6) var(--sp-4) var(--sp-6); }
|
||||
|
||||
.eyebrow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--text-muted); margin: 0 0 var(--sp-2);
|
||||
}
|
||||
.eyebrow::before {
|
||||
content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px; letter-spacing: -0.2px; text-wrap: balance;
|
||||
margin: 0 0 var(--sp-1); font-weight: 650;
|
||||
}
|
||||
.dek { color: var(--text-muted); margin: 0 0 var(--sp-4); max-width: 36rem; }
|
||||
|
||||
nav.toc {
|
||||
display: flex; flex-wrap: wrap; gap: var(--sp-2);
|
||||
margin: 0 0 var(--sp-6); padding: var(--sp-3);
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
nav.toc a {
|
||||
font-size: 13px; color: var(--text-muted); text-decoration: none;
|
||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
nav.toc a:hover, nav.toc a:focus-visible { color: var(--text); background: var(--surface-2); outline: none; }
|
||||
nav.toc a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.component { scroll-margin-top: var(--sp-4); }
|
||||
.component + .component { margin-top: var(--sp-6); padding-top: var(--sp-5); border-top: 1px solid var(--border); }
|
||||
|
||||
h2 {
|
||||
display: flex; align-items: baseline; gap: var(--sp-2);
|
||||
font-size: 20px; margin: 0 0 var(--sp-1); font-weight: 600;
|
||||
}
|
||||
h2 .n { font-family: var(--mono); font-size: 13px; color: var(--text-muted); font-weight: 400; }
|
||||
.status {
|
||||
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
padding: 2px 7px; border-radius: 999px; font-weight: 600;
|
||||
}
|
||||
.status.live { color: var(--success); background: color-mix(in srgb, var(--success) 16%, transparent); }
|
||||
.status.spec { color: var(--warning); background: color-mix(in srgb, var(--warning) 16%, transparent); }
|
||||
.from { font-size: 13px; color: var(--text-muted); margin: 0 0 var(--sp-3); font-family: var(--mono); }
|
||||
|
||||
section.body { margin-top: var(--sp-3); }
|
||||
section.body + section.body { margin-top: var(--sp-4); }
|
||||
|
||||
/* ---- annotated diagram ---- */
|
||||
.diagram-wrap { overflow-x: auto; }
|
||||
.diagram { position: relative; min-width: 560px; margin: var(--sp-3) 0; }
|
||||
.diagram svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
.diagram svg path { fill: none; stroke: var(--arrow); stroke-width: 1.5; marker-end: url(#arrowhead); }
|
||||
.diagram svg path.danger-arrow { stroke: var(--danger); }
|
||||
|
||||
.callout { position: absolute; width: 190px; font-size: 13.5px; color: var(--text-muted); }
|
||||
.callout b { color: var(--text); font-weight: 600; }
|
||||
.callout code { font-family: var(--mono); font-size: 12px; color: var(--accent); }
|
||||
.callout.bad code { color: var(--danger); }
|
||||
.c-tl { left: 0; top: 0; } .c-tr { right: 0; top: 0; text-align: right; }
|
||||
.c-bl { left: 0; bottom: 0; } .c-br { right: 0; bottom: 0; text-align: right; }
|
||||
.c-ml { left: 0; top: 50%; transform: translateY(-50%); }
|
||||
.c-mr { right: 0; top: 50%; transform: translateY(-50%); text-align: right; }
|
||||
|
||||
.stage { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
|
||||
|
||||
/* ---- shared control styles ---- */
|
||||
button { font: inherit; }
|
||||
.btn-primary {
|
||||
background: var(--accent); color: var(--on-fill); border: none;
|
||||
border-radius: var(--radius-sm); padding: 12px 24px; font-size: 16px; cursor: default;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: var(--surface-1); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 12px 24px; font-size: 16px;
|
||||
}
|
||||
.btn-disabled { background: var(--accent); color: var(--on-fill); opacity: 0.5; border: none; border-radius: var(--radius-sm); padding: 12px 24px; font-size: 16px; }
|
||||
|
||||
.variants { display: flex; gap: var(--sp-4); align-items: flex-start; flex-wrap: wrap; margin: var(--sp-3) 0 var(--sp-2); }
|
||||
.variant { display: flex; flex-direction: column; gap: var(--sp-2); align-items: flex-start; }
|
||||
.variant .label { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
|
||||
|
||||
.flag {
|
||||
display: flex; gap: var(--sp-2); align-items: flex-start;
|
||||
border-radius: var(--radius-sm); padding: var(--sp-3); font-size: 14px; margin-top: var(--sp-3);
|
||||
}
|
||||
.flag.ok { background: color-mix(in srgb, var(--success) 12%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--success) 40%, var(--border)); }
|
||||
.flag.warn { background: color-mix(in srgb, var(--warning) 12%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--warning) 40%, var(--border)); }
|
||||
.flag.bad { background: color-mix(in srgb, var(--danger) 12%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
||||
.flag b { color: var(--text); }
|
||||
.flag code { font-family: var(--mono); font-size: 12.5px; }
|
||||
|
||||
p.note { font-size: 14px; color: var(--text-muted); margin: var(--sp-2) 0 0; }
|
||||
p.note code { font-family: var(--mono); font-size: 13px; color: var(--text); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
th, td { text-align: left; padding: 6px var(--sp-2); border-bottom: 1px solid var(--border); }
|
||||
th { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); font-weight: 600; }
|
||||
td .val { font-family: var(--mono); font-size: 12.5px; }
|
||||
.table-wrap { overflow-x: auto; margin-top: var(--sp-2); }
|
||||
|
||||
footer { margin-top: var(--sp-6); padding-top: var(--sp-4); border-top: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
|
||||
footer strong { color: var(--text); }
|
||||
footer code { font-family: var(--mono); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
<p class="eyebrow">unified-design-system · component spec</p>
|
||||
<h1>Components</h1>
|
||||
<p class="dek">
|
||||
One decision, one sentence of why, per component — grounded in real
|
||||
screens (diet-guard, dufs-cloud, screen-locker), not invented examples.
|
||||
Where a component isn't built yet, that's stated rather than faked.
|
||||
</p>
|
||||
|
||||
<nav class="toc" aria-label="Components">
|
||||
<a href="#button">button</a><a href="#card">card</a><a href="#input">input</a>
|
||||
<a href="#chip">chip</a><a href="#dialog">dialog</a><a href="#switch">switch</a>
|
||||
<a href="#divider">divider</a><a href="#checkbox">checkbox</a><a href="#snackbar">snackbar</a>
|
||||
</nav>
|
||||
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="var(--arrow)" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- ============ BUTTON ============ -->
|
||||
<article class="component" id="button">
|
||||
<h2>Button <span class="n">01</span> <span class="status live">live</span></h2>
|
||||
<p class="from">diet-guard/app/lib/screens/meal_builder_screen.dart</p>
|
||||
|
||||
<section class="body">
|
||||
<div class="diagram-wrap">
|
||||
<div class="diagram" style="height:380px">
|
||||
<svg viewBox="0 0 560 380" preserveAspectRatio="none">
|
||||
<path d="M 130,60 C 200,100 240,140 272,172" />
|
||||
<path d="M 430,60 C 360,100 332,140 310,165" />
|
||||
<path d="M 130,320 C 200,280 240,240 272,220" />
|
||||
<path d="M 430,320 C 360,280 332,240 310,220" />
|
||||
</svg>
|
||||
<p class="callout c-tl"><b>Radius: <code>radius-sm</code>, 8px.</b> Same token as inputs and chips — one family of interactive elements, not a size picked for buttons alone.</p>
|
||||
<p class="callout c-tr"><b>Padding: 24px / 12px.</b> Horizontal is exactly 2× vertical (rule 22) — never tuned per component.</p>
|
||||
<button class="btn-primary stage" type="button">Log meal</button>
|
||||
<p class="callout c-bl"><b>Label: 16px, <code>body</code>.</b> A button is read, not decorative chrome — never below the 16px floor.</p>
|
||||
<p class="callout c-br"><b>Fill only, no shadow.</b> Dark surfaces never carry a shadow — weight comes from the fill itself.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="variants">
|
||||
<div class="variant"><span class="label">Secondary</span><button class="btn-ghost" type="button">Add item</button></div>
|
||||
<div class="variant"><span class="label">Disabled</span><button class="btn-disabled" type="button" disabled>Log meal</button></div>
|
||||
</div>
|
||||
<p class="note">Fill vs. border-only is the only weight distinction — never two filled buttons on one screen, so <code>Log meal</code> (primary) and <code>Add item</code> (secondary) read as different weight at a glance.</p>
|
||||
|
||||
<div class="flag ok">
|
||||
<span>✓</span>
|
||||
<span><b>Fixed this session:</b> label is <code>on-fill</code> (<code>#211d1b</code>, dark), not near-white — near-white measured ≈3.2:1 on this fill, below the 4.5:1 floor. Dark text passes 5.8–9.7:1 on all four semantic fills; it's a closed rule, not a per-color pick.</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Radius</td><td><span class="val">8px</span></td><td>shared with inputs/chips</td></tr>
|
||||
<tr><td>Padding</td><td><span class="val">12px 24px</span></td><td>rule 22 — 2× ratio</td></tr>
|
||||
<tr><td>Label</td><td><span class="val">16px on-fill</span></td><td>rule 20 floor + on-fill</td></tr>
|
||||
<tr><td>Depth</td><td><span class="val">fill / border only</span></td><td>shadow policy</td></tr>
|
||||
<tr><td>Disabled</td><td><span class="val">opacity 0.5</span></td><td>no separate muted token</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<!-- ============ CARD ============ -->
|
||||
<article class="component" id="card">
|
||||
<h2>Card <span class="n">02</span> <span class="status live">live</span></h2>
|
||||
<p class="from">diet-guard/app/lib/widgets/day_status_calendar.dart</p>
|
||||
|
||||
<section class="body">
|
||||
<div class="diagram-wrap">
|
||||
<div class="diagram" style="height:300px">
|
||||
<svg viewBox="0 0 560 300" preserveAspectRatio="none">
|
||||
<path d="M 110,50 C 170,90 200,110 226,128" />
|
||||
<path d="M 450,50 C 390,90 360,110 334,128" />
|
||||
<path d="M 110,250 C 170,210 200,190 226,175" />
|
||||
<path d="M 450,250 C 390,210 360,190 334,175" />
|
||||
</svg>
|
||||
<div class="stage" style="width:280px;height:150px;background:var(--surface-1);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;gap:10px;padding:14px;">
|
||||
<div style="width:28px;height:28px;border-radius:6px;background:var(--surface-2);"></div>
|
||||
<div style="width:28px;height:28px;border-radius:6px;background:var(--success);color:var(--on-fill);display:grid;place-items:center;font-size:11px;font-family:var(--mono)">✓</div>
|
||||
<div style="width:28px;height:28px;border-radius:6px;background:var(--danger);color:var(--on-fill);display:grid;place-items:center;font-size:11px;font-family:var(--mono)">!</div>
|
||||
</div>
|
||||
<p class="callout c-tl"><b>Radius: <code>radius-md</code>, 12px.</b> One step up from a button's 8px — a card contains interactive elements, so it must read as a larger unit than they are.</p>
|
||||
<p class="callout c-tr"><b>Elevation: fill step, not shadow.</b> <code>surface-1</code> against the page background — dark surfaces never use a shadow for depth.</p>
|
||||
<p class="callout c-bl"><b>Nested radius: 6px.</b> Rule 24 — inner cell radius = outer (12px) − the gap to its edge (≈6px), not a reused constant.</p>
|
||||
<p class="callout c-br"><b>Status cells use <code>on-fill</code>.</b> The ✓/! cells sit directly on <code>success</code>/<code>danger</code> — this is the one place in the app that already got the on-fill rule right.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">The status colors sit at mid brightness, so <code>ink</code> gives strong on-palette contrast — this is the real code comment in <code>day_status_calendar.dart</code>, written before today's <code>on-fill</code> token existed to name the pattern.</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<!-- ============ INPUT ============ -->
|
||||
<article class="component" id="input">
|
||||
<h2>Input <span class="n">03</span> <span class="status live">live</span></h2>
|
||||
<p class="from">diet-guard/app/lib/widgets/macro_input_row.dart · screen-locker/_ui_widgets.py</p>
|
||||
|
||||
<section class="body">
|
||||
<div class="diagram-wrap">
|
||||
<div class="diagram" style="height:220px">
|
||||
<svg viewBox="0 0 560 220" preserveAspectRatio="none">
|
||||
<path class="danger-arrow" d="M 120,40 C 180,70 220,90 252,105" />
|
||||
<path class="danger-arrow" d="M 440,40 C 380,70 340,90 308,105" />
|
||||
</svg>
|
||||
<div class="stage" style="width:260px;">
|
||||
<label style="font-size:12px;color:var(--text-muted);display:block;margin-bottom:4px;">Protein (g)</label>
|
||||
<div style="height:44px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface-1);"></div>
|
||||
</div>
|
||||
<p class="callout c-tl bad"><b>Not yet themed.</b> No <code>InputDecorationTheme</code> is set in <code>theme.dart</code> — every <code>TextField</code> renders M3's default indigo focus color, off-palette.</p>
|
||||
<p class="callout c-tr bad"><b>Tkinter side too.</b> <code>gatelock</code>'s <code>tk.Entry</code> sets <code>bg</code>/<code>fg</code> but no <code>relief</code>/<code>bd</code> — Tk's default sunken bevel border, not the flat <code>line-dark</code> outline.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flag bad">
|
||||
<span>⚠</span>
|
||||
<span><b>Open finding, not fixed:</b> the input shown above is the <em>target</em> spec (radius-sm, flat 1px border, no bevel) — the actual rendered app still falls back to each toolkit's own default. Queued with the pilot-code palette migration, same class of bug as the Chip finding below.</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Radius</td><td><span class="val">8px</span></td><td>shared with buttons/chips</td></tr>
|
||||
<tr><td>Border</td><td><span class="val">1px line-dark</span></td><td>flat, no bevel</td></tr>
|
||||
<tr><td>Label</td><td><span class="val">12px caption</span></td><td>chrome, not read content</td></tr>
|
||||
<tr><td>Fill on focus</td><td><span class="val">surface-1 → surface-2</span></td><td>fill step, not a shadow/glow</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<!-- ============ CHIP ============ -->
|
||||
<article class="component" id="chip">
|
||||
<h2>Chip <span class="n">04</span> <span class="status live">live</span></h2>
|
||||
<p class="from">diet-guard/app/lib/widgets/slot_selector_row.dart</p>
|
||||
|
||||
<section class="body">
|
||||
<div class="diagram-wrap">
|
||||
<div class="diagram" style="height:260px">
|
||||
<svg viewBox="0 0 560 260" preserveAspectRatio="none">
|
||||
<path d="M 100,40 C 140,70 160,90 182,108" />
|
||||
<path class="danger-arrow" d="M 460,40 C 420,70 400,90 378,108" />
|
||||
</svg>
|
||||
<div class="stage" style="display:flex;gap:14px;align-items:center;">
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:999px;background:color-mix(in srgb, var(--success) 15%, transparent);border:1px solid color-mix(in srgb, var(--success) 40%, transparent);color:var(--success);font-size:14px;">
|
||||
<span style="opacity:.72">✓</span>9am
|
||||
</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:999px;background:transparent;border:1px solid #fff;color:var(--text);font-size:14px;">
|
||||
🍎 Snack
|
||||
</span>
|
||||
</div>
|
||||
<p class="callout c-tl"><b>Fill/border at 15%/40% alpha of the status color.</b> Not a fixed chip palette — <code>logged</code>/<code>due</code>/<code>upcoming</code> reuse <code>success</code>/<code>danger</code>/<code>muted</code>, so a chip's meaning matches the calendar's.</p>
|
||||
<p class="callout c-br bad"><b>Bug, still open:</b> the <code>Snack</code> chip sets no <code>backgroundColor</code>/<code>side</code> at all, so it falls through to Flutter's own default — a literal pure-white <code>#FFFFFF</code> border, the one banned value in the whole system.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">The hour chips (left) do this correctly: icon at 72% opacity of the status color (rule 28), selected border 2px vs. unselected 1px at 40% alpha. <code>Snack</code> just needs the same three properties applied — not a new pattern, a missed one.</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<!-- ============ DIALOG ============ -->
|
||||
<article class="component" id="dialog">
|
||||
<h2>Dialog <span class="n">05</span> <span class="status live">live</span></h2>
|
||||
<p class="from">dufs-cloud/web/src/index.css · screen-locker/_manual_workout_dialog.py</p>
|
||||
|
||||
<section class="body">
|
||||
<div class="variants">
|
||||
<div class="variant">
|
||||
<span class="label">Web (floating over content)</span>
|
||||
<div style="width:220px;padding:18px;border-radius:var(--radius-md);background:var(--surface-1);border:1px solid var(--border);box-shadow:0 4px 8px rgba(33,29,27,0.16);">
|
||||
<div style="font-size:14px;margin-bottom:10px;">Delete 3 files?</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<span style="padding:6px 12px;border-radius:6px;border:1px solid var(--border);font-size:13px;">Cancel</span>
|
||||
<span style="padding:6px 12px;border-radius:6px;background:var(--danger);color:var(--on-fill);font-size:13px;">Delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="variant">
|
||||
<span class="label">Tkinter (the whole screen)</span>
|
||||
<div style="width:220px;padding:18px;border-radius:var(--radius-md);background:var(--ink);border:1px solid var(--line-dark);">
|
||||
<div style="font-size:14px;margin-bottom:10px;color:var(--text-dark)">Log Manual Workout</div>
|
||||
<span style="padding:6px 12px;border-radius:6px;background:var(--accent);color:var(--on-fill);font-size:13px;">SUBMIT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">Same radius (<code>radius-md</code>), different depth technique, and that's <em>correct</em>, not inconsistent: the web dialog floats over other content (shadow allowed, per policy), the screen-locker dialog fills the entire lock screen — there's nothing behind it to cast a shadow onto, so it's fill/border only, same as every other dark surface.</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Radius</td><td><span class="val">12px</span></td><td>radius-md, one step above buttons</td></tr>
|
||||
<tr><td>Web shadow</td><td><span class="val">0 4px 8px rgba(ink,.16)</span></td><td>level 2 — bigger than a dropdown's level 1</td></tr>
|
||||
<tr><td>Actions</td><td><span class="val">right-aligned, 8px gap</span></td><td>space-2 on the spacing scale</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<!-- ============ SWITCH ============ -->
|
||||
<article class="component" id="switch">
|
||||
<h2>Switch <span class="n">06</span> <span class="status spec">spec only</span></h2>
|
||||
<p class="from">no live usage yet — locking the values in before the first one gets built</p>
|
||||
|
||||
<section class="body">
|
||||
<div class="diagram-wrap">
|
||||
<div class="diagram" style="height:180px">
|
||||
<svg viewBox="0 0 560 180" preserveAspectRatio="none">
|
||||
<path d="M 110,30 C 170,55 200,70 226,84" />
|
||||
<path d="M 450,30 C 390,55 360,70 334,84" />
|
||||
</svg>
|
||||
<div class="stage" style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="width:44px;height:26px;border-radius:999px;background:var(--accent);position:relative;">
|
||||
<div style="width:20px;height:20px;border-radius:50%;background:var(--on-fill);position:absolute;top:3px;right:3px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="callout c-tl"><b>Track = accent, thumb = on-fill.</b> Same fill/on-fill pairing as a button — a switch is a tiny filled control, not a new color relationship to invent.</p>
|
||||
<p class="callout c-tr"><b>Fully rounded track.</b> The one place in this system a pill shape is correct: an on/off track has no other elements to stay visually consistent with, unlike a button.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">Off state: track becomes <code>surface-2</code>, thumb becomes <code>muted</code> — a fill-step change, matching how every other "inactive" state in this system works (never a separate grey palette).</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<!-- ============ DIVIDER ============ -->
|
||||
<article class="component" id="divider">
|
||||
<h2>Divider <span class="n">07</span> <span class="status live">live</span></h2>
|
||||
<p class="from">diet-guard/app/lib/screens/settings_screen.dart · dufs-cloud/web/src/index.css</p>
|
||||
|
||||
<section class="body">
|
||||
<div class="variants">
|
||||
<div class="variant">
|
||||
<span class="label">dufs-cloud (correct)</span>
|
||||
<div style="width:220px;padding-bottom:10px;border-bottom:1px solid var(--border);font-size:13px;color:var(--text-muted)">.topbar</div>
|
||||
</div>
|
||||
<div class="variant">
|
||||
<span class="label">diet-guard (open finding)</span>
|
||||
<div style="width:220px;padding-bottom:10px;border-bottom:1px solid #fff;font-size:13px;color:var(--text-muted)"><code style="font-family:var(--mono)">const Divider()</code></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flag bad">
|
||||
<span>⚠</span>
|
||||
<span><b>Open finding, not fixed:</b> <code>settings_screen.dart</code> uses <code>const Divider()</code> with no <code>dividerTheme</code> set app-wide — same missing-theme pattern as Input above, this time landing on Material's default grey rather than <code>line-dark</code>.</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Weight</td><td><span class="val">1px</span></td><td>hairline — never a filled bar</td></tr>
|
||||
<tr><td>Color</td><td><span class="val">line-dark / line-light</span></td><td>same border token as cards/inputs</td></tr>
|
||||
<tr><td>Spacing</td><td><span class="val">space-3 (16px) each side</span></td><td>never touches adjacent text</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<!-- ============ CHECKBOX ============ -->
|
||||
<article class="component" id="checkbox">
|
||||
<h2>Checkbox <span class="n">08</span> <span class="status live">live</span></h2>
|
||||
<p class="from">dufs-cloud/web/src/index.css — <code>.tile-select</code></p>
|
||||
|
||||
<section class="body">
|
||||
<div class="diagram-wrap">
|
||||
<div class="diagram" style="height:180px">
|
||||
<svg viewBox="0 0 560 180" preserveAspectRatio="none">
|
||||
<path d="M 120,30 C 170,55 200,70 224,84" />
|
||||
<path class="danger-arrow" d="M 440,30 C 390,55 360,70 336,84" />
|
||||
</svg>
|
||||
<div class="stage" style="width:32px;height:32px;border-radius:var(--radius-sm);background:rgba(33,29,27,0.45);display:grid;place-items:center;">
|
||||
<input type="checkbox" checked style="margin:0;" />
|
||||
</div>
|
||||
<p class="callout c-tl"><b>Chip sits on a scrim, not a fill.</b> <code>rgba(ink,0.45)</code> over a photo thumbnail — this is <code>on-scrim</code> territory, not <code>on-fill</code>, since the thumbnail behind it varies.</p>
|
||||
<p class="callout c-tr bad"><b>Open finding:</b> the checkbox itself is an unstyled native <code><input type="checkbox"></code> — its checked-state color is the browser's default blue, off-palette. No custom checkbox styling exists yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">The 8px-radius scrim chip behind it is deliberately correct (matches <code>radius-sm</code>, dark translucent per the scrim rule) — only the checkbox glyph inside it is unstyled.</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<!-- ============ SNACKBAR ============ -->
|
||||
<article class="component" id="snackbar">
|
||||
<h2>Snack bar / toast <span class="n">09</span> <span class="status spec">spec only</span></h2>
|
||||
<p class="from">no live usage yet — no repo has a toast/snackbar today</p>
|
||||
|
||||
<section class="body">
|
||||
<div class="diagram-wrap">
|
||||
<div class="diagram" style="height:200px">
|
||||
<svg viewBox="0 0 560 200" preserveAspectRatio="none">
|
||||
<path d="M 100,30 C 160,55 190,75 220,95" />
|
||||
<path d="M 460,30 C 400,55 370,75 340,95" />
|
||||
</svg>
|
||||
<div class="stage" style="width:280px;padding:14px 16px;border-radius:var(--radius-md);background:var(--surface-2);border:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px;">
|
||||
<span style="font-size:14px;">Upload failed — connection lost</span>
|
||||
<span style="font-size:13px;color:var(--accent);white-space:nowrap;">Retry</span>
|
||||
</div>
|
||||
<p class="callout c-tl"><b>Never <code>danger</code>-filled.</b> A toast is transient chrome, not a status cell — <code>surface-2</code> + <code>danger</code>-colored icon/text reads as urgent without becoming a full alert box.</p>
|
||||
<p class="callout c-tr"><b>One action max.</b> <code>Retry</code> here, styled as text, not a button — a toast disappears on its own; a second filled button would compete with the page's real primary action.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">Copy above is realistic (a dufs-cloud upload failure), not a placeholder — written to spec the tone (states what broke, offers the one useful next step) alongside the visual values.</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<footer>
|
||||
9 of 9 components from the original punch list. <strong>Live</strong> means
|
||||
grounded in a real screen — several of those also carry an open finding
|
||||
(Input, Chip, Divider, Checkbox) that this doc surfaced but didn't
|
||||
silently fix. <strong>Spec only</strong> (Switch, Snack bar) means no
|
||||
usage exists yet; values are derived from the closed token rules, not
|
||||
guessed. Next: apply the frozen warm palette + <code>on-fill</code>/<code>on-scrim</code>
|
||||
split to the 3 pilots, then work the open findings above.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
123
unified-design-system/tokens.md
Normal file
123
unified-design-system/tokens.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Unified design tokens (frozen values)
|
||||
|
||||
Concrete instantiation of `safe-design-rules`' 28 rules. One identical
|
||||
palette across every repo/stack (Flutter, web, Python/Tkinter) — not just
|
||||
shared rules, the same hex values everywhere, including Flutter launcher-icon
|
||||
glyphs (`app-icon` skill). Anchored on the shared icon charcoal `#211D1B` so
|
||||
the UI palette and the icon family cohere instead of adding a second
|
||||
"near-black." Warm-tinted throughout (rule 10) — superseded from an earlier
|
||||
cool-tinted draft; every neutral is a byte-reversed (RRGGBB→BBGGRR) mirror of
|
||||
that draft's value, so relative lightness/contrast steps are unchanged, only
|
||||
the tint shifts from blue toward red/brown.
|
||||
|
||||
## Neutrals
|
||||
|
||||
| Token | Hex | Notes |
|
||||
| ------------------ | --------- | ------------------------------------------------- |
|
||||
| `ink` | `#211D1B` | Dark bg. Same value as the icon charcoal. |
|
||||
| `ink-raised-1` | `#2B2624` | Dark elevated surface, step 1. |
|
||||
| `ink-raised-2` | `#38312E` | Dark elevated surface, step 2 (≤12% luma vs bg). |
|
||||
| `line-dark` | `#463E3A` | Border on dark surfaces. |
|
||||
| `text-on-dark` | `#ECEAE9` | Primary text on dark. Near-white, not pure. |
|
||||
| `muted-on-dark` | `#AAA09A` | Secondary/caption text on dark. |
|
||||
| `paper` | `#F6F4F3` | Light bg. |
|
||||
| `paper-raised` | `#FCFBFB` | Light elevated surface — lighter than bg (rule 15). |
|
||||
| `line-light` | `#E0DAD7` | Border on light surfaces (≥3:1 vs bg and card). |
|
||||
| `text-on-light` | `#211D1B` | Reuses `ink` — deliberate symmetry. |
|
||||
| `muted-on-light` | `#70625B` | Secondary/caption text on light. |
|
||||
|
||||
## Accent + semantic roles
|
||||
|
||||
One set, used identically everywhere — UI, icons, everything. All four now
|
||||
cluster in the red-orange-yellow-olive wedge, distinguished mainly by
|
||||
lightness (danger 130 < accent 143 < success ~150 < warning 171 — a flagged,
|
||||
deliberate tradeoff, not an oversight): a colorblind/at-a-glance
|
||||
distinguishing risk the user accepted over keeping green/red as a semantic
|
||||
exception.
|
||||
|
||||
| Token | Hex |
|
||||
| --------- | --------- |
|
||||
| `accent` | `#B8862E` |
|
||||
| `info` | `#B8862E` (= accent) |
|
||||
| `success` | `#8A9A3C` |
|
||||
| `warning` | `#E0A63C` |
|
||||
| `danger` | `#E2585F` |
|
||||
| `on-fill` | `#211D1B` (= `ink`) |
|
||||
|
||||
`on-fill`: text/icon color for anything drawn **on top of** a filled
|
||||
accent/success/warning/danger surface (a primary button, a filled badge) —
|
||||
never `text-on-dark`. All four fill colors sit in the same mid-light
|
||||
lightness band (that's the accent/success/warning/danger clustering noted
|
||||
above), so near-white text under-contrasts on every one of them (measured
|
||||
2.2:1–3.6:1 against WCAG's 4.5:1 body-text floor); `ink` text passes on all
|
||||
four (5.8:1–9.7:1). This is a closed rule, not a per-color judgment call —
|
||||
never pick text color per fill, always `on-fill`.
|
||||
|
||||
Do not confuse `on-fill` with text on a **scrim/overlay** (a dark
|
||||
semi-transparent backdrop behind a modal or a translucent control chip over
|
||||
an image) — those stay `text-on-dark` (near-white), fixed regardless of
|
||||
page theme, since the scrim itself is always dark. `on-fill` only applies to
|
||||
the four *opaque* semantic fills above.
|
||||
|
||||
## Spacing scale (4px base)
|
||||
|
||||
`xs 4 · sm 8 · md 16 · lg 24 · xl 32 · xxl 48`
|
||||
|
||||
Every spacing/sizing value in a file should land on this scale. Round
|
||||
outliers to the nearest step rather than introducing a new one.
|
||||
|
||||
## Corner radius scale
|
||||
|
||||
`sm 8` (buttons, inputs, chips) · `md 12` (cards) · `lg 16` (sheets, dialogs).
|
||||
|
||||
Nesting (rule 24): an inner element's radius = outer radius − the gap between
|
||||
inner and outer edges, computed per instance — not a fixed constant. E.g. an
|
||||
`lg` (16px) container with 4px of padding before its child starts should give
|
||||
that child a 12px radius, not reuse 16 or invent an unrelated value.
|
||||
|
||||
## Typography scale
|
||||
|
||||
| Role | Size (px) | Tracking |
|
||||
| ---------- | --------- | ---------------------------------- |
|
||||
| `display` | 32 | tight (e.g. −0.2px) |
|
||||
| `title` | 24 | tight (e.g. −0.1px) |
|
||||
| `subtitle` | 20 | default |
|
||||
| `body` | 16 | default — the floor (rule 20) |
|
||||
| `label` | 14 | loose (+0.05–0.1em), chrome only |
|
||||
| `caption` | 12 | loose (+0.1–0.2em), chrome only |
|
||||
|
||||
`body` (16px) is the minimum for anything a user actually reads.
|
||||
`label`/`caption` are for UI chrome (timestamps, badges, tags) — never for
|
||||
primary reading content.
|
||||
|
||||
Two typefaces max (rule 23): one system sans stack for all UI text
|
||||
(`-apple-system, "Segoe UI", Roboto, system-ui, sans-serif` on web; platform
|
||||
default on Flutter/Tk), one monospace stack reserved for code/data-dense
|
||||
contexts only.
|
||||
|
||||
Line length (rule 21): prose/paragraph blocks capped at `max-width: 40rem`
|
||||
(~640px, ~65–70 characters).
|
||||
|
||||
## Buttons
|
||||
|
||||
Horizontal padding = 2× vertical (rule 22). Canonical: vertical `12px`,
|
||||
horizontal `24px` (both on the spacing scale: 12 = 3×4, 24 = 6×4).
|
||||
|
||||
## Shadow policy (rules 16, 26, 27)
|
||||
|
||||
Pick exactly one depth technique per surface — never a shadow and a lighter
|
||||
fill on the same element, never a border-only style next to a shadowed one
|
||||
at the same z-level.
|
||||
|
||||
- **Dark surfaces: no shadows, ever.** Elevation is `ink-raised-1`/`-2` fill
|
||||
steps only.
|
||||
- **Light surfaces**: shadows allowed *only* on floating/overlay elements
|
||||
(dropdown menus, modal dialogs) — never on inline cards or list rows.
|
||||
Two tiers, blur = 2× offset, tinted from `ink` (never pure black):
|
||||
- Level 1 (e.g. a dropdown): `0 2px 4px rgba(33, 29, 27, 0.12)`
|
||||
- Level 2 (e.g. a modal): `0 4px 8px rgba(33, 29, 27, 0.16)`
|
||||
|
||||
## Icon-next-to-text contrast (rule 28)
|
||||
|
||||
Icons adjacent to text render at ~72% opacity of the text's color, or use the
|
||||
`muted` role instead of the full-strength text role — never equal contrast.
|
||||
Loading…
Reference in New Issue
Block a user