Files

264 lines
17 KiB
Markdown
Raw Permalink Normal View History

2026-09-19 11:31:50 -04:00
# 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.