Compare commits
10 Commits
64620ef75a
...
d3786c9768
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3786c9768 | ||
|
|
27f89b288e | ||
|
|
8f61a8324c | ||
|
|
2193409b6e | ||
|
|
f163a24f62 | ||
|
|
29f1a1e58b | ||
|
|
96d11f7059 | ||
|
|
499b4f9e70 | ||
|
|
249e2a2b96 | ||
|
|
baca3c2df1 |
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
3
.gitignore
vendored
@@ -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
14
Dockerfile
Normal 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
380
docs/design-system.md
Normal 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
39
nginx.conf
Normal 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;
|
||||
}
|
||||
989
web/index.html
989
web/index.html
File diff suppressed because it is too large
Load Diff
@@ -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">> 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
|
||||
// ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user