From a50d27688f0108161108ec5cd26c1647cc645060 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 22 Jul 2026 21:23:47 +0200 Subject: [PATCH] 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 --- README.md | 11 + unified-design-system/README.md | 158 +++++++++ unified-design-system/button.html | 255 ++++++++++++++ unified-design-system/components.html | 483 ++++++++++++++++++++++++++ unified-design-system/tokens.md | 123 +++++++ 5 files changed, 1030 insertions(+) create mode 100644 unified-design-system/README.md create mode 100644 unified-design-system/button.html create mode 100644 unified-design-system/components.html create mode 100644 unified-design-system/tokens.md diff --git a/README.md b/README.md index a556191..218b448 100644 --- a/README.md +++ b/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. diff --git a/unified-design-system/README.md b/unified-design-system/README.md new file mode 100644 index 0000000..53a6667 --- /dev/null +++ b/unified-design-system/README.md @@ -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. diff --git a/unified-design-system/button.html b/unified-design-system/button.html new file mode 100644 index 0000000..f2fc5a7 --- /dev/null +++ b/unified-design-system/button.html @@ -0,0 +1,255 @@ + + + + + +Button — unified-design-system + + + + +
+

unified-design-system · format check

+

Button

+

+ 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. +

+ +
+

Annotated

+
+
+ + + + + + + + + + + + +

+ Radius: radius-sm, 8px. + Same token as inputs and chips — reads as one family of + interactive elements, not a size picked for buttons alone. +

+

+ Padding: 24px / 12px. + Horizontal is exactly 2× vertical (rule 22) — never tuned + per component. +

+ +

+ Label: 16px, body. + A button is read, not decorative chrome, so it never drops + below the 16px floor. +

+

+ Fill only, no shadow. + Dark surfaces never carry a shadow — weight comes from the + accent fill itself. +

+
+
+
+ +
+

Variants

+
+
+ Secondary + +
+
+ Disabled + +
+
+

+ Fill vs. border-only is the only weight distinction — never two + filled buttons on one screen, so Log meal + (primary action) and Add item + (secondary) read as different weight at a glance. +

+
+ ✓ + Fixed: label is now on-fill + (#211d1b, 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. on-fill + is distinct from the near-white used on dark scrim overlays — see + tokens.md. +
+
+ +
+

Spec table

+
+ + + + + + + + + + + + +
PropertyValueRule
Corner radius8pxshared with inputs/chips
Padding12px 24pxrule 22 — 2× ratio
Label size16pxrule 20 — body floor
Depthfill / border onlyshadow policy
Primary fillaccent #b8862etokens.md
Primary labelon-fill #211d1b4.5:1+ on all 4 fills
Secondarysurface-1 + 1px bordertokens.md
Disabledopacity 0.5no separate muted token
+
+
+ +
+ Content is real: Log meal / Add item are + diet-guard's meal_builder_screen.dart + buttons — the exact pair flagged earlier as indistinguishable in weight. + If this format works, say go and the rest get written the same way. +
+
+ + + diff --git a/unified-design-system/components.html b/unified-design-system/components.html new file mode 100644 index 0000000..341155e --- /dev/null +++ b/unified-design-system/components.html @@ -0,0 +1,483 @@ + + + + + +Components — unified-design-system + + + + +
+

unified-design-system · component spec

+

Components

+

+ 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. +

+ + + + + + + + + + + + +
+

Button 01 live

+

diet-guard/app/lib/screens/meal_builder_screen.dart

+ +
+
+
+ + + + + + +

Radius: radius-sm, 8px. Same token as inputs and chips — one family of interactive elements, not a size picked for buttons alone.

+

Padding: 24px / 12px. Horizontal is exactly 2× vertical (rule 22) — never tuned per component.

+ +

Label: 16px, body. A button is read, not decorative chrome — never below the 16px floor.

+

Fill only, no shadow. Dark surfaces never carry a shadow — weight comes from the fill itself.

+
+
+ +
+
Secondary
+
Disabled
+
+

Fill vs. border-only is the only weight distinction — never two filled buttons on one screen, so Log meal (primary) and Add item (secondary) read as different weight at a glance.

+ +
+ ✓ + Fixed this session: label is on-fill (#211d1b, 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. +
+ +
+ + + + + + + + + +
PropertyValueRule
Radius8pxshared with inputs/chips
Padding12px 24pxrule 22 — 2× ratio
Label16px on-fillrule 20 floor + on-fill
Depthfill / border onlyshadow policy
Disabledopacity 0.5no separate muted token
+
+
+
+ + +
+

Card 02 live

+

diet-guard/app/lib/widgets/day_status_calendar.dart

+ +
+
+
+ + + + + + +
+
+
✓
+
!
+
+

Radius: radius-md, 12px. One step up from a button's 8px — a card contains interactive elements, so it must read as a larger unit than they are.

+

Elevation: fill step, not shadow. surface-1 against the page background — dark surfaces never use a shadow for depth.

+

Nested radius: 6px. Rule 24 — inner cell radius = outer (12px) − the gap to its edge (≈6px), not a reused constant.

+

Status cells use on-fill. The ✓/! cells sit directly on success/danger — this is the one place in the app that already got the on-fill rule right.

+
+
+

The status colors sit at mid brightness, so ink gives strong on-palette contrast — this is the real code comment in day_status_calendar.dart, written before today's on-fill token existed to name the pattern.

+
+
+ + +
+

Input 03 live

+

diet-guard/app/lib/widgets/macro_input_row.dart · screen-locker/_ui_widgets.py

+ +
+
+
+ + + + +
+ +
+
+

Not yet themed. No InputDecorationTheme is set in theme.dart — every TextField renders M3's default indigo focus color, off-palette.

+

Tkinter side too. gatelock's tk.Entry sets bg/fg but no relief/bd — Tk's default sunken bevel border, not the flat line-dark outline.

+
+
+
+ ⚠ + Open finding, not fixed: the input shown above is the target 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. +
+
+ + + + + + + + +
PropertyValueRule
Radius8pxshared with buttons/chips
Border1px line-darkflat, no bevel
Label12px captionchrome, not read content
Fill on focussurface-1 → surface-2fill step, not a shadow/glow
+
+
+
+ + +
+

Chip 04 live

+

diet-guard/app/lib/widgets/slot_selector_row.dart

+ +
+
+
+ + + + +
+ + ✓9am + + + 🍎 Snack + +
+

Fill/border at 15%/40% alpha of the status color. Not a fixed chip palette — logged/due/upcoming reuse success/danger/muted, so a chip's meaning matches the calendar's.

+

Bug, still open: the Snack chip sets no backgroundColor/side at all, so it falls through to Flutter's own default — a literal pure-white #FFFFFF border, the one banned value in the whole system.

+
+
+

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. Snack just needs the same three properties applied — not a new pattern, a missed one.

+
+
+ + +
+

Dialog 05 live

+

dufs-cloud/web/src/index.css · screen-locker/_manual_workout_dialog.py

+ +
+
+
+ Web (floating over content) +
+
Delete 3 files?
+
+ Cancel + Delete +
+
+
+
+ Tkinter (the whole screen) +
+
Log Manual Workout
+ SUBMIT +
+
+
+

Same radius (radius-md), different depth technique, and that's correct, 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.

+
+ + + + + + + +
PropertyValueRule
Radius12pxradius-md, one step above buttons
Web shadow0 4px 8px rgba(ink,.16)level 2 — bigger than a dropdown's level 1
Actionsright-aligned, 8px gapspace-2 on the spacing scale
+
+
+
+ + +
+

Switch 06 spec only

+

no live usage yet — locking the values in before the first one gets built

+ +
+
+
+ + + + +
+
+
+
+
+

Track = accent, thumb = on-fill. Same fill/on-fill pairing as a button — a switch is a tiny filled control, not a new color relationship to invent.

+

Fully rounded track. 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.

+
+
+

Off state: track becomes surface-2, thumb becomes muted — a fill-step change, matching how every other "inactive" state in this system works (never a separate grey palette).

+
+
+ + +
+

Divider 07 live

+

diet-guard/app/lib/screens/settings_screen.dart · dufs-cloud/web/src/index.css

+ +
+
+
+ dufs-cloud (correct) +
.topbar
+
+
+ diet-guard (open finding) +
const Divider()
+
+
+
+ ⚠ + Open finding, not fixed: settings_screen.dart uses const Divider() with no dividerTheme set app-wide — same missing-theme pattern as Input above, this time landing on Material's default grey rather than line-dark. +
+
+ + + + + + + +
PropertyValueRule
Weight1pxhairline — never a filled bar
Colorline-dark / line-lightsame border token as cards/inputs
Spacingspace-3 (16px) each sidenever touches adjacent text
+
+
+
+ + +
+

Checkbox 08 live

+

dufs-cloud/web/src/index.css — .tile-select

+ +
+
+
+ + + + +
+ +
+

Chip sits on a scrim, not a fill. rgba(ink,0.45) over a photo thumbnail — this is on-scrim territory, not on-fill, since the thumbnail behind it varies.

+

Open finding: the checkbox itself is an unstyled native <input type="checkbox"> — its checked-state color is the browser's default blue, off-palette. No custom checkbox styling exists yet.

+
+
+

The 8px-radius scrim chip behind it is deliberately correct (matches radius-sm, dark translucent per the scrim rule) — only the checkbox glyph inside it is unstyled.

+
+
+ + +
+

Snack bar / toast 09 spec only

+

no live usage yet — no repo has a toast/snackbar today

+ +
+
+
+ + + + +
+ Upload failed — connection lost + Retry +
+

Never danger-filled. A toast is transient chrome, not a status cell — surface-2 + danger-colored icon/text reads as urgent without becoming a full alert box.

+

One action max. Retry 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.

+
+
+

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.

+
+
+ + +
+ + + diff --git a/unified-design-system/tokens.md b/unified-design-system/tokens.md new file mode 100644 index 0000000..b4037f8 --- /dev/null +++ b/unified-design-system/tokens.md @@ -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.