docs: design system spec for the studio hardware emulation

Captures every decision made during the studio.html sandbox iteration
so the same visual language can be applied to the rest of the app
(and to new components later) without re-deriving it.

Sections:
- Vision + reference instruments
- Lighting model (single rule: light from above)
- Color tokens with semantic naming
- The 3-layer "metal" language as shared CSS custom properties
- Surface hierarchy (3 depth layers with progressively brighter bases)
- Component anatomies: rack, bars, screen, sub-panels, knobs, faders,
  step LEDs, buttons, screws, engraved labels, numeric displays
- Typography, spacing scale, interaction conventions
- "What is intentionally NOT here" — rejected ideas to keep the
  language disciplined
- A "how to add a new component" checklist

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis Montañes
2026-05-01 18:28:47 +02:00
parent 2193409b6e
commit 8f61a8324c

380
docs/design-system.md Normal file
View File

@@ -0,0 +1,380 @@
# code-sinth — design system
The studio hardware emulation language. Anything we add to the right-pane
control surface should follow this. The goal is consistency: a new component
should look like it belongs on the same physical rack as everything else.
The reference implementation lives in [web/sandbox/studio.html](../web/sandbox/studio.html).
## Vision
> Vintage studio rack module. Dark anodised aluminium plates mounted on a
> darker frame, lit from above by a softbox. Metal parts catch the light.
> Engraved labels on the metal. Amber LEDs and amber readouts where
> something is "active".
References that pull this together: Moog Voyager, Roland TR-808, Korg MS-20,
Universal Audio 1176. Skeuomorphic but **disciplined** — every effect
exists to model a physical material under a specific lighting setup, not
for decoration.
## Lighting model
**One single rule, never broken**: light comes from **above**, slightly
in front. Every reflective surface lights its top and shadows its bottom.
This applies regardless of where the surface sits within the rack.
Practical consequences:
- Vertical gradients on metal (lighter top, darker bottom).
- Specular bands on the upper portion of any large flat surface.
- Bevels: bright on top edge, dark on bottom edge.
- Top-edge gloss crescent on circular pieces (knob disc, indicator dot).
- Subtle warm rim light at the very bottom — bounce light from the surface
the rack sits on (typical of softbox-lit photos of studio gear).
When in doubt, ask: "if this were aluminium under a softbox lamp, where
would the highlight go?"
## Color tokens
All colors live as CSS custom properties on `:root`. Semantic naming, not
literal — adding a new theme later means changing the values, not the
selectors.
```css
/* surfaces (warm-neutral dark, faint warm tint to feel like metal not plastic) */
--bg: #131210; /* page bg, behind the rack */
--hw-bg: #1a1916; /* outer rack base */
--hw-bg-hi: #232220;
--hw-bg-lo: #0e0d0b;
--hw-edge: #050505; /* sharp dark edges between surfaces */
--hw-screen: #161412; /* code-area glass — slightly darker than the rack */
/* text */
--hw-fg: #a8a39a; /* general body text */
--hw-fg-hi: #d8d0c0; /* numeric values, labels at attention */
--hw-fg-dim: #6c6660; /* secondary status text */
--hw-engrave: #8a847a; /* engraved labels on metal */
/* accent — amber. ONLY for active indicators: arcs, LEDs, running label */
--hw-amber: #e8a050;
--hw-amber-hi: #f4c890;
--hw-amber-mut: rgba(232, 160, 80, 0.18);
--hw-amber-glow: rgba(232, 160, 80, 0.45);
--hw-amber-off: #28201a;
/* status LEDs */
--hw-led-red: #c84838; /* stop / alert */
--hw-led-green: #6aca8a; /* play / running */
/* code syntax — warm desaturated palette, never bright primary colors */
--hw-syn-com: #5a554f;
--hw-syn-kw: #d68868;
--hw-syn-num: #d6a268;
--hw-syn-fn: #c8a878;
--hw-syn-id: #b8b0a0;
--hw-syn-op: #6c6660;
```
**The amber accent is precious**. Used for: knob value arcs, fader scale
indication, step LEDs when on, the running label, numeric readouts on
amber LCDs (we don't currently have those). Never for plain text, plain
borders, or decorative accents — overuse kills its meaning.
## The "metal" language (3 reusable layers)
Any frame that should read as a brushed-aluminium plate composes three
shared CSS variables:
```css
/* (1) horizontal specular band on the upper portion */
--metal-specular: linear-gradient(180deg,
rgba(255, 248, 230, 0.08) 0%,
rgba(255, 248, 230, 0.03) 28%,
rgba(255, 248, 230, 0) 45%,
rgba(0, 0, 0, 0) 70%,
rgba(0, 0, 0, 0.10) 100%);
/* (2) very fine repeating horizontal stripes (brushed steel feel) */
--metal-brush: repeating-linear-gradient(0deg,
rgba(255, 250, 235, 0.018) 0px,
rgba(255, 250, 235, 0.018) 1px,
rgba(0, 0, 0, 0.030) 1px,
rgba(0, 0, 0, 0.030) 2px);
/* (3) 4-edge bevel via inset shadows */
--metal-bevel:
inset 0 1px 0 rgba(255, 248, 230, 0.18), /* top edge highlight */
inset 0 -1px 0 rgba(0, 0, 0, 0.70), /* bottom edge shadow */
inset 1px 0 0 rgba(255, 248, 230, 0.06), /* left edge faint highlight */
inset -1px 0 0 rgba(0, 0, 0, 0.45); /* right edge dark */
```
A frame plugs them in and adds its own base color and drop shadow:
```css
.some-panel {
background:
var(--metal-specular),
var(--metal-brush),
linear-gradient(180deg, /* base color top → bottom */ );
box-shadow:
var(--metal-bevel),
/* element-specific drop shadow */;
}
```
This is the single most important rule: **every metal surface uses these
three layers**. Don't reinvent them per component.
## Surface hierarchy
Three depth layers, each visually brighter than the one beneath so they
read as plates physically mounted on top of each other:
| Layer | Element | Base gradient | Drop shadow |
|------------------|-------------------------------|-------------------------------------------|----------------------------------------------|
| L0 (deepest) | `.hardware` (outer rack) | `#181513``#080604` | `0 8px 32px rgba(0,0,0,0.65)` |
| L1 (mid) | `.top-bar`, `.bottom-bar` | `#25211d``#16130f` | `0 2px 4px rgba(0,0,0,0.4)` |
| L2 (brightest) | `.knobs`, `.faders`, `.seq` | `#2c2823``#1a1612``#110d09` | `0 2px 4px + 0 6px 14px` (two-layer) |
The progressively brighter base + progressively bolder drop shadow makes
the L2 plates feel "lifted" off the rack, while the bars feel mounted
flush. The code "screen" is **outside** this hierarchy — it's a recess,
not a surface (see below).
## Components
### Outer rack frame (`.hardware`)
- L0 of the surface hierarchy. Largest border-radius (10px) — the overall
unit feels like a single piece.
- Has 4 corner screws, **only on the outermost frame** — sub-panels do not
carry screws.
- Padding 14px. Grid: editor column on the left, fixed-width control
column (220px) on the right.
### Top / bottom bars (`.top-bar`, `.bottom-bar`)
- L1. Twin styling — they read as a matched pair anchoring the screen.
- Height 30px. Border-radius 4px (lower than rack).
- Top bar: transport button group + status text. Bottom bar: running
label with pulsing amber dot. Same height/style/border, different
contents — visual symmetry is the point.
### Code "screen" (`.screen`)
- Not a panel — a **recess**. No raised surface, no bevel highlights.
- Achieved with deep inset shadows on a slightly darker background:
```css
background: var(--hw-screen);
box-shadow:
inset 0 2px 4px rgba(0,0,0,0.7),
inset 0 0 0 1px var(--hw-edge),
inset 0 6px 14px rgba(0,0,0,0.5);
```
- Reads as a hole cut into the rack with a glass screen behind it.
- Sits between top-bar and bottom-bar to complete the "header / screen /
footer" feel of a single rack module.
### Sub-panels (`.knobs`, `.faders`, `.seq`)
- L2 of the hierarchy. Border-radius 6px.
- Use `.knobs, .seq, .faders` shared selector for the metal language;
each adds its own internal layout (grid for knobs, flex row for faders,
flex column for seq).
### Knobs
The most elaborate component. Eight layers in canvas, in this order:
1. **Drop shadow** — black disc fill at slight Y-offset with `shadowBlur`.
Sits the knob "on" the panel.
2. **Amber value arc** — outside the knob's outer edge. Faint full-track
stroke + bright glowing active arc (length proportional to value).
3. **Dark rim** — thin (~2.5px) outer ring of the knob body. Vertical
gradient `#2a2826#0a0908#040404`.
4. **Big metallic disc** — fills most of the knob's area. This is the
centerpiece. Six sub-layers stacked:
- **Base** radial gradient (matte foundation, slightly darker at the edge).
- **Concentric brushing** — concentric circles every 0.55px with
pseudo-random alpha alternating bright/dark. Imitates lathe-turned
aluminium. Deterministic seed so it doesn't shimmer between frames.
- **Vertical lighting** overlay (white wash 32% on top, black wash 45%
on bottom).
- **Horizontal specular band** on the upper third (white-warm 42% peak
with soft falloff).
- **Bottom rim light** — warm cream wash 14% at the very bottom.
- **Bevel** — bright crescent on the top edge (55% white-warm) +
dark crescent on the bottom edge (50% black).
5. **Inset shadow** between disc and rim, gives the disc a "sunk-in" feel.
6. **Indicator notch** — small dark triangle on the disc, base near the
center, tip toward the rim, rotated to the value angle. Engraved
appearance: dark fill + thin bright lower-edge highlight catching
light on the inside of the groove.
Fixed pixel sizes: `totalR = 26`, `rimW = 2.5`, `discR = 23.5`, notch
half-base = 1.5. Canvas is `72×72` — the extra ~10px margin lets the
amber arc and drop shadow render without clipping.
Indicator angle range: `Math.PI * 0.78` (lower-left) to `Math.PI * 2.22`
(lower-right) — a 7:30 → 4:30 sweep, matching how real synth knobs end
their throw.
### Faders
Vertical sliders, sit in their own sub-panel arranged in a row.
- **Track** (`.fd-track`) — 5px wide × 78px tall. Deep slot cut into the
panel. Heavy `inset 0 1px 3px rgba(0,0,0,0.85)` shadow + thin warm
highlight at the bottom edge for chamfer feel.
- **Tick marks** (`.fd-ticks`) — 16px wide pseudo-element on each side
of the track. `repeating-linear-gradient` 1px on / 6px off. Engraved
scale alongside the slot.
- **Cap** (`.fd-cap`) — 26×14px rounded-corners metallic block. Same
metal language as the knob disc, condensed for a small element:
- Brushed stripes via `repeating-linear-gradient` (4% / 5% alpha).
- Vertical light gradient `#b6b0a4#807a6e#3a352e#1a1612`.
- Horizontal grip line — dark band at 46-54% Y for the "thumb groove".
- 4-edge inset bevel (top bright, bottom dark, left subtle highlight,
right dark).
- Drop shadow `0 2px 3px rgba(0,0,0,0.6)`.
Interaction: click anywhere on the track jumps the cap there + starts a
drag; drag follows pointer; pointerCapture is on the track itself so the
cap follows even past the bounds.
### Step LEDs (`.led`)
- Off: `linear-gradient(180deg, #100c08#28201a)` — looks unlit but
not flat black (so the off LEDs have a subtle "physical" feel).
- On: `radial-gradient(circle at 50% 25%, #f4c890#e8a050#a06820)`
+ `0 0 5px var(--hw-amber-glow)` outer glow.
- Hover: brighter dim variant. Click toggles.
- 16-cell row. Beat dividers every 4 cells via a pseudo-element on the
left edge of cells where `i % 4 === 0`.
- Playhead: `outline: 1px solid rgba(232, 160, 80, 0.6)` + 1px offset.
### Buttons (`.hw-btn`)
- Vertical gradient `#2c261f#14110d`.
- Border `1px solid var(--hw-edge)`.
- Inset highlight on top edge + dark on bottom edge.
- Letter-spacing 0.12em, uppercase, font-size 10px.
- Hover: text changes to amber. Active: `transform: translateY(1px)` +
inset darkening (the "press" feel).
- **Button groups** in the top bar (STOP+RUN): adjacent, no gap, share
borders. First child has left-rounded corners only, last child has
right-rounded only. The middle border merges. Gives the feel of a
single rocker switch.
### Screws (`.screw`)
- 8px diameter. **Only on the outermost rack** — sub-panels don't carry
screws (real rack panels are usually pop-mounted, not bolted).
- `radial-gradient(circle at 30% 25%, #8a7d6a#3a3025#100c08)` for
the metal head.
- `::after` adds a slot mark with a 45° linear-gradient hard-edge.
- Inset shadow + small drop shadow for "sunk into the metal".
### Engraved labels
The `.label` class on knob/fader cells, the engraved text inside bars.
- `font-size: 9-10px`, `letter-spacing: 0.16-0.18em`, `text-transform:
uppercase`.
- Color `var(--hw-engrave)` — the desaturated warm gray.
- Double text-shadow for the engraved feel:
```css
text-shadow:
0 1px 0 rgba(0, 0, 0, 0.7), /* deep shadow below */
0 -1px 0 rgba(255, 220, 180, 0.04); /* faint highlight above */
```
- The shadow below + highlight above creates the optical illusion of
text incised into metal lit from above.
### Numeric value displays
- Below knobs and faders. Cream color (`var(--hw-fg-hi)`), tabular nums.
- **No frame, no inset background** — flat text on the panel. We
experimented with LCD-style amber inset readouts and it felt too busy.
The amber is reserved for active indicators; values are passive.
- Width matches the knob (~46px min) so the layout doesn't shift when
the digit count changes.
## Typography
Single font stack: `'JetBrains Mono', 'Cascadia Code', Consolas, monospace`.
Everything monospace — keeps the "instrument firmware" feel.
Sizes:
- Body / labels: 10-11px
- Code: 12-13px
- Headings (when needed): 12-14px
Letter-spacing: `0.06em` for normal text, `0.16-0.18em` for engraved
labels and button text. Wider tracking gives the "carved into metal"
feel.
Tabular numerals (`font-variant-numeric: tabular-nums`) for any value
display so they don't wobble when digits change.
## Interaction
- **Drag**: knobs and faders use `pointerdown` + `setPointerCapture`.
Always change `cursor` to `grabbing` on body during the drag (so the
cursor doesn't flicker if the pointer leaves the element bounds).
- **Click track to jump**: faders accept a click anywhere on the track
to jump the cap there, then a drag continues from that point.
- **Double-click to recenter**: knobs reset to their default value.
- **Shift-drag for fine adjust**: knobs accept `ev.shiftKey` for ¼-speed
adjustment.
- **`pointercancel`** is always handled alongside `pointerup` — covers
edge cases where the browser cancels a drag mid-gesture.
- Always remove all listeners and reset cursors on the up/cancel handler.
## Spacing & proportions
- Outer rack padding: 14px.
- Outer rack gap (editor ↔ control column): 12px.
- Right-stack inner gap (knobs / faders / seq): 8px.
- Sub-panel padding: 8-14px depending on density.
- Element gap inside sub-panels: 4-12px.
The unifying scale is multiples of 2 (2/4/8/12/14). Avoid 5px, 7px,
13px — they look like accidents.
## What is intentionally NOT here
To keep the language disciplined, these were considered and rejected:
- **Skeuomorphic photo textures** (real wood, real brushed metal photos).
Pure CSS gradients keep the file small and stay sharp at any DPI.
- **3D rotation / perspective transforms**. The lighting model already
conveys depth; transforms add visual noise.
- **Animated reflections / moving highlights**. Static metal looks
premium; moving highlights look like a screensaver.
- **Multiple accent colors**. Amber is the only "active" hue. A second
accent would dilute the meaning.
- **Decorative dots / flourishes / brand marks**. The rack is
minimalist — every visible element is functional.
- **Bright primaries in syntax highlighting**. The code area is
"behind glass" with warm light passing through; saturated greens
and blues break the illusion.
## How to add a new component
1. Decide its surface hierarchy layer (L0 / L1 / L2). Almost everything
new is L2 (a sub-panel).
2. Apply the metal language: `var(--metal-specular)`, `var(--metal-brush)`,
`var(--metal-bevel)` plus a base color and drop shadow appropriate
to the layer.
3. Use `--hw-engrave` for any label text, with the double-text-shadow
formula.
4. Use `--hw-amber` only if the new component has an "active" indicator
to show.
5. Drag interactions: pointerdown + setPointerCapture, body cursor =
`grabbing`, listen for pointermove/up/cancel, clean up on up/cancel.
6. Numbers: tabular-nums, `--hw-fg-hi` color, no inset frame.
7. If it's reflective (metal disc, cap, sphere), light it from above.
No exceptions.