Compare commits

...

10 Commits

Author SHA1 Message Date
Jose Luis Montañes
d3786c9768 deploy: add Dockerfile + nginx config for static hosting
The web/ directory is fully self-contained (index.html + worklet.js, all JS
deps pulled from esm.sh via the importmap). Package it as a tiny nginx:alpine
image so Coolify can build + serve it behind Caddy.

- Dockerfile: nginx:1.27-alpine, copies web/ to the document root, healthcheck.
- nginx.conf: serves /, no-cache for index.html and worklet.js (so engine
  changes land immediately after a redeploy), short cache for everything else,
  gzip on text payloads, JS MIME for AudioWorklet.
- .dockerignore: keep the image small (excludes Python sources, docs,
  references, sandbox is included since it's served from /sandbox/).
2026-05-01 18:58:55 +02:00
Jose Luis Montañes
27f89b288e web: port studio hardware aesthetic into the real app
Aligns web/index.html with the design language captured in docs/design-system.md
(reference: web/sandbox/studio.html). The whole UI now reads as a single rack
module: corner screws, top/bottom bars, screen recess, and L2 sub-panels.

- Replace dark-blue token palette with the warm-neutral hardware palette
  and the shared --metal-specular / --metal-brush / --metal-bevel layers.
- Wrap header + main + footer in a .hardware shell with 4 corner screws.
- Editor lives inside a .screen recess (deep inset shadows, transparent
  CodeMirror background, amber cursor + selection).
- Splitter is now a hairline that lights amber on hover/drag.
- Right pane groups widgets by kind into shared sub-panels (.knobs, .faders,
  .seq); each piano_roll is its own panel.
- drawKnob ported with all 8 layers (drop shadow, amber arc, dark rim,
  metallic disc with concentric brushing + vertical lighting + specular
  band + rim light + bevel + inset + engraved notch). DPR-aware canvas.
- Faders use a deep slot track with side ticks and a metallic cap.
- Step seq cells become .led with amber glow + beat dividers.
- Piano roll keys + cells recolored to warm/amber palette.
- Wave widgets render with amber glow on a recessed background.
- RUN/STOP button group with led swap; bottom-bar shows IDLE/RUNNING.

Also: ignore local-only /referencias (heavy reference images, not source).
2026-05-01 18:58:43 +02:00
Jose Luis Montañes
8f61a8324c 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>
2026-05-01 18:28:47 +02:00
Jose Luis Montañes
2193409b6e sandbox/studio: fix amber arc clipping at the canvas edges
The amber arc was drawn at radius ~30px from center on a 56×56
canvas — the bottom of the arc (at angles near 6 o'clock) sat at
y ≈ 58 and got clipped by the canvas's own bounding box. Visible
as a flat cut-off at extreme positions.

Fix: bump the canvas to 72×72 and use fixed-pixel values for the
knob's geometry instead of fractions of `size`, so the visible knob
diameter stays at ~52px regardless of canvas size and there's now
~10px of margin all around for the arc and drop shadow.

- KNOB_SIZE: 56 → 72
- totalR: size * 0.46 → 26 (fixed)
- rimW:   size * 0.045 → 2.5 (fixed)
- notch half-base: size * 0.026 → 1.5 (fixed)
- cy: size/2 + 1 → size/2 (no need for the offset on the larger canvas)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:22:34 +02:00
Jose Luis Montañes
f163a24f62 sandbox/studio: add faders sub-panel between knobs and seq
Four channel-volume faders matching the patch (KICK/HAT/MEL/MIX),
in their own sub-panel with the same brushed-metal treatment as
.knobs and .seq.

- Track is a deep slot cut into the panel — heavy inset shadow,
  thin highlight at the bottom edge for chamfer feel.
- Side ticks drawn with repeating-linear-gradient (1px on / 6px off).
- Cap uses the same metal language as the knob disc: brushed stripes
  + vertical light gradient + grip line in the middle + 4-edge bevel
  via inset shadows + drop shadow underneath.
- Click anywhere on the track jumps the cap to that value, then
  dragging continues. Pointer capture on the track itself so the cap
  follows even outside the bounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:15:48 +02:00
Jose Luis Montañes
29f1a1e58b sandbox/studio: brushed-metal language across the whole rack
Same metal treatment now applies to the outer hardware frame, the
top/bottom bars and the sub-panels (knobs + seq). Three CSS custom
properties capture the shared visual language so each frame just
plugs them in:

- --metal-specular: vertical gradient with a soft bright band on the
  upper portion
- --metal-brush: very fine repeating horizontal stripes
- --metal-bevel: 4-edge inset shadow set (bright top + dark bottom +
  highlighted left + dark right)

Per-element base colours retain a depth hierarchy: the outer
.hardware is the darkest, top/bottom bars sit slightly brighter, and
.knobs/.seq are the brightest plates so they read as mounted on top.

Drop shadows scaled to scope: .hardware gets a strong 0 8px 32px,
the bars get a small 0 2px 4px, and the sub-panels keep their two
layered drop shadows for depth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:13:06 +02:00
Jose Luis Montañes
96d11f7059 sandbox/studio: brushed-metal effect on .knobs and .seq sub-panels
Same lighting language as the knob disc, adapted for rectangular
panels (horizontal brushing instead of concentric).

Layered backgrounds:
- (1) horizontal specular band on the upper portion of the panel
- (2) very fine repeating horizontal stripes (brushed steel feel)
- (3) base vertical gradient (light from above on dark anodised metal)

Bevel via inset box-shadows: bright top edge + dark bottom edge +
faint side highlights/shadows. Drop shadow under each panel gives
visual depth so they read as separate plates mounted on the rack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:10:28 +02:00
Jose Luis Montañes
499b4f9e70 sandbox/studio: realistic brushed-aluminium knob (multi-layer lighting)
Previous version was too flat. Real metal needs concentric brushing
(not radial), a strong horizontal specular band, multiple light
sources and a defined bevel.

Disc rendering now layers:
- Radial base gradient (matte foundation, slightly dark at edge)
- Concentric brushing rings (lathe-turned aluminium, alternating
  bright/dark with deterministic pseudo-random alpha)
- Vertical lighting overlay (light-from-above gradient)
- Horizontal specular band on the upper portion (light source reflection)
- Subtle rim light from below (typical of softbox-lit studio gear)
- Bright bevel crescent on the upper edge + dark crescent on the lower
- Inset shadow line where disc meets the dark rim

Indicator notch refined to read as engraved (dark groove + thin
bright lower lip catching light on the inner edge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:08:23 +02:00
Jose Luis Montañes
249e2a2b96 sandbox/studio: knob now has large metallic disc filling the body
Reading the close-up: the knob is dominated by a big metallic chrome
disc that occupies most of its surface. The dark part is just a thin
outer rim. The indicator is a small dark notch on the disc that
rotates with the value (the disc itself stays vertically lit).

- Thin dark rim around the outside (~3px @ 56).
- Large metallic disc fills the inside (radial gradient, brushed-metal
  striations from center outward, top crescent highlight).
- Indicator is a small dark triangular notch on the disc, base near
  the center, tip toward the rim, rotated to the value angle.
- Amber arc kept outside everything as a value-progress meter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:06:08 +02:00
Jose Luis Montañes
baca3c2df1 sandbox/studio: metallic ring on knobs, top/bottom bars, recessed code
Three changes following the reference more closely:

1. Knobs now have a metallic outer bezel (chrome ring): vertical
   gradient running silver at the top → dark at the bottom, with a
   thin highlight on the upper crescent. The dark dial sits inside
   it with a slight inner shadow showing depth. Size bumped to 56px
   so the bezel is visible.

2. Layout split into two stacks:
   - left-stack: top-bar / screen / bottom-bar (vertical strip)
   - right-stack: knobs / seq

3. Top bar: STOP+RUN as a tight pair (no gap), buttons stretch to
   the bar's full height. Bottom bar matches the top bar visually
   and shows a "● RUNNING_" label with the pulsing amber dot, no
   longer inside the code area.

4. Code area is now *recessed* into the rack — no separate bordered
   panel. Inset shadows + slightly darker background simulate a
   cutout / screen window in the metal frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:01:24 +02:00
7 changed files with 1636 additions and 345 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.gitignore
__pycache__/
*.pyc
*.pyo
.venv/
venv/
.idea/
.vscode/
*.swp
*.tmp
referencias/
docs/
code_sinth/
run.py
*.md

3
.gitignore vendored
View File

@@ -15,3 +15,6 @@ venv/
.DS_Store
Thumbs.db
*.swp
# Local-only design references (heavy images, not part of the source)
referencias/

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# code-sinth — static site served by nginx
# The web/ folder is fully self-contained (index.html + worklet.js).
# All JS dependencies are loaded from esm.sh via the importmap.
FROM nginx:1.27-alpine
# Replace the default nginx site config with one that serves /web/ at /,
# applies long cache for the worklet, and sets the right MIME types.
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY web/ /usr/share/nginx/html/
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost/ >/dev/null || exit 1

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.

39
nginx.conf Normal file
View File

@@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# AudioWorkletProcessor needs the worklet served as a real JS module.
types {
application/javascript js;
text/html html;
text/css css;
image/svg+xml svg;
}
# Static index — short cache so updates land quickly after a redeploy.
location = / {
try_files /index.html =404;
add_header Cache-Control "no-cache, must-revalidate";
}
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
}
# Worklet: revalidate on every load (it changes whenever we touch the engine).
location = /worklet.js {
add_header Cache-Control "no-cache, must-revalidate";
}
# Everything else (sandbox/, future static assets) gets a moderate cache.
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "public, max-age=300";
}
# Gzip the text payloads.
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,24 @@
--hw-syn-fn: #c8a878;
--hw-syn-id: #b8b0a0;
--hw-syn-op: #6c6660;
/* shared brushed-metal language for all rack frames + sub-panels */
--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%);
--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);
--metal-bevel:
inset 0 1px 0 rgba(255, 248, 230, 0.18),
inset 0 -1px 0 rgba(0, 0, 0, 0.70),
inset 1px 0 0 rgba(255, 248, 230, 0.06),
inset -1px 0 0 rgba(0, 0, 0, 0.45);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--hw-fg);
@@ -49,22 +67,25 @@
/* ===================================================================== */
.hardware {
position: relative;
/* darker base than the sub-panels so they stand out against it */
background:
radial-gradient(ellipse at 30% 0%, var(--hw-bg-hi) 0%, var(--hw-bg) 50%, var(--hw-bg-lo) 100%);
var(--metal-specular),
var(--metal-brush),
linear-gradient(180deg, #181513 0%, #100c08 60%, #080604 100%);
border: 1px solid var(--hw-edge);
border-radius: 10px;
box-shadow:
inset 0 1px 0 rgba(255, 220, 180, 0.05),
inset 0 -1px 0 rgba(0, 0, 0, 0.7),
var(--metal-bevel),
inset 0 0 0 1px rgba(232, 160, 80, 0.04),
0 1px 0 rgba(255, 220, 180, 0.02),
0 8px 24px rgba(0, 0, 0, 0.55);
padding: 12px;
0 8px 32px rgba(0, 0, 0, 0.65),
0 1px 0 rgba(255, 220, 180, 0.04);
padding: 14px;
display: grid;
grid-template-columns: 1fr 220px;
grid-template-rows: auto 1fr;
gap: 10px;
gap: 12px;
}
.left-stack { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; min-width: 0; }
.right-stack { display: grid; grid-template-rows: auto auto auto; gap: 8px; min-width: 0; }
/* corner screws — only on the outermost frame */
.screw {
@@ -93,33 +114,48 @@
/* ===================================================================== */
/* Header bar inside the hardware unit */
/* ===================================================================== */
.hw-header {
grid-column: 1 / -1;
.top-bar, .bottom-bar {
height: 30px;
display: flex;
align-items: center;
gap: 14px;
padding: 4px 26px;
height: 36px;
align-items: stretch;
gap: 10px;
padding: 3px 12px;
color: var(--hw-fg-dim);
font-size: 10px;
letter-spacing: 0.08em;
background:
var(--metal-specular),
var(--metal-brush),
linear-gradient(180deg, #25211d 0%, #16130f 100%);
border: 1px solid var(--hw-edge);
border-radius: 4px;
box-shadow:
var(--metal-bevel),
0 2px 4px rgba(0, 0, 0, 0.4);
}
.top-bar > *, .bottom-bar > * { align-self: center; }
.top-bar .btn-group { align-self: stretch; }
.btn-group { display: flex; align-items: stretch; height: 100%; }
.btn-group .hw-btn { border-radius: 0; border-right-width: 0; }
.btn-group .hw-btn:first-child { border-radius: 3px 0 0 3px; }
.btn-group .hw-btn:last-child { border-radius: 0 3px 3px 0; border-right-width: 1px; }
.hw-btn {
background: linear-gradient(180deg, #2c261f 0%, #14110d 100%);
border: 1px solid var(--hw-edge);
border-radius: 12px;
color: var(--hw-fg-hi);
padding: 4px 12px;
padding: 0 14px;
font-family: inherit;
font-size: 10px;
cursor: pointer;
letter-spacing: 0.1em;
letter-spacing: 0.12em;
text-transform: uppercase;
box-shadow:
inset 0 1px 0 rgba(255,220,180,0.08),
inset 0 1px 0 rgba(255,220,180,0.10),
inset 0 -1px 0 rgba(0,0,0,0.55),
0 1px 1px rgba(0,0,0,0.4);
display: inline-flex; align-items: center; gap: 5px;
display: inline-flex; align-items: center; gap: 6px;
transition: color 80ms;
}
.hw-btn:hover { color: var(--hw-amber); }
@@ -148,16 +184,19 @@
/* ===================================================================== */
/* Code "screen" — left, big */
/* ===================================================================== */
/* the code area is *recessed* into the rack — no separate bordered panel.
Achieved with deep inset shadow + slightly darker bg than the rack. */
.screen {
background: var(--hw-screen);
border: 1px solid var(--hw-edge);
border-radius: 6px;
padding: 14px 18px 10px;
border-radius: 4px;
padding: 12px 18px;
box-shadow:
inset 0 0 0 1px rgba(232,160,80,0.04),
inset 0 1px 8px rgba(0,0,0,0.5);
inset 0 2px 4px rgba(0,0,0,0.7),
inset 0 -1px 0 rgba(255,220,180,0.03),
inset 0 0 0 1px var(--hw-edge),
inset 0 6px 14px rgba(0,0,0,0.5);
display: flex; flex-direction: column;
min-height: 320px;
min-height: 300px;
overflow: hidden;
}
.glass {
@@ -174,12 +213,19 @@
.syn-op { color: var(--hw-syn-op); }
.syn-arrow { color: var(--hw-amber); }
.status-line {
margin-top: 8px;
.running-label {
color: var(--hw-amber);
font-size: 11px;
letter-spacing: 0.16em;
text-shadow: 0 0 6px var(--hw-amber-glow);
font-size: 10px;
letter-spacing: 0.18em;
text-shadow: 0 0 5px var(--hw-amber-glow);
}
.running-dot {
display: inline-block;
width: 6px; height: 6px; border-radius: 50%;
background: var(--hw-amber);
box-shadow: 0 0 5px var(--hw-amber-glow);
margin-right: 6px; vertical-align: middle;
animation: led-pulse 2.2s ease-in-out infinite;
}
.blink { animation: blink 1.1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
@@ -187,20 +233,26 @@
/* ===================================================================== */
/* Right column */
/* ===================================================================== */
.right-col {
display: flex; flex-direction: column; gap: 10px;
min-width: 0;
/* shared brushed-metal panel treatment for sub-panels inside the rack */
.knobs, .seq, .faders {
border: 1px solid var(--hw-edge);
border-radius: 6px;
background:
var(--metal-specular),
var(--metal-brush),
/* brighter base than .hardware so they read as plates mounted on top */
linear-gradient(180deg, #2c2823 0%, #1a1612 55%, #110d09 100%);
box-shadow:
var(--metal-bevel),
inset 0 -10px 14px -10px rgba(255, 220, 180, 0.10),
0 2px 4px rgba(0, 0, 0, 0.45),
0 6px 14px rgba(0, 0, 0, 0.30);
}
/* knobs sub-panel inside hardware */
.knobs {
background: linear-gradient(180deg, #1f1a14 0%, #14110d 100%);
border: 1px solid var(--hw-edge);
border-radius: 6px;
padding: 14px 12px;
box-shadow:
inset 0 1px 0 rgba(255,220,180,0.04),
inset 0 -1px 0 rgba(0,0,0,0.6);
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 10px;
@@ -222,15 +274,85 @@
.hw-knob { cursor: grab; touch-action: none; }
.hw-knob.dragging { cursor: grabbing; }
/* sequencer sub-panel */
.seq {
background: linear-gradient(180deg, #1f1a14 0%, #14110d 100%);
border: 1px solid var(--hw-edge);
border-radius: 6px;
padding: 8px 10px;
/* faders sub-panel */
.faders {
padding: 10px 10px 14px;
display: flex;
justify-content: space-around;
align-items: flex-start;
gap: 8px;
}
.fader-cell {
display: flex; flex-direction: column; align-items: center;
gap: 4px; flex: 1; min-width: 0;
}
.fader-cell .label {
font-size: 8px; letter-spacing: 0.18em; text-transform: uppercase;
color: var(--hw-engrave);
text-shadow: 0 1px 0 rgba(0,0,0,0.7), 0 -1px 0 rgba(255,220,180,0.04);
}
/* track is a deep slot cut into the panel */
.fd-track {
position: relative;
width: 5px; height: 78px;
background:
linear-gradient(180deg, #050402 0%, #0a0808 50%, #060403 100%);
border-radius: 3px;
box-shadow:
inset 0 1px 0 rgba(255,220,180,0.04),
inset 0 -1px 0 rgba(0,0,0,0.6);
inset 0 1px 3px rgba(0,0,0,0.85),
inset 0 0 0 1px rgba(0,0,0,0.6),
inset 0 -1px 0 rgba(255,220,180,0.05),
0 1px 0 rgba(255,220,180,0.04);
cursor: pointer; touch-action: none;
}
/* tick marks on either side of the track */
.fd-ticks {
position: absolute; top: 4px; bottom: 4px;
width: 16px; pointer-events: none;
background: repeating-linear-gradient(
180deg,
rgba(255,220,180,0.20) 0px,
rgba(255,220,180,0.20) 1px,
transparent 1px,
transparent 7px);
}
.fd-ticks.left { left: -18px; }
.fd-ticks.right { right: -18px; }
/* the cap — metallic, sits on the track */
.fd-cap {
position: absolute;
left: 50%; transform: translate(-50%, -50%);
width: 26px; height: 14px;
border-radius: 2px;
background:
/* horizontal grip line in middle */
linear-gradient(180deg,
transparent 38%, rgba(0,0,0,0.55) 46%, rgba(0,0,0,0.55) 54%,
transparent 62%),
/* very fine brushed stripes */
repeating-linear-gradient(0deg,
rgba(255,250,235,0.04) 0px, rgba(255,250,235,0.04) 1px,
rgba(0,0,0,0.05) 1px, rgba(0,0,0,0.05) 2px),
/* vertical light gradient */
linear-gradient(180deg, #b6b0a4 0%, #807a6e 45%, #3a352e 80%, #1a1612 100%);
box-shadow:
inset 0 1px 0 rgba(255,252,240,0.55),
inset 0 -1px 0 rgba(0,0,0,0.7),
inset 1px 0 0 rgba(255,250,235,0.15),
inset -1px 0 0 rgba(0,0,0,0.45),
0 2px 3px rgba(0,0,0,0.6);
cursor: grab; touch-action: none;
}
.fd-cap.dragging { cursor: grabbing; }
.fader-cell .value {
font-size: 10px; font-variant-numeric: tabular-nums;
color: var(--hw-fg-hi); letter-spacing: 0.05em;
margin-top: 2px;
}
/* sequencer sub-panel — inherits brushed-metal background from .knobs,.seq */
.seq {
padding: 8px 10px;
display: flex; flex-direction: column; gap: 5px;
}
.seq-row {
@@ -284,24 +406,32 @@
<span class="screw bl"></span>
<span class="screw br"></span>
<!-- header bar, full width -->
<div class="hw-header">
<!-- left stack: top-bar / screen / bottom-bar -->
<div class="left-stack">
<div class="top-bar">
<div class="btn-group">
<button class="hw-btn"><span class="led-red"></span>STOP</button>
<button class="hw-btn"><span class="ico-play"></span>RUN</button>
</div>
<span>48 KHZ</span>
<span>TAPS: <span class="num">14</span></span>
<span>CPU: <span class="num">9%</span></span>
<span class="right">CODE · SINTH</span>
</div>
<!-- code area -->
<section class="screen">
<div class="glass" id="glass"></div>
<div class="status-line">&gt; RUNNING<span class="blink">_</span></div>
</section>
<div class="bottom-bar">
<span class="running-label"><span class="running-dot"></span>RUNNING<span class="blink">_</span></span>
</div>
</div>
<!-- right column with knobs + seq -->
<div class="right-col">
<div class="right-stack">
<section class="knobs">
<div class="knob-cell">
@@ -326,6 +456,45 @@
</div>
</section>
<section class="faders">
<div class="fader-cell">
<span class="label">kick</span>
<div class="fd-track" data-value="0.78">
<div class="fd-ticks left"></div>
<div class="fd-ticks right"></div>
<div class="fd-cap"></div>
</div>
<span class="value">0.78</span>
</div>
<div class="fader-cell">
<span class="label">hat</span>
<div class="fd-track" data-value="0.55">
<div class="fd-ticks left"></div>
<div class="fd-ticks right"></div>
<div class="fd-cap"></div>
</div>
<span class="value">0.55</span>
</div>
<div class="fader-cell">
<span class="label">mel</span>
<div class="fd-track" data-value="0.62">
<div class="fd-ticks left"></div>
<div class="fd-ticks right"></div>
<div class="fd-cap"></div>
</div>
<span class="value">0.62</span>
</div>
<div class="fader-cell">
<span class="label">mix</span>
<div class="fd-track" data-value="0.85">
<div class="fd-ticks left"></div>
<div class="fd-ticks right"></div>
<div class="fd-cap"></div>
</div>
<span class="value">0.85</span>
</div>
</section>
<section class="seq">
<div class="seq-row" data-pattern="1000100010001000">
<span class="label">kicks</span>
@@ -406,7 +575,9 @@ for (const line of PATCH) {
// knob — restrained: dark gradient + thin amber arc + single tickmark.
// No knurled ridges, no brushed metal. Closer to the reference image.
// ===========================================================================
const KNOB_SIZE = 50;
// Canvas is bigger than the visible knob so the amber arc + drop shadow have
// room around the dial. Visible knob radius is ~26px regardless of canvas size.
const KNOB_SIZE = 72;
function setupCanvas(canvas, size) {
const dpr = window.devicePixelRatio || 1;
canvas.style.width = size + 'px';
@@ -419,72 +590,162 @@ function setupCanvas(canvas, size) {
}
function drawKnob(ctx, size, norm) {
const cx = size / 2, cy = size / 2 + 1;
const dialR = size * 0.40;
const cx = size / 2, cy = size / 2;
// Fixed pixel sizes so the visible knob doesn't grow with the canvas.
// Canvas size only adds margin around these for arc + shadow.
const totalR = 26; // outer edge of the whole knob
const rimW = 2.5; // dark rim thickness
const discR = totalR - rimW; // metallic disc fills most of inside
ctx.clearRect(0, 0, size, size);
// soft outer drop shadow under the knob (sits ON the panel)
// (1) drop shadow under the whole knob
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.6)';
ctx.shadowColor = 'rgba(0,0,0,0.7)';
ctx.shadowBlur = 6;
ctx.shadowOffsetY = 2;
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(cx, cy, dialR, 0, Math.PI * 2);
ctx.arc(cx, cy, totalR + 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// value arc — amber, only the active portion is drawn (no background track)
// (2) amber value arc OUTSIDE everything (faint full track + bright active)
const startA = Math.PI * 0.78, endA = Math.PI * 2.22;
ctx.lineCap = 'round';
ctx.lineWidth = 1.6;
ctx.strokeStyle = 'rgba(232,160,80,0.10)'; // very faint full track
ctx.lineWidth = 1.5;
ctx.strokeStyle = 'rgba(232,160,80,0.10)';
ctx.beginPath();
ctx.arc(cx, cy, dialR + 2.5, startA, endA);
ctx.arc(cx, cy, totalR + 4, startA, endA);
ctx.stroke();
ctx.strokeStyle = '#e8a050';
ctx.shadowBlur = 3;
ctx.shadowColor = 'rgba(232,160,80,0.5)';
ctx.shadowColor = 'rgba(232,160,80,0.55)';
ctx.beginPath();
ctx.arc(cx, cy, dialR + 2.5, startA, startA + norm * (endA - startA));
ctx.arc(cx, cy, totalR + 4, startA, startA + norm * (endA - startA));
ctx.stroke();
ctx.shadowBlur = 0;
// dial body — neutral charcoal black, NOT warm brown
const grad = ctx.createRadialGradient(
cx - dialR * 0.35, cy - dialR * 0.5, 0,
cx, cy, dialR * 1.1
);
grad.addColorStop(0, '#2a2a28'); // top-left highlight
grad.addColorStop(0.5, '#141312');
grad.addColorStop(1, '#040404');
ctx.fillStyle = grad;
// (3) thin DARK RIM around the outside (the body of the knob)
const rim = ctx.createLinearGradient(0, cy - totalR, 0, cy + totalR);
rim.addColorStop(0, '#2a2826');
rim.addColorStop(0.5, '#0a0908');
rim.addColorStop(1, '#040404');
ctx.strokeStyle = rim;
ctx.lineWidth = rimW;
ctx.beginPath();
ctx.arc(cx, cy, dialR, 0, Math.PI * 2);
ctx.arc(cx, cy, totalR - rimW / 2, 0, Math.PI * 2);
ctx.stroke();
// (4a) METAL — base radial gradient (matte foundation, slightly dark at edge)
const baseGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, discR);
baseGrad.addColorStop(0, '#787268');
baseGrad.addColorStop(0.7, '#5a544c');
baseGrad.addColorStop(1, '#36322d');
ctx.fillStyle = baseGrad;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// outer dark ring (separates dial from panel)
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
ctx.lineWidth = 1;
// (4b) CONCENTRIC BRUSHING — lathe-turned aluminium look (signature on
// Moog/Roland synths). Pseudo-random alpha per ring, alternating
// bright/dark bands, deterministic so the rings don't shimmer.
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, dialR, 0, Math.PI * 2);
ctx.arc(cx, cy, discR - 0.3, 0, Math.PI * 2);
ctx.clip();
for (let r = discR; r > 0.5; r -= 0.55) {
const seed = Math.floor(r * 17.31);
const n = ((seed * 9301 + 49297) % 233) / 233;
const a = 0.04 + n * 0.10;
ctx.strokeStyle = n > 0.55
? `rgba(255, 245, 225, ${a.toFixed(3)})`
: `rgba(10, 8, 6, ${(a * 0.85).toFixed(3)})`;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
// (4c) VERTICAL LIGHTING — light from above. Strong on top, dark below.
const vert = ctx.createLinearGradient(0, cy - discR, 0, cy + discR);
vert.addColorStop(0, 'rgba(255, 248, 230, 0.32)');
vert.addColorStop(0.40, 'rgba(255, 248, 230, 0.04)');
vert.addColorStop(0.55, 'rgba(0, 0, 0, 0.05)');
vert.addColorStop(1, 'rgba(0, 0, 0, 0.45)');
ctx.fillStyle = vert;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (4d) HORIZONTAL SPECULAR BAND — the bright reflection of the light
// source running across the upper part of the disc.
const band = ctx.createLinearGradient(0, cy - discR * 0.65, 0, cy - discR * 0.05);
band.addColorStop(0, 'rgba(255, 252, 240, 0)');
band.addColorStop(0.5, 'rgba(255, 252, 240, 0.42)');
band.addColorStop(1, 'rgba(255, 252, 240, 0)');
ctx.fillStyle = band;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (4e) RIM LIGHT FROM BELOW — subtle glow on the bottom edge (typical of
// studio gear photographed under softboxes).
const rimLight = ctx.createLinearGradient(0, cy + discR * 0.55, 0, cy + discR);
rimLight.addColorStop(0, 'rgba(255, 230, 200, 0)');
rimLight.addColorStop(1, 'rgba(255, 230, 200, 0.14)');
ctx.fillStyle = rimLight;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (5) BEVEL — bright crescent on the upper edge
ctx.strokeStyle = 'rgba(255, 252, 240, 0.55)';
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.arc(cx, cy, discR - 0.5, Math.PI * 1.10, Math.PI * 1.90);
ctx.stroke();
// glossy top crescent — short bright arc on the upper half
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 0.8;
// (5b) BEVEL — dark crescent on the lower edge (shadow side of the bevel)
ctx.strokeStyle = 'rgba(0, 0, 0, 0.50)';
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.arc(cx, cy, dialR - 1.0, Math.PI * 1.18, Math.PI * 1.82);
ctx.arc(cx, cy, discR - 0.5, Math.PI * 0.10, Math.PI * 0.90);
ctx.stroke();
// indicator: short cream tick from edge inward
// (6) inset shadow line where disc meets rim
ctx.strokeStyle = 'rgba(0, 0, 0, 0.65)';
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.arc(cx, cy, discR + 0.3, 0, Math.PI * 2);
ctx.stroke();
// (7) INDICATOR — small engraved dark notch on the disc, rotates with value.
// Drawn as a tapered triangle with a subtle bottom highlight to look
// like it's incised into the metal rather than printed on top.
const ang = startA + norm * (endA - startA);
ctx.strokeStyle = 'rgba(245,235,215,0.92)';
ctx.lineWidth = 1.6;
ctx.lineCap = 'round';
const tipR = discR * 0.86;
const baseR = discR * 0.50;
const perp = ang + Math.PI / 2;
const half = 1.5; // fixed half-width of the notch base
const tipX = cx + Math.cos(ang) * tipR;
const tipY = cy + Math.sin(ang) * tipR;
const bX = cx + Math.cos(ang) * baseR;
const bY = cy + Math.sin(ang) * baseR;
// dark fill (the engraved groove)
ctx.fillStyle = 'rgba(6, 5, 4, 0.92)';
ctx.beginPath();
ctx.moveTo(cx + Math.cos(ang) * dialR * 0.55, cy + Math.sin(ang) * dialR * 0.55);
ctx.lineTo(cx + Math.cos(ang) * dialR * 0.88, cy + Math.sin(ang) * dialR * 0.88);
ctx.moveTo(tipX, tipY);
ctx.lineTo(bX + Math.cos(perp) * half, bY + Math.sin(perp) * half);
ctx.lineTo(bX - Math.cos(perp) * half, bY - Math.sin(perp) * half);
ctx.closePath();
ctx.fill();
// tiny bright lower lip (light catches the inner edge of the groove)
ctx.strokeStyle = 'rgba(255, 250, 230, 0.25)';
ctx.lineWidth = 0.4;
ctx.beginPath();
ctx.moveTo(bX + Math.cos(perp) * half, bY + Math.sin(perp) * half);
ctx.lineTo(tipX, tipY);
ctx.stroke();
}
@@ -535,6 +796,51 @@ document.querySelectorAll('canvas.hw-knob').forEach((canvas) => {
});
});
// ===========================================================================
// faders — drag the cap or click anywhere on the track to jump
// ===========================================================================
document.querySelectorAll('.fader-cell').forEach((cell) => {
const track = cell.querySelector('.fd-track');
const cap = cell.querySelector('.fd-cap');
const valueEl = cell.querySelector('.value');
let value = parseFloat(track.dataset.value || '0.5');
function paint() {
const trackH = track.clientHeight;
cap.style.top = ((1 - value) * trackH) + 'px';
if (valueEl) valueEl.textContent = value.toFixed(2);
}
// wait for layout
requestAnimationFrame(paint);
function valueAt(clientY) {
const rect = track.getBoundingClientRect();
const norm = 1 - (clientY - rect.top) / rect.height;
return Math.max(0, Math.min(1, norm));
}
function startDrag(e, jumpToPointer) {
e.preventDefault();
cap.classList.add('dragging');
track.setPointerCapture(e.pointerId);
document.body.style.cursor = 'grabbing';
if (jumpToPointer) { value = valueAt(e.clientY); paint(); }
const onMove = (ev) => { value = valueAt(ev.clientY); paint(); };
const onUp = () => {
cap.classList.remove('dragging');
document.body.style.cursor = '';
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onUp);
};
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onUp);
}
// click on track jumps to that position; drag continues from there
track.addEventListener('pointerdown', (e) => startDrag(e, true));
});
// ===========================================================================
// step LEDs
// ===========================================================================