Tokens (light + dark, neumorphism, 54/54 WCAG AA contrast pairs), the self-hosted JetBrains Mono fonts, and interactive mockups of every screen across three iterations. Iteration 3 is final: phone, tablet and desktop tiers, device preview, and "empty spool weight" terminology. The build scripts regenerate the screens byte-identically from source. Implementation is tracked in Maestro feature UI Refresh - V3.0 (224b72a1-8d84-4970-97ea-5dafa4d8ea3b). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
17 KiB
Markdown
264 lines
17 KiB
Markdown
# CubeCraftLabs Design System v3 (proposal): Soft UI
|
||
|
||
**Status:** proposal, not adopted. Nothing in the Extrudex repository has changed.
|
||
**Base:** Design System v2.0 as served at `http://pages.ccl/design-v2.0/`, verified
|
||
on 2026-09-18 to match `web/src/theme.css` on `dev` (`321ce0d`) token for token.
|
||
**Tokens:** [`design-system-v3.css`](design-system-v3.css) · **Fonts:** [`fonts/`](fonts/)
|
||
(JetBrains Mono 400/500/700/800 woff2 from `@fontsource/jetbrains-mono` 5.3.0,
|
||
the same package the app imports; OFL license included).
|
||
|
||
---
|
||
|
||
## 1. Principles
|
||
|
||
1. **The v2.0 palette stays.** Charcoal, violet accent, green and red are
|
||
unchanged. Every v2.0 token name still exists and still resolves. New colors
|
||
are tints of v2.0 hues, except for two new status hues (warning, info) that
|
||
v2.0 lacked.
|
||
2. **Shadow is decoration, never structure.** Every button, input, select,
|
||
toggle and checkbox has a border at 3:1 or better against its surface
|
||
(WCAG 1.4.11). Turn the shadows off and every control is still findable.
|
||
3. **No state relies on shadow direction alone.** See the state matrix in §4.
|
||
4. **Offline-identical.** One self-hosted font. No CDN, no remote asset, no
|
||
`@import`. The mockups embed the font as base64.
|
||
5. **Numbers stay square.** Tables, code and numeric columns keep
|
||
`--radius-none`. Soft corners belong to containers and controls, not data.
|
||
|
||
## 2. Two v2.0 rules this proposal overturns (deliberately)
|
||
|
||
| v2.0 rule | v3 | Why |
|
||
|---|---|---|
|
||
| "Radius: the brand is square. **0 is the rule.**" | A 0–26 px radius scale | Neumorphism reads as extruded material. A soft raised shape on a square corner looks like a rendering error, not a style. `--radius-none` stays for tables, code and grid lines. |
|
||
| "Shadows: **flat brand.** Only a faint lift where truly needed." | A four-level raised/inset shadow system | Soft UI *is* the shadow system. The accessibility cost of that is paid by §1.2 and §4. |
|
||
|
||
DECISIONS.MD says "do not edit a token to suit a component". v3 doesn't edit
|
||
v2.0 tokens: it adds new ones and remaps the alias layer. The two changes above
|
||
are brand-level decisions for you to accept or reject, and they're listed again
|
||
in SUMMARY.md.
|
||
|
||
---
|
||
|
||
## 3. Every token that changed or was added compared to v2.0
|
||
|
||
**Unchanged** (kept verbatim): the whole `--ccl-*` base scale and its aliases,
|
||
`--font-*`, `--fw-*`, `--fs-*`, `--lh-*`, `--tracking-*`, `--space-*`,
|
||
`--ease-out`, `--dur-fast`, `--dur-base`, `--scrollbar-width`, `--radius-none`,
|
||
`--shadow-none`, `--border-width`.
|
||
|
||
### 3a. Base scale: added
|
||
|
||
| Token | Value | Reason |
|
||
|---|---|---|
|
||
| `--ccl-gold-light` | `#a99af0` | v2.0's accent `#8f7ae0` is **3.99:1** as text on charcoal (fails 4.5:1), and v2.0's own ink-on-accent button text is **4.32:1** (fails). A lighter step of the same hue passes both: 5.67:1 as text and 6.14:1 under ink. |
|
||
| `--ccl-violet-deep` | `#5b43b8` | The accent for the light theme. `#8f7ae0` on a light surface is about 2.6:1. |
|
||
| `--ccl-charcoal-inset` | `#232327` | Neumorphic wells (inputs, tracks) need a step between page `#2c2c30` and code `#1b1b1e`. Code black inside a well looked like a hole. |
|
||
| `--ccl-amber` | `#e8c170` | **v2.0 has no warning color.** "Running low", "tare estimated" and "duration is an upper bound" all need a caution that isn't an error. |
|
||
| `--ccl-blue` | `#8fc3f0` | An informational/provenance hue for "sliced", "recalled" and "on feature branch" labels, so provenance doesn't borrow success or error meanings. |
|
||
| `--ccl-mist` / `-2` / `-3` | `#e4e2ea` / `#f1f0f5` / `#d9d6e1` | The light theme's page, high and inset surfaces. They carry charcoal's slight violet cast, inverted, so both themes are one family. Soft UI can't use pure white: the highlight half of the shadow needs a lighter color than the surface. |
|
||
|
||
### 3b. Semantic tokens: changed
|
||
|
||
| Token | v2.0 | v3 dark | v3 light | Reason |
|
||
|---|---|---|---|---|
|
||
| `--surface-raised` | `#36363a` | `#2c2c30` (= page) | `#e4e2ea` | In soft UI a raised surface is the *same* material as the page. The lift comes from the shadow pair. A lighter fill plus a shadow looks like a card lying on top. |
|
||
| `--shadow-raised` | `0 1px 0 0 #3d3d44` | `var(--nm-raised-2)` | same | The v2.0 name now points to the level-2 soft shadow, so existing rules pick it up. |
|
||
| `--accent-fill` (new) | (none) | `#a99af0` | `#5b43b8` | Primary button fill. The text on it passes AA, which v2.0's did not (4.32:1). |
|
||
| `--text-on-accent` | `#2a2431` | `#2a2431` | `#ffffff` | Unchanged in dark. White in light, because the light accent fill is dark. |
|
||
| `--focus-ring` | `#8f7ae0` | `#a99af0` | `#5b43b8` | v2.0's ring was 3.45:1 on a raised surface. v3's is at least 5.6:1 everywhere. |
|
||
| `--radius-sm` / `--radius-md` | 0 | 8 px / 12 px | same | See §2. |
|
||
| `--border-hairline` (light) | (none) | `#3d3d44` | `#cfccd8` | The light theme needs its own value. Hairlines are decorative and not relied on. |
|
||
| `--scrollbar-thumb` | `#4e4e57` | `var(--border-strong)` | `#b9b5c6` | Now follows the theme. |
|
||
|
||
### 3c. Semantic tokens: added
|
||
|
||
| Token | Dark | Light | Reason |
|
||
|---|---|---|---|
|
||
| `--surface-high` | `#36363a` | `#f1f0f5` | Dialogs and menus. Reuses v2.0's charcoal-2 in dark. |
|
||
| `--surface-inset` | `#232327` | `#d9d6e1` | Inputs, wells and progress tracks. |
|
||
| `--text-accent` | `#a99af0` | `#5b43b8` | Links and accent text that pass 4.5:1. |
|
||
| `--accent-fill-press` | `#9785e6` | `#4d37a3` | The pressed primary button is a different *fill*, not just an inset shadow. |
|
||
| `--accent-wash` | 10% of `#a99af0` | 10% of `#5b43b8` | The pressed/selected background for secondary controls. |
|
||
| `--status-warning` / `--status-info` | amber / blue | `#7c5100` / `#1d5e94` | Explained in §3a. |
|
||
| `--status-success` / `--status-error` (light) | (v2.0 hues) | `#2c6619` / `#a8202f` | The v2.0 hues are too light for a light surface. |
|
||
| `--wash-success/error/warning/info` | 10% alpha | 10% alpha | Chip backgrounds. 16% dropped chip text below 4.5:1, so it's 10%. |
|
||
| `--border-control` | `#85858f` | `#736e86` | **The 1.4.11 token.** v2.0's strongest border `#4e4e57` is 1.69:1 on charcoal, so it can't identify a control. v3's is 3.29–4.30:1 on every surface. |
|
||
| `--border-control-width` / `--border-error-width` | 1 px / 2 px | same | An error changes the border's *width* as well as its color. |
|
||
| `--focus-width` / `--focus-offset` | 2 px / 3 px | same | An offset outline, visually separate from the shadow. |
|
||
| `--dur-press` | 60 ms | same | The press responds faster than hover, so the push feels physical. |
|
||
|
||
### 3d. Neumorphism tokens: added
|
||
|
||
| Token | Value | Purpose |
|
||
|---|---|---|
|
||
| `--radius-xs` … `--radius-pill` | 4 / 8 / 12 / 18 / 26 / 999 px | Chips / inputs / buttons / cards / dialogs / tracks and badges |
|
||
| `--nm-d-1…4`, `--nm-b-1…4` | 2/5, 4/10, 8/18, 14/30 px | Shadow distance and blur per **intensity level** |
|
||
| `--nm-shadow-dark` | dark `rgba(0,0,0,.62)` · light `rgba(88,80,120,.30)` | Bottom-right half of the raised pair |
|
||
| `--nm-shadow-light` | dark `rgba(255,255,255,.055)` · light `rgba(255,255,255,.95)` | Top-left half of the raised pair |
|
||
| `--nm-inset-dark` / `--nm-inset-light` | dark `.55` / `.045` · light `.28` / `.9` | Inset (pressed/well) pair |
|
||
| `--nm-rim` | dark `inset 0 1px 0 rgba(255,255,255,.06)` | Top-edge rim light (the dark-mode fix, §5) |
|
||
| `--nm-raised-gradient` | dark `#313136 → #29292d` · light `#eceaf1 → #dcd9e3` | A 145° surface gradient that sells the convex shape |
|
||
| `--nm-raised-1…4` | composed | Level 1 chips · 2 buttons/rows · 3 cards · 4 dialogs |
|
||
| `--nm-inset-1…2` | composed | Level 1 inputs/pressed buttons · 2 wells/panels |
|
||
| `--nm-flat` | `none` | Disabled controls. They lose their shadow entirely. |
|
||
|
||
---
|
||
|
||
## 4. State matrix
|
||
|
||
What changes in each state. The "besides shadow" column is what makes each
|
||
state identifiable without the shadow.
|
||
|
||
| State | Shadow | Besides shadow (the part that must be visible) |
|
||
|---|---|---|
|
||
| **Rest** | raised-2 | 1 px solid `--border-control` (3:1+) |
|
||
| **Hover** | raised-2 | Border becomes `--text-accent` |
|
||
| **Pressed / selected** | inset-1 | Background `--accent-wash`, accent border, **✓ glyph** on toggles; primary uses the `--accent-fill-press` fill |
|
||
| **Focused** | unchanged | **2 px solid outline, 3 px offset**, `--focus-ring`. Combines with any other state. |
|
||
| **Disabled** | none | **Dashed** border, transparent fill, secondary text, `not-allowed` cursor |
|
||
| **Error** | inset-1 (inputs) | **2 px** border in `--status-error`, **⚠ icon**, and a message under the field |
|
||
|
||
Because each state uses a different channel (outline, fill and glyph, dash,
|
||
width and icon), any two states stay distinguishable in grayscale.
|
||
|
||
---
|
||
|
||
## 5. Dark-mode neumorphism: the problem and the fix
|
||
|
||
**The problem.** Soft UI draws the same surface with a lighter shadow top-left
|
||
and a darker one bottom-right. On `#2c2c30`, the conventional recipe (darker and
|
||
lighter charcoal, for example `#1b1b1e` and `#3d3d44`) gives about 1.3:1 each
|
||
way. The shape vanishes, or turns into a muddy halo.
|
||
|
||
**The fix, as four rules in the tokens:**
|
||
|
||
1. **The dark half is true black at high alpha** (`rgba(0,0,0,.62)`), not a
|
||
darker gray. Alpha black darkens whatever it lands on, so it stays visible
|
||
even beside the v2.0 raised gray.
|
||
2. **The light half is white at about 5% alpha.** At that level it reads as a
|
||
sheen. Any brighter and dark soft UI starts to glow.
|
||
3. **A 1 px rim light** (`inset 0 1px 0 rgba(255,255,255,.06)`) on every raised
|
||
surface. The rim is sharp where the shadow is soft, so it keeps the top edge
|
||
visible even when the drop shadow merges into the page.
|
||
4. **The page stays at v2.0's `#2c2c30`, not near-black.** Soft UI needs room
|
||
*below* the surface for the dark half, and `#1b1b1e` would leave none. That
|
||
is also why wells use the new in-between `#232327`.
|
||
|
||
The fifth rule is the accessibility floor, and it's why none of this is
|
||
fragile: **every control has a 3:1 border anyway**. If a monitor crushes the
|
||
blacks, or someone forces high contrast, the UI loses depth but keeps its
|
||
structure.
|
||
|
||
---
|
||
|
||
## 6. Alternative palettes (not the main proposal)
|
||
|
||
Both would reuse every structural and neumorphism token. Only the base scale
|
||
would change.
|
||
|
||
| | A: Warm graphite | B: Slate and teal |
|
||
|---|---|---|
|
||
| Page (dark / light) | `#2e2b29` / `#e9e5e0` | `#27303a` / `#e1e7ec` |
|
||
| Accent (dark text / light text) | `#e0a36b` / `#8a4b12` | `#5fd0c0` / `#0f6b61` |
|
||
| Mood | Workshop, wood and brass. It suits a print farm but moves away from the CCL violet. | Cooler and more technical. It reads as "instrument panel" and gives the swatches more room. |
|
||
| Risk | Amber becomes the accent, so warning needs a new hue | Teal is close to success green, so success would need re-tuning |
|
||
|
||
The main proposal keeps v2.0 violet. Both alternatives change the brand color.
|
||
|
||
---
|
||
|
||
## 7. Contrast checks
|
||
|
||
The script used the WCAG 2.1 relative luminance formula. The v2.0 rows show
|
||
what v3 fixes.
|
||
|
||
### 7a. v2.0 as it stands today (dark only)
|
||
|
||
| Pair | Ratio | Needs | Result |
|
||
|---|---|---|---|
|
||
| Text `#f4f4f6` on page `#2c2c30` | 12.66:1 | 4.5 | Pass |
|
||
| Muted `#b2b2ba` on page | 6.60:1 | 4.5 | Pass |
|
||
| **Ink `#2a2431` on accent `#8f7ae0` (primary button)** | **4.32:1** | 4.5 | **FAIL** |
|
||
| **Accent `#8f7ae0` as text on page** | **3.99:1** | 4.5 | **FAIL** |
|
||
| **Strongest border `#4e4e57` vs page (control edge)** | **1.69:1** | 3 | **FAIL** |
|
||
| Focus ring `#8f7ae0` on raised `#36363a` | 3.45:1 | 3 | Pass (narrowly) |
|
||
|
||
### 7b. v3, both themes
|
||
|
||
All 54 checks pass.
|
||
|
||
| Theme | Pair | Foreground | Background | Ratio | Needs | Result |
|
||
|---|---|---|---|---|---|---|
|
||
| Dark | Body text | `#f4f4f6` | `#2c2c30` | 12.66:1 | 4.5:1 | Pass |
|
||
| Dark | Body text on inset (inputs) | `#f4f4f6` | `#232327` | 14.25:1 | 4.5:1 | Pass |
|
||
| Dark | Body text on high surface (dialog) | `#f4f4f6` | `#36363a` | 10.95:1 | 4.5:1 | Pass |
|
||
| Dark | Secondary text | `#b2b2ba` | `#2c2c30` | 6.60:1 | 4.5:1 | Pass |
|
||
| Dark | Secondary text on inset | `#b2b2ba` | `#232327` | 7.43:1 | 4.5:1 | Pass |
|
||
| Dark | Secondary text on high | `#b2b2ba` | `#36363a` | 5.71:1 | 4.5:1 | Pass |
|
||
| Dark | Accent text / links | `#a99af0` | `#2c2c30` | 5.67:1 | 4.5:1 | Pass |
|
||
| Dark | Accent text on high | `#a99af0` | `#36363a` | 4.91:1 | 4.5:1 | Pass |
|
||
| Dark | Text on primary button | `#2a2431` | `#a99af0` | 6.14:1 | 4.5:1 | Pass |
|
||
| Dark | Success text | `#a9d88b` | `#2c2c30` | 8.53:1 | 4.5:1 | Pass |
|
||
| Dark | Error text | `#f08b93` | `#2c2c30` | 5.84:1 | 4.5:1 | Pass |
|
||
| Dark | Error text on high | `#f08b93` | `#36363a` | 5.05:1 | 4.5:1 | Pass |
|
||
| Dark | Warning text | `#e8c170` | `#2c2c30` | 8.14:1 | 4.5:1 | Pass |
|
||
| Dark | Info text | `#8fc3f0` | `#2c2c30` | 7.44:1 | 4.5:1 | Pass |
|
||
| Dark | Control border vs page (1.4.11) | `#85858f` | `#2c2c30` | 3.81:1 | 3:1 | Pass |
|
||
| Dark | Control border vs inset | `#85858f` | `#232327` | 4.29:1 | 3:1 | Pass |
|
||
| Dark | Control border vs high | `#85858f` | `#36363a` | 3.29:1 | 3:1 | Pass |
|
||
| Dark | Focus ring vs page | `#a99af0` | `#2c2c30` | 5.67:1 | 3:1 | Pass |
|
||
| Dark | Focus ring vs high | `#a99af0` | `#36363a` | 4.91:1 | 3:1 | Pass |
|
||
| Dark | Primary button fill vs page | `#a99af0` | `#2c2c30` | 5.67:1 | 3:1 | Pass |
|
||
| Dark | Error border vs inset | `#f08b93` | `#232327` | 6.58:1 | 3:1 | Pass |
|
||
| Dark | Warning border vs page | `#e8c170` | `#2c2c30` | 8.14:1 | 3:1 | Pass |
|
||
| Dark | ok chip text on 10% wash | `#a9d88b` | `#383d39` | 6.80:1 | 4.5:1 | Pass |
|
||
| Dark | err chip text on 10% wash | `#f08b93` | `#40363a` | 4.88:1 | 4.5:1 | Pass |
|
||
| Dark | warn chip text on 10% wash | `#e8c170` | `#3f3b36` | 6.50:1 | 4.5:1 | Pass |
|
||
| Dark | info chip text on 10% wash | `#8fc3f0` | `#363b43` | 6.03:1 | 4.5:1 | Pass |
|
||
| Dark | acc chip text on 10% wash | `#a99af0` | `#383743` | 4.77:1 | 4.5:1 | Pass |
|
||
| Light | Body text | `#2a2431` | `#e4e2ea` | 11.73:1 | 4.5:1 | Pass |
|
||
| Light | Body text on inset (inputs) | `#2a2431` | `#d9d6e1` | 10.51:1 | 4.5:1 | Pass |
|
||
| Light | Body text on high surface (dialog) | `#2a2431` | `#f1f0f5` | 13.28:1 | 4.5:1 | Pass |
|
||
| Light | Secondary text | `#55505f` | `#e4e2ea` | 6.06:1 | 4.5:1 | Pass |
|
||
| Light | Secondary text on inset | `#55505f` | `#d9d6e1` | 5.43:1 | 4.5:1 | Pass |
|
||
| Light | Secondary text on high | `#55505f` | `#f1f0f5` | 6.86:1 | 4.5:1 | Pass |
|
||
| Light | Accent text / links | `#5b43b8` | `#e4e2ea` | 5.57:1 | 4.5:1 | Pass |
|
||
| Light | Accent text on high | `#5b43b8` | `#f1f0f5` | 6.30:1 | 4.5:1 | Pass |
|
||
| Light | Text on primary button | `#ffffff` | `#5b43b8` | 7.14:1 | 4.5:1 | Pass |
|
||
| Light | Success text | `#2c6619` | `#e4e2ea` | 5.42:1 | 4.5:1 | Pass |
|
||
| Light | Error text | `#a8202f` | `#e4e2ea` | 5.62:1 | 4.5:1 | Pass |
|
||
| Light | Error text on high | `#a8202f` | `#f1f0f5` | 6.36:1 | 4.5:1 | Pass |
|
||
| Light | Warning text | `#7c5100` | `#e4e2ea` | 5.39:1 | 4.5:1 | Pass |
|
||
| Light | Info text | `#1d5e94` | `#e4e2ea` | 5.31:1 | 4.5:1 | Pass |
|
||
| Light | Control border vs page (1.4.11) | `#736e86` | `#e4e2ea` | 3.80:1 | 3:1 | Pass |
|
||
| Light | Control border vs inset | `#736e86` | `#d9d6e1` | 3.41:1 | 3:1 | Pass |
|
||
| Light | Control border vs high | `#736e86` | `#f1f0f5` | 4.30:1 | 3:1 | Pass |
|
||
| Light | Focus ring vs page | `#5b43b8` | `#e4e2ea` | 5.57:1 | 3:1 | Pass |
|
||
| Light | Focus ring vs high | `#5b43b8` | `#f1f0f5` | 6.30:1 | 3:1 | Pass |
|
||
| Light | Primary button fill vs page | `#5b43b8` | `#e4e2ea` | 5.57:1 | 3:1 | Pass |
|
||
| Light | Error border vs inset | `#a8202f` | `#d9d6e1` | 5.03:1 | 3:1 | Pass |
|
||
| Light | Warning border vs page | `#7c5100` | `#e4e2ea` | 5.39:1 | 3:1 | Pass |
|
||
| Light | ok chip text on 10% wash | `#2c6619` | `#d2d6d5` | 4.74:1 | 4.5:1 | Pass |
|
||
| Light | err chip text on 10% wash | `#a8202f` | `#decfd7` | 4.81:1 | 4.5:1 | Pass |
|
||
| Light | warn chip text on 10% wash | `#7c5100` | `#dad4d3` | 4.73:1 | 4.5:1 | Pass |
|
||
| Light | info chip text on 10% wash | `#1d5e94` | `#d0d5e1` | 4.64:1 | 4.5:1 | Pass |
|
||
| Light | acc chip text on 10% wash | `#5b43b8` | `#d6d2e5` | 4.83:1 | 4.5:1 | Pass |
|
||
|
||
Additional pairs checked:
|
||
|
||
| Theme | Pair | Ratio | Needs | Result |
|
||
|---|---|---|---|---|
|
||
| Dark | Ink on primary fill `#a99af0` | 6.14:1 | 4.5 | Pass |
|
||
| Dark | Ink on pressed primary `#9785e6` | 4.88:1 | 4.5 | Pass |
|
||
| Light | White on primary fill `#5b43b8` | 7.14:1 | 4.5 | Pass |
|
||
| Light | White on pressed primary `#4d37a3` | 8.78:1 | 4.5 | Pass |
|
||
| Dark | Progress fill `#a99af0` vs track `#232327` | 6.39:1 | 3 | Pass |
|
||
| Light | Progress fill `#5b43b8` vs track `#d9d6e1` | 4.99:1 | 3 | Pass |
|
||
| Dark | Low-stock fill `#f08b93` vs track | 6.58:1 | 3 | Pass |
|
||
| Light | Low-stock fill `#a8202f` vs track | 5.03:1 | 3 | Pass |
|
||
| Dark | Accent text on pressed wash (≈`#3a3848`) | 4.66:1 | 4.5 | Pass |
|
||
|
||
**Not covered by the table:** filament swatch colors are user data, not tokens.
|
||
Each swatch gets a `--border-control` ring, so a charcoal-black or mist-white
|
||
filament still shows an edge on either theme, and the color name is always
|
||
printed beside it.
|