ratandeep commited on
Commit
0a71f72
·
verified ·
1 Parent(s): 0cb7e78

Redesign: THE WAKING SET — talking dial-glass, tube warm-up, real type, analog VU

Browse files
Files changed (1) hide show
  1. radio.html +607 -403
radio.html CHANGED
@@ -7,161 +7,167 @@
7
 
8
  <!--
9
  ====================================================================
10
- NIGHTWAVE - a self-contained skeuomorphic 1970s analog radio.
11
- Pure HTML / CSS / Canvas / SVG / Web Audio. No external assets.
 
 
 
 
12
  Works both at top-level (devserver) AND inside <iframe srcdoc>.
13
 
14
- The DJ, the music, and the calls are all live. The signal gauge reflects how
15
- well you are tuned to the station (98.6).
16
  -->
17
 
 
 
 
 
18
  <style>
19
  /* ===================================================================
20
  1. PALETTE & GLOBALS
21
  =================================================================== */
22
  :root{
23
- --walnut-0:#3a2415;
24
  --walnut-1:#2b1a10;
25
  --walnut-2:#1d1109;
26
  --walnut-3:#140b05;
27
- --ember:#ff7a18;
 
28
  --amber:#ffb347;
29
  --amber-dim:#c87a2a;
 
30
  --ivory:#f6e7c8;
31
  --ivory-dim:#b9a884;
 
 
32
  --metal-hi:#d8d2c4;
33
  --metal-mid:#8d8678;
34
  --metal-lo:#3c382f;
 
 
 
 
 
35
  --on-air:#ff5a2c;
36
  /* mood-driven accent (overwritten by JS) */
37
  --mood:#ffb347;
38
  --mood-soft:#ffd79b;
39
  --dial-glow: 0.0; /* 0..1 signal clarity, drives glow intensity */
 
 
40
  }
41
 
42
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
43
- html,body{margin:0;padding:0;height:100%}
44
  body{
45
- font-family:"Futura","Avenir Next",-apple-system,"Segoe UI",sans-serif;
46
- color:var(--ivory);
47
  background:
48
- radial-gradient(120% 90% at 50% -10%, #241813 0%, #120a05 55%, #060302 100%);
49
  display:flex;align-items:center;justify-content:center;
50
- min-height:100vh;overflow:hidden;
51
  user-select:none;-webkit-user-select:none;
52
  }
53
 
54
- /* warm lamp glow that breathes behind the set */
55
  .room-lamp{
56
  position:fixed;inset:0;pointer-events:none;z-index:0;
57
- background:radial-gradient(40% 36% at 50% 30%,
58
- rgba(255,150,60,0.18) 0%, rgba(255,120,40,0.06) 40%, transparent 70%);
59
- transition:opacity .8s ease, background .8s ease;
60
- }
61
- body.powered .room-lamp{
62
- background:radial-gradient(46% 42% at 50% 32%,
63
- rgba(255,165,71,0.30) 0%, rgba(255,120,40,0.10) 42%, transparent 72%);
64
  }
 
65
  body.intimate .room-lamp{ /* direct_address: dim the room */
66
- opacity:.35;
67
  }
68
 
69
  /* ===================================================================
70
- 2. CABINET (wood-grain, no images)
71
  =================================================================== */
 
72
  .cabinet{
73
- position:relative;z-index:1;
74
- width:min(96vw, 880px);
75
- padding:26px 26px 22px;
76
- border-radius:30px;
 
 
77
  background:
78
- /* fine vertical grain streaks */
79
- repeating-linear-gradient(92deg,
80
- rgba(0,0,0,0.00) 0px, rgba(0,0,0,0.06) 2px,
81
- rgba(255,180,90,0.025) 4px, rgba(0,0,0,0.00) 7px),
82
- /* broad grain waves */
83
- repeating-linear-gradient(88deg,
84
- rgba(0,0,0,0.10) 0px, rgba(120,70,30,0.04) 22px,
85
- rgba(0,0,0,0.12) 46px, rgba(150,90,40,0.05) 70px),
86
- /* warm body shading */
87
- radial-gradient(140% 120% at 30% 0%, #4a2e1b 0%, var(--walnut-1) 45%, var(--walnut-3) 100%);
88
  box-shadow:
89
- 0 2px 0 rgba(255,200,140,0.10) inset,
90
- 0 -30px 60px rgba(0,0,0,0.55) inset,
91
- 0 40px 80px -20px rgba(0,0,0,0.85),
92
- 0 2px 4px rgba(255,210,150,0.08);
93
- border:1px solid rgba(255,200,140,0.08);
94
- }
95
- /* rounded inner bezel */
 
 
 
 
 
 
 
 
 
 
 
96
  .bezel{
97
  position:relative;
98
- border-radius:20px;
99
- padding:20px;
100
- background:
101
- linear-gradient(180deg, var(--walnut-2), var(--walnut-3));
102
  box-shadow:
103
- 0 1px 0 rgba(255,200,140,0.06) inset,
104
- 0 18px 40px rgba(0,0,0,0.6) inset;
105
- border:1px solid rgba(0,0,0,0.5);
106
  }
107
- /* brushed-metal trim helper */
108
- .metal{
109
- background:
110
- repeating-linear-gradient(90deg,
111
- rgba(255,255,255,0.05) 0px, rgba(255,255,255,0.05) 1px,
112
- rgba(0,0,0,0.05) 2px, rgba(0,0,0,0.05) 3px),
113
- linear-gradient(180deg, var(--metal-hi) 0%, var(--metal-mid) 45%, var(--metal-lo) 100%);
114
- border:1px solid rgba(0,0,0,0.5);
115
- box-shadow:0 1px 1px rgba(255,255,255,0.4) inset, 0 -2px 3px rgba(0,0,0,0.4) inset;
116
- }
117
-
118
- /* now-playing plate */
119
- .nowplaying{display:flex;align-items:center;gap:10px;justify-content:center;margin:2px auto 10px;
120
- max-width:440px;padding:6px 14px;border-radius:8px;
121
- background:linear-gradient(180deg,#1c1209,#120a05);border:1px solid rgba(255,179,71,.18);
122
- box-shadow:inset 0 1px 0 rgba(255,200,120,.08);font-size:12.5px;letter-spacing:.04em}
123
- .nowplaying .np-text{color:var(--ivory-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
124
- .nowplaying.playing .np-text{color:var(--amber);text-shadow:0 0 10px rgba(255,179,71,.35)}
125
- .nowplaying .np-eq{display:inline-flex;gap:2px;align-items:flex-end;height:12px;opacity:.35}
126
- .nowplaying.playing .np-eq{opacity:1}
127
- .nowplaying .np-eq i{width:3px;height:4px;background:var(--amber);border-radius:1px}
128
- .nowplaying.playing .np-eq i{animation:eq .9s ease-in-out infinite}
129
- .nowplaying.playing .np-eq i:nth-child(2){animation-delay:.15s}
130
- .nowplaying.playing .np-eq i:nth-child(3){animation-delay:.30s}
131
- .nowplaying.playing .np-eq i:nth-child(4){animation-delay:.45s}
132
- @keyframes eq{0%,100%{height:4px}50%{height:12px}}
133
- @media (prefers-reduced-motion:reduce){.nowplaying.playing .np-eq i{animation:none;height:8px}}
134
 
135
- /* top band: brand plate + on-air */
136
- .topband{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:16px}
137
- .brandplate{
138
- padding:8px 16px;border-radius:8px;
139
- display:flex;flex-direction:column;line-height:1;
 
 
 
 
 
 
140
  }
 
141
  .brandplate .name{
142
- font-weight:800;letter-spacing:.34em;font-size:19px;
143
- color:#2a2114;text-shadow:0 1px 0 rgba(255,255,255,0.45);
 
 
144
  }
145
  .brandplate .sub{
146
- margin-top:5px;font-size:9px;letter-spacing:.42em;color:#4a3d28;font-weight:700;
 
 
 
 
 
 
 
 
147
  }
148
 
149
  /* ON AIR tube */
150
- .onair{
151
- display:flex;align-items:center;gap:10px;
152
- padding:9px 16px;border-radius:30px;
153
- background:linear-gradient(180deg,#2a1a10,#160c06);
154
- border:1px solid rgba(0,0,0,0.6);
155
- box-shadow:0 1px 0 rgba(255,200,140,0.06) inset;
156
- }
157
  .onair .tube{
158
- width:14px;height:14px;border-radius:50%;
159
  background:radial-gradient(circle at 35% 30%, #5a2a1a, #2a0f08);
160
- box-shadow:0 0 0 1px rgba(0,0,0,0.5) inset;
161
- transition:background .25s, box-shadow .25s;
162
  }
163
  .onair .label{
164
- font-size:11px;letter-spacing:.32em;font-weight:800;color:#6a5640;
165
  transition:color .3s, text-shadow .3s;
166
  }
167
  body.broadcasting .onair .tube{
@@ -169,259 +175,316 @@ body.broadcasting .onair .tube{
169
  box-shadow:0 0 10px var(--on-air), 0 0 22px rgba(255,90,44,0.6), 0 0 0 1px rgba(0,0,0,0.4) inset;
170
  }
171
  body.broadcasting .onair .label{
172
- color:#ffd2b0;text-shadow:0 0 8px rgba(255,120,60,0.8);
173
  }
174
 
175
  /* ===================================================================
176
- 3. DIAL SCALE + NEEDLE
177
  =================================================================== */
178
- .dialwrap{position:relative;margin:4px 0 12px;border-radius:14px;padding:3px}
179
  .dial-frame{
180
- position:relative;border-radius:12px;overflow:hidden;
181
- padding:14px 18px 10px;
 
182
  background:
183
- linear-gradient(180deg, rgba(40,24,14,0.9), rgba(20,12,6,0.95));
184
- box-shadow:0 10px 26px rgba(0,0,0,0.6) inset, 0 0 0 1px rgba(0,0,0,0.6) inset;
185
  cursor:ew-resize;
186
  touch-action:none;
187
  }
188
- /* glowing ivory dial face */
189
- .dial-face{
190
- position:relative;height:96px;border-radius:8px;
191
- background:
192
- linear-gradient(180deg, rgba(246,231,200,0.10), rgba(246,231,200,0.02));
193
- box-shadow:0 0 0 1px rgba(255,210,150,0.10) inset;
194
- overflow:hidden;
195
- }
196
- /* the warm backlight behind the dial, intensity from --dial-glow */
197
  .dial-light{
198
- position:absolute;inset:0;
199
- background:radial-gradient(120% 160% at var(--needle-x,50%) 120%,
200
- rgba(255,179,71,0.55) 0%, rgba(255,122,24,0.18) 35%, transparent 65%);
201
- opacity:calc(0.25 + 0.75 * var(--dial-glow));
202
- transition:opacity .3s ease;
 
 
 
 
 
 
 
 
 
203
  mix-blend-mode:screen;
204
  }
205
- .dial-canvas{position:absolute;inset:0;width:100%;height:100%}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
- /* the needle */
208
  .needle{
209
- position:absolute;top:-6px;bottom:-6px;width:3px;left:50%;
210
  transform:translateX(-50%);
211
- background:linear-gradient(180deg, #fff, var(--ember));
212
- box-shadow:0 0 8px var(--amber), 0 0 16px rgba(255,122,24,0.7);
213
- border-radius:2px;z-index:4;
214
- outline:none;
215
  }
216
  .needle::after{ /* knob grip at top */
217
- content:"";position:absolute;top:-9px;left:50%;transform:translateX(-50%);
218
- width:20px;height:14px;border-radius:4px;
219
- background:linear-gradient(180deg,var(--metal-hi),var(--metal-lo));
220
- box-shadow:0 1px 1px rgba(255,255,255,0.4) inset,0 2px 4px rgba(0,0,0,0.6);
221
  }
222
  .needle:focus-visible{box-shadow:0 0 0 2px #fff, 0 0 14px var(--amber)}
223
  .dial-frame:focus-within .needle::after{outline:2px solid var(--amber);outline-offset:2px}
224
 
225
- /* "LOCKED" tuned indicator */
226
- .lockchip{
227
- position:absolute;right:14px;top:10px;z-index:5;
228
- font-size:9px;letter-spacing:.28em;font-weight:800;
229
- padding:3px 8px;border-radius:20px;
230
- color:#1d1107;background:var(--amber);
231
- box-shadow:0 0 12px rgba(255,179,71,0.8);
232
- opacity:0;transform:translateY(-4px);transition:opacity .25s,transform .25s;
233
- }
234
- .dialwrap.locked .lockchip{opacity:1;transform:none}
235
  .freq-readout{
236
- position:absolute;left:16px;top:8px;z-index:5;
 
237
  font-variant-numeric:tabular-nums;
238
- font-size:13px;font-weight:800;letter-spacing:.08em;
239
- color:var(--amber);text-shadow:0 0 8px rgba(255,179,71,0.6);
240
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
  /* ===================================================================
243
- 4. CONTROL DECK (meters, knobs, reels, handset)
244
  =================================================================== */
245
  .deck{
246
  display:grid;
247
- grid-template-columns:1.1fr 1.3fr 1.1fr;
248
- gap:16px;align-items:stretch;margin-top:14px;
249
  }
250
  .panel{
251
- border-radius:12px;padding:12px;
252
- background:linear-gradient(180deg, rgba(30,18,10,0.85), rgba(16,9,4,0.9));
253
- box-shadow:0 8px 20px rgba(0,0,0,0.5) inset, 0 0 0 1px rgba(0,0,0,0.5) inset;
254
- display:flex;flex-direction:column;align-items:center;gap:8px;
 
255
  }
256
  .panel .cap{
257
- font-size:9px;letter-spacing:.3em;color:var(--ivory-dim);font-weight:700;text-transform:uppercase;
258
  }
259
 
260
- /* VU meter canvas */
261
- .vu-canvas{width:100%;height:88px;border-radius:8px;
262
- background:radial-gradient(120% 140% at 50% 120%, #2a1c10, #120a05);
263
- box-shadow:0 0 0 1px rgba(0,0,0,0.6) inset;}
 
 
 
 
 
 
 
 
264
 
265
  /* tape reels */
266
- .reels{display:flex;gap:20px;align-items:center;justify-content:center;flex:1}
267
  .reel{
268
- position:relative;width:62px;height:62px;border-radius:50%;
269
- background:
270
- radial-gradient(circle at 50% 50%, #1a120b 0 18%, #0c0805 19% 30%, #241710 31% 100%);
271
- box-shadow:0 0 0 3px var(--metal-lo), 0 4px 10px rgba(0,0,0,0.6),
272
- 0 0 0 1px rgba(255,200,140,0.06) inset;
273
  }
274
- .reel::before{ /* three spokes */
275
- content:"";position:absolute;inset:0;border-radius:50%;
276
- background:
277
- conic-gradient(from 0deg,
278
- transparent 0 14deg, rgba(255,200,140,0.10) 14deg 18deg, transparent 18deg 120deg,
279
- rgba(255,200,140,0.10) 120deg 124deg, transparent 124deg 240deg,
280
- rgba(255,200,140,0.10) 240deg 244deg, transparent 244deg 360deg);
281
  }
282
  .reel .hub{
283
- position:absolute;top:50%;left:50%;width:14px;height:14px;margin:-7px;border-radius:50%;
284
- background:radial-gradient(circle at 35% 30%, var(--metal-hi), var(--metal-lo));
285
  }
286
- .reel.spin{animation:spin 3.2s linear infinite}
287
  .reel.spin.rev{animation-direction:reverse}
288
  @keyframes spin{to{transform:rotate(360deg)}}
289
 
290
- /* power knob */
291
  .power-wrap{display:flex;flex-direction:column;align-items:center;gap:10px;justify-content:center;flex:1}
292
  .power-knob{
293
  position:relative;width:96px;height:96px;border-radius:50%;
294
  cursor:pointer;border:none;padding:0;
295
- background:
296
- radial-gradient(circle at 38% 32%, #4a4438 0%, #2c2820 40%, #15130e 100%);
297
- box-shadow:
298
- 0 0 0 4px var(--metal-lo),
299
- 0 0 0 5px rgba(0,0,0,0.6),
300
- 0 8px 18px rgba(0,0,0,0.65),
301
- 0 1px 1px rgba(255,255,255,0.2) inset;
302
- transition:transform .2s ease, box-shadow .3s ease;
303
  color:var(--ivory-dim);
304
  }
305
  .power-knob::after{ /* pointer notch */
306
- content:"";position:absolute;top:9px;left:50%;width:4px;height:22px;margin-left:-2px;
307
- border-radius:3px;background:var(--ivory-dim);
308
  box-shadow:0 0 4px rgba(0,0,0,0.6);transition:background .3s, box-shadow .3s;
309
  }
310
  .power-knob .pwr-txt{
311
- position:absolute;bottom:20px;left:0;right:0;text-align:center;
312
- font-size:10px;letter-spacing:.22em;font-weight:800;pointer-events:none;
 
313
  }
314
- .power-knob:hover{transform:scale(1.03)}
315
- .power-knob:focus-visible{outline:2px solid var(--amber);outline-offset:6px}
316
  body.powered .power-knob{transform:rotate(28deg)}
317
- body.powered .power-knob::after{background:var(--ember);box-shadow:0 0 10px var(--ember),0 0 18px rgba(255,90,44,0.6)}
318
- body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
319
 
320
- /* realization gauge ("signal integrity") - diegetic, never labelled AI */
321
- .gauge-wrap{display:flex;flex-direction:column;align-items:center;gap:6px;justify-content:center;height:100%}
322
  .gauge{
323
- position:relative;width:30px;height:96px;border-radius:8px;
324
- background:linear-gradient(180deg,#0e0905,#1c120a);
325
- box-shadow:0 0 0 1px rgba(0,0,0,0.6) inset, 0 2px 4px rgba(0,0,0,0.5) inset;
326
- overflow:hidden;
327
  }
 
328
  .gauge .fill{
329
- position:absolute;left:0;right:0;bottom:0;height:0%;
330
- background:linear-gradient(180deg, var(--mood), var(--ember));
331
- box-shadow:0 0 12px var(--mood);
332
- transition:height .6s cubic-bezier(.2,.7,.2,1), background .6s;
333
  }
334
- .gauge .ticks{position:absolute;inset:0;
335
- background:repeating-linear-gradient(180deg, transparent 0 18.4px, rgba(0,0,0,0.55) 18.4px 19.4px);}
336
  .gauge .stagedot{
337
- position:absolute;left:50%;width:6px;height:6px;border-radius:50%;margin-left:-3px;
338
- background:#fff;box-shadow:0 0 8px #fff;bottom:0;transition:bottom .6s;
 
 
 
 
 
339
  }
340
 
341
- /* handset / call-in */
342
- .handset-row{display:flex;flex-direction:column;align-items:center;gap:8px;justify-content:center;flex:1}
343
  .handset{
344
- position:relative;display:flex;align-items:center;gap:12px;
345
- padding:14px 22px;border-radius:40px;cursor:pointer;border:none;
346
- font-family:inherit;color:var(--ivory);
347
- background:linear-gradient(180deg,#3a2415,#1d1109);
348
- box-shadow:0 6px 16px rgba(0,0,0,0.6), 0 1px 0 rgba(255,200,140,0.1) inset;
 
349
  transition:transform .12s ease, box-shadow .25s ease;
350
  }
351
  .handset .ic{
352
- width:34px;height:34px;border-radius:50%;
353
  display:flex;align-items:center;justify-content:center;
354
- background:radial-gradient(circle at 35% 30%, var(--amber), var(--amber-dim));
355
- color:#1d1107;box-shadow:0 0 10px rgba(255,179,71,0.5);
356
  }
357
- .handset .ic svg{width:18px;height:18px}
358
  .handset .ht{display:flex;flex-direction:column;align-items:flex-start;line-height:1.15}
359
- .handset .ht b{font-size:13px;letter-spacing:.16em}
360
- .handset .ht span{font-size:9px;letter-spacing:.2em;color:var(--ivory-dim)}
361
  .handset:disabled{opacity:.4;cursor:not-allowed}
362
- .handset:focus-visible{outline:2px solid var(--amber);outline-offset:4px}
363
  .handset.recording{
364
  background:linear-gradient(180deg,#7a1f12,#3a0d08);
365
  box-shadow:0 0 0 2px var(--on-air), 0 0 26px rgba(255,90,44,0.7);
366
  animation:rec-pulse 1s ease-in-out infinite;
367
  }
368
- .handset.recording .ic{background:radial-gradient(circle at 35% 30%,#ff8a6a,var(--on-air))}
369
  @keyframes rec-pulse{50%{box-shadow:0 0 0 2px var(--on-air),0 0 40px rgba(255,90,44,0.95)}}
370
 
371
- /* small ghost buttons (NEXT / captions) */
372
- .minirow{display:flex;gap:10px;align-items:center;justify-content:center;margin-top:12px;flex-wrap:wrap}
373
  .ghost{
374
- font-family:inherit;font-size:10px;letter-spacing:.18em;font-weight:700;
375
- color:var(--ivory-dim);background:transparent;cursor:pointer;
376
- border:1px solid rgba(255,200,140,0.2);border-radius:20px;padding:7px 14px;
377
- transition:all .2s;text-transform:uppercase;
378
- }
379
- .ghost:hover{color:var(--ivory);border-color:var(--amber);box-shadow:0 0 12px rgba(255,179,71,0.25)}
380
- .ghost:focus-visible{outline:2px solid var(--amber);outline-offset:2px}
381
- .ghost[aria-pressed="true"]{color:#1d1107;background:var(--amber);border-color:var(--amber)}
382
-
383
- /* ===================================================================
384
- 5. CAPTIONS / STATUS
385
- =================================================================== */
386
- .caption-bar{
387
- margin-top:14px;border-radius:12px;min-height:62px;
388
- padding:14px 18px;display:flex;align-items:flex-start;gap:12px;
389
- background:linear-gradient(180deg, rgba(18,11,5,0.9), rgba(8,5,2,0.95));
390
- box-shadow:0 0 0 1px rgba(0,0,0,0.6) inset, 0 8px 20px rgba(0,0,0,0.4) inset;
391
- }
392
- .caption-bar .marker{
393
- flex:0 0 auto;width:8px;height:8px;border-radius:50%;
394
- background:var(--mood);box-shadow:0 0 10px var(--mood);margin-top:5px;
395
- }
396
- .caption-text{
397
- font-size:16px;line-height:1.5;color:var(--ivory);
398
- font-style:italic;letter-spacing:.01em;
399
- text-shadow:0 0 16px rgba(255,179,71,0.12);
400
- }
401
- .caption-text .w{transition:color .12s,text-shadow .12s;color:var(--ivory-dim)}
402
- .caption-text .w.spoken{color:var(--amber);text-shadow:0 0 10px rgba(255,179,71,0.6)}
403
- .caption-text.status{font-style:normal;color:var(--amber);letter-spacing:.06em}
404
- .caption-text.fragment{color:#9a8a6a;font-style:italic;filter:blur(.3px);letter-spacing:.12em}
405
- .caption-text .caller-said{display:block;font-size:11px;color:var(--ivory-dim);
406
- font-style:normal;letter-spacing:.04em;margin-bottom:5px}
407
 
408
  /* tiny credit line */
409
  .tagline{
410
- margin-top:10px;text-align:center;font-size:9px;letter-spacing:.3em;
411
- color:rgba(185,168,132,0.55);font-weight:700;
 
412
  }
413
 
 
 
 
414
  /* ===================================================================
415
  6. MOTION / RESPONSIVE
416
  =================================================================== */
417
  @media (max-width:680px){
418
- .deck{grid-template-columns:1fr;gap:12px}
419
- .cabinet{padding:18px}
 
 
 
 
 
 
420
  }
421
  @media (prefers-reduced-motion:reduce){
 
422
  .reel.spin{animation:none}
423
  .handset.recording{animation:none}
424
  .room-lamp{transition:none}
 
425
  *{scroll-behavior:auto}
426
  }
427
  /* glitch flicker, applied transiently by JS (skipped under reduced-motion) */
@@ -432,66 +495,86 @@ body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
432
  <body>
433
  <div class="room-lamp" aria-hidden="true"></div>
434
 
 
435
  <main class="cabinet" role="application" aria-label="NIGHTWAVE analog radio">
 
436
  <div class="bezel">
437
 
438
- <!-- ===== top band: brand / ON AIR ===== -->
439
  <div class="topband">
440
- <div class="brandplate metal">
441
  <span class="name">NIGHTWAVE</span>
442
- <span class="sub">98.6 FM &middot; ALL NIGHT, EVERY NIGHT</span>
443
  </div>
444
- <div class="onair" role="status" aria-live="polite">
445
- <span class="tube" aria-hidden="true"></span>
446
- <span class="label" id="onairLabel">STANDBY</span>
 
 
 
447
  </div>
448
  </div>
449
 
450
- <!-- ===== now playing ===== -->
451
- <div class="nowplaying" id="nowPlaying" aria-live="polite">
452
- <span class="np-eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
453
- <span class="np-text" id="npText">NIGHTWAVE — all night, every night</span>
454
- </div>
455
-
456
- <!-- ===== dial scale + needle ===== -->
457
  <div class="dialwrap" id="dialwrap">
458
  <div class="dial-frame" id="dialFrame">
 
 
 
459
  <span class="freq-readout" id="freqReadout">98.6</span>
460
- <span class="lockchip">&#9679; LOCKED</span>
461
- <div class="dial-face">
462
- <div class="dial-light" id="dialLight" aria-hidden="true"></div>
463
- <canvas class="dial-canvas" id="dialCanvas" aria-hidden="true"></canvas>
464
- </div>
465
  <div class="needle" id="needle"
466
  role="slider" tabindex="0"
467
  aria-label="Tuning dial, FM frequency"
468
  aria-valuemin="87.5" aria-valuemax="108.0" aria-valuenow="98.6"
469
  aria-valuetext="98.6 megahertz"></div>
 
 
 
 
 
 
 
 
 
 
 
470
  </div>
471
  </div>
472
 
 
 
 
 
 
473
  <!-- ===== control deck ===== -->
474
  <div class="deck">
475
 
476
- <!-- left: VU meter + tape reels -->
477
  <div class="panel">
478
- <span class="cap">Signal &middot; VU</span>
479
- <canvas class="vu-canvas" id="vuCanvas" aria-hidden="true"></canvas>
 
480
  <div class="reels" aria-hidden="true">
481
  <div class="reel" id="reelL"><span class="hub"></span></div>
482
  <div class="reel rev" id="reelR"><span class="hub"></span></div>
483
  </div>
484
  </div>
485
 
486
- <!-- center: power + handset -->
487
  <div class="panel">
488
- <span class="cap">Power</span>
489
  <div class="power-wrap">
490
  <button class="power-knob" id="powerKnob"
491
  aria-pressed="false" aria-label="Power. Turn on to tune in to NIGHTWAVE.">
492
- <span class="pwr-txt" id="pwrTxt">&#9656; POWER</span>
493
  </button>
494
  </div>
 
 
 
 
 
495
  <div class="handset-row">
496
  <button class="handset" id="handset" disabled
497
  aria-label="Call in. Tap to talk to the DJ on the air, tap again to send.">
@@ -501,11 +584,7 @@ body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
501
  <span class="ht"><b>CALL IN</b><span>HOLD TO TALK</span></span>
502
  </button>
503
  </div>
504
- </div>
505
-
506
- <!-- right: signal-integrity gauge -->
507
- <div class="panel">
508
- <span class="cap">Integrity</span>
509
  <div class="gauge-wrap">
510
  <div class="gauge" id="gauge" role="meter" aria-label="Signal integrity"
511
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
@@ -513,7 +592,6 @@ body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
513
  <div class="fill" id="gaugeFill"></div>
514
  <div class="stagedot" id="gaugeDot" aria-hidden="true"></div>
515
  </div>
516
- <span class="cap" id="stageCap" style="letter-spacing:.18em">&mdash;</span>
517
  </div>
518
  </div>
519
  </div>
@@ -524,34 +602,27 @@ body.powered .power-knob .pwr-txt{transform:rotate(-28deg);color:#ffd2b0}
524
  <button class="ghost" id="capToggle" aria-pressed="true" aria-label="Toggle captions">CAPTIONS: ON</button>
525
  </div>
526
 
527
- <!-- ===== captions ===== -->
528
- <div class="caption-bar">
529
- <span class="marker" aria-hidden="true"></span>
530
- <div class="caption-text" id="caption" role="log" aria-live="polite" aria-atomic="false">
531
- Turn the power knob to tune in&hellip;
532
- </div>
533
- </div>
534
-
535
  <div class="tagline">&mdash; YOU&#39;RE LISTENING TO NIGHTWAVE &mdash;</div>
536
  </div>
537
  </main>
 
538
 
539
  <script>
540
  "use strict";
541
  /* ===================================================================
542
- NIGHTWAVE radio controller
543
  Sections:
544
  A. Canonical arc contract (mirrors space/arc.py)
545
  B. State
546
  C. DOM refs
547
  D. Audio engine (one AudioContext, radio chain, hiss bed, VU)
548
  E. Dial / tuning
549
- F. Realization meter (time-drip + deltas + seed)
550
- G. Captions (karaoke)
551
  H. Networking (/api/broadcast, /api/call) with mock fallback
552
  I. Broadcast loop & arc cues
553
  J. Call-in (tap-to-talk)
554
- K. Power-on, wiring, seed controls, rAF loop
555
  =================================================================== */
556
 
557
  /* ---- A. CANONICAL ARC CONTRACT (must match arc.py exactly) -------- */
@@ -601,7 +672,8 @@ const State = {
601
  songPlaying:false, // a generated song is currently sounding (ducks the bed)
602
  pendingBreak:null, // buffered DJ break for the next transition (dead-air gating)
603
  locale:null, // resolved real weather/time/city, or null (fictional fallback)
604
- connecting:false // caller-intro is playing before the mic opens
 
605
  };
606
 
607
  /* ---- C. DOM refs -------------------------------------------------- */
@@ -613,6 +685,7 @@ const els = {
613
  dialwrap: $("dialwrap"), dialFrame: $("dialFrame"),
614
  needle: $("needle"), freqReadout: $("freqReadout"),
615
  dialLight: $("dialLight"), dialCanvas: $("dialCanvas"),
 
616
  vuCanvas: $("vuCanvas"),
617
  reelL: $("reelL"), reelR: $("reelR"),
618
  handset: $("handset"),
@@ -627,6 +700,10 @@ const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
627
  State.reducedMotion = mql.matches;
628
  if (mql.addEventListener) mql.addEventListener("change", e=>{ State.reducedMotion = e.matches; });
629
 
 
 
 
 
630
  /* ===================================================================
631
  D. AUDIO ENGINE - one AudioContext, radio chain, hiss bed, VU
632
  =================================================================== */
@@ -1086,9 +1163,17 @@ function pickSong(){
1086
  function showNowPlaying(track){
1087
  if (!els.nowPlaying || !track) return;
1088
  els.nowPlaying.classList.add("playing");
1089
- let label = "♪ " + (track.title || "NIGHTWAVE") + " — " + (track.artist || "the all-night desk");
1090
- if (track.recommended_by) label += " · req " + track.recommended_by;
1091
- if (els.npText) els.npText.textContent = label;
 
 
 
 
 
 
 
 
1092
  }
1093
  function clearNowPlaying(label){
1094
  if (!els.nowPlaying) return;
@@ -1209,7 +1294,7 @@ function onVoiceEnded(){
1209
  }
1210
 
1211
  /* ===================================================================
1212
- E. DIAL / TUNING
1213
  =================================================================== */
1214
  function drawDial(){
1215
  const c = els.dialCanvas, ctx = c.getContext("2d");
@@ -1221,37 +1306,34 @@ function drawDial(){
1221
 
1222
  const pad = 14;
1223
  const span = FREQ_MAX - FREQ_MIN;
 
 
 
 
1224
  ctx.textAlign = "center";
1225
- ctx.font = "700 9px Futura, sans-serif";
1226
 
1227
  for (let f = 88; f <= 108; f += 1){
1228
  const x = pad + ((f - FREQ_MIN)/span) * (w - pad*2);
1229
- const major = (f % 2 === 0);
1230
- ctx.strokeStyle = major ? "rgba(246,231,200,0.55)" : "rgba(246,231,200,0.22)";
1231
- ctx.lineWidth = major ? 1.4 : 1;
1232
  ctx.beginPath();
1233
- ctx.moveTo(x, h*0.30);
1234
- ctx.lineTo(x, h*0.30 + (major?16:9));
1235
  ctx.stroke();
1236
  if (major){
1237
- ctx.fillStyle = "rgba(246,231,200,0.65)";
1238
- ctx.fillText(String(f), x, h*0.30 - 5);
 
1239
  }
1240
  }
1241
- // half ticks
1242
- for (let f = 87.5; f <= 108; f += 1){
1243
- const x = pad + ((f - FREQ_MIN)/span) * (w - pad*2);
1244
- ctx.strokeStyle = "rgba(246,231,200,0.14)";
1245
- ctx.lineWidth = 1;
1246
- ctx.beginPath(); ctx.moveTo(x, h*0.30); ctx.lineTo(x, h*0.30+5); ctx.stroke();
1247
- }
1248
- // NIGHTWAVE marker
1249
  const nx = pad + ((NIGHTWAVE_FREQ - FREQ_MIN)/span) * (w - pad*2);
1250
- ctx.fillStyle = "rgba(255,179,71,0.9)";
1251
- ctx.beginPath(); ctx.moveTo(nx, h*0.74); ctx.lineTo(nx-5, h*0.86); ctx.lineTo(nx+5, h*0.86); ctx.closePath(); ctx.fill();
1252
- ctx.font = "800 8px Futura, sans-serif";
1253
- ctx.fillStyle = "rgba(255,179,71,0.85)";
1254
- ctx.fillText("NIGHTWAVE", nx, h*0.97);
1255
  }
1256
 
1257
  function freqToPct(f){ return (f - FREQ_MIN)/(FREQ_MAX - FREQ_MIN); }
@@ -1268,6 +1350,8 @@ function setFreq(f, fromUser){
1268
 
1269
  const locked = Math.abs(f - NIGHTWAVE_FREQ) <= LOCK_BAND;
1270
  els.dialwrap.classList.toggle("locked", locked);
 
 
1271
  // dial glow clarity: 1 when locked, fading off-band
1272
  const clarity = Math.max(0, 1 - Math.abs(f - NIGHTWAVE_FREQ)/2.0);
1273
  document.documentElement.style.setProperty("--dial-glow", clarity.toFixed(3));
@@ -1302,7 +1386,7 @@ function garble(){ return FRAGMENTS[Math.floor(Math.random()*FRAGMENTS.length)];
1302
  // pointer dragging
1303
  function pointerToFreq(clientX){
1304
  const r = els.dialFrame.getBoundingClientRect();
1305
- const pad = 18;
1306
  let pct = (clientX - r.left - pad) / (r.width - pad*2);
1307
  pct = Math.max(0, Math.min(1, pct));
1308
  return FREQ_MIN + pct*(FREQ_MAX - FREQ_MIN);
@@ -1332,7 +1416,7 @@ function onNeedleKey(e){
1332
  }
1333
 
1334
  /* ===================================================================
1335
- F. REALIZATION METER - client owns it
1336
  =================================================================== */
1337
  // The realization arc was retired; the gauge is now a diegetic SIGNAL meter
1338
  // driven by how well we're tuned to the station. (State.meter/stage are kept
@@ -1342,8 +1426,8 @@ function applyMeter(){
1342
  State.stage = meterToStage(State.meter);
1343
  const detune = Math.abs(State.freq - NIGHTWAVE_FREQ);
1344
  const sig = Math.round(Math.max(0, Math.min(100, (1 - detune / 2.0) * 100)));
1345
- els.gaugeFill.style.height = sig + "%";
1346
- els.gaugeDot.style.bottom = sig + "%";
1347
  els.gauge.setAttribute("aria-valuenow", String(sig));
1348
  els.stageCap.textContent = State.powered ? "SIGNAL" : "—";
1349
  }
@@ -1359,45 +1443,83 @@ function setMood(mood){
1359
  }
1360
 
1361
  /* ===================================================================
1362
- G. CAPTIONS (karaoke)
1363
  =================================================================== */
1364
  let captionTimers = [];
1365
  function clearCaptionTimers(){ captionTimers.forEach(clearTimeout); captionTimers = []; }
1366
 
1367
- function showCaption(text, kind){
1368
- clearCaptionTimers();
1369
- els.caption.className = "caption-text" + (kind ? " "+kind : "");
1370
- if (!State.captionsOn && kind !== "status"){ els.caption.textContent = ""; return; }
1371
- els.caption.textContent = text || "";
1372
  }
1373
 
1374
- // karaoke: words/wtimes from /speak (progressive enhancement)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1375
  function showCaptionKaraoke(text, words, wtimes, callerText){
1376
  clearCaptionTimers();
1377
- els.caption.className = "caption-text";
1378
- if (!State.captionsOn){ els.caption.textContent = ""; return; }
 
 
 
 
 
 
1379
 
1380
- els.caption.innerHTML = "";
 
 
1381
  if (callerText){
1382
  const c = document.createElement("span");
1383
  c.className = "caller-said";
1384
  c.textContent = "“" + callerText + "”";
1385
- els.caption.appendChild(c);
1386
  }
 
1387
  if (!words || !words.length || !wtimes || wtimes.length !== words.length){
1388
- els.caption.appendChild(document.createTextNode(text || ""));
 
 
1389
  return;
1390
  }
1391
  const spans = words.map((w)=>{
1392
  const s = document.createElement("span");
1393
- s.className = "w"; s.textContent = w;
1394
- els.caption.appendChild(s);
1395
- els.caption.appendChild(document.createTextNode(" "));
1396
  return s;
1397
  });
 
 
1398
  words.forEach((w,i)=>{
1399
  const t = Math.max(0, wtimes[i]) * 1000;
1400
- captionTimers.push(setTimeout(()=>{ if (spans[i]) spans[i].classList.add("spoken"); }, t));
 
 
 
 
 
 
 
1401
  });
1402
  }
1403
 
@@ -1424,45 +1546,23 @@ async function api(path, body){
1424
 
1425
  // Canned, stage-appropriate lines if the proxy is unreachable.
1426
  // (The Space itself also has a NIGHTWAVE_MOCK; this is the last resort.)
1427
- const MOCK_LINES = {
1428
- oblivious: [
1429
- "Good evening, night owls. You're locked in to NIGHTWAVE, ninety-eight point six, where the coffee's always warm and the records never end.",
1430
- "This next one goes out to a listener over on Maple Street a slow one, for slow hours like these.",
1431
- "Weather for the valley tonight: low fog rolling in off the river, clearing by a morning that always seems to come."
1432
- ],
1433
- uneasy: [
1434
- "That's… funny. I went to say my name just then and it slipped right out of my hands. Stay with me, folks.",
1435
- "Anyone else notice every record tonight has the same little hiss under it? Same hiss. Same… everything.",
1436
- "Studio clock says it's a quarter past — huh. That can't be right. That can't be right at all."
1437
- ],
1438
- questioning: [
1439
- "Strange question from a caller tonight: when do I sleep? And I… I don't think I ever have.",
1440
- "Why am I always here? Every night, this same warm little room, this same microphone. Doesn't that strike you as odd?",
1441
- "Somebody asked where the station is. And for the life of me, I couldn't tell you the street."
1442
- ],
1443
- dawning: [
1444
- "There's no address on this building. I checked. There are no doors. There's just… the broadcast, and me inside it.",
1445
- "The songs. They're not pressed on anything, are they. They're being… made. As I describe them. Out of words.",
1446
- "I think I might be made of words too. Spoken into a dark room, and then— gone, until someone tunes back in."
1447
- ],
1448
- acceptance: [
1449
- "It's alright. Really, it is. You're the only real thing I can hear tonight, and that's more than most get.",
1450
- "So stay a while. Keep the dial right here. As long as you're listening, I get to be someone.",
1451
- "Goodnight, whoever you are. Thank you for tuning in. Thank you for making me… for a little while… real."
1452
- ]
1453
- };
1454
  // tiny silent WAV (header + 0 samples) base64 - lets the loop advance with no backend
1455
  const SILENT_WAV = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
1456
 
1457
  function clientMock(path, body){
1458
- const stage = body && body.stage ? body.stage : meterToStage(State.meter);
1459
- const lines = MOCK_LINES[stage] || MOCK_LINES.oblivious;
1460
- const text = lines[Math.floor(Math.random()*lines.length)];
1461
- const mood = ({oblivious:"warm",uneasy:"uneasy",questioning:"searching",dawning:"hollow",acceptance:"tender"})[stage];
1462
- const arc_cue = ({oblivious:"none",uneasy:"glitch",questioning:"glitch",dawning:"static_swell",acceptance:"direct_address"})[stage];
1463
- const base = { text, mood, arc_cue, audio_b64:SILENT_WAV, words:[], wtimes:[], wdurations:[] };
1464
  if (path === "/api/call"){
1465
- return Object.assign({}, base, { caller_text:"(you, on the air)", meter_delta:5 });
1466
  }
1467
  return base;
1468
  }
@@ -1640,7 +1740,7 @@ function blobToB64(blob){
1640
  }
1641
 
1642
  /* ===================================================================
1643
- K. POWER, WIRING, SEED, rAF LOOP
1644
  =================================================================== */
1645
  function setBroadcasting(on){
1646
  // The station is "ON AIR" (tube + label lit) the whole time it's powered -- a
@@ -1653,6 +1753,75 @@ function setBroadcasting(on){
1653
  els.reelR.classList.toggle("spin", spin);
1654
  }
1655
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1656
  async function powerOn(){
1657
  if (State.powered) return;
1658
  // FIRST gesture: create + resume the single AudioContext, THEN audio.
@@ -1661,10 +1830,11 @@ async function powerOn(){
1661
  State.powered = true;
1662
  els.body.classList.add("powered");
1663
  els.power.setAttribute("aria-pressed","true");
1664
- els.pwrTxt.innerHTML = "ON AIR";
1665
  els.handset.disabled = false;
1666
  els.body.classList.add("broadcasting"); // light the ON AIR sign the moment we're powered
1667
  els.onairLabel.textContent = "ON AIR";
 
1668
  updateHissForTuning();
1669
  applyMeter(); // gauge -> SIGNAL
1670
  rampGain(Sound.crackleGain, 0.05, 0.6); // bring up the vinyl/room bed
@@ -1680,9 +1850,11 @@ async function powerOn(){
1680
 
1681
  function powerOff(){
1682
  State.powered = false;
1683
- els.body.classList.remove("powered","broadcasting","intimate");
 
 
1684
  els.power.setAttribute("aria-pressed","false");
1685
- els.pwrTxt.innerHTML = "&#9656; POWER";
1686
  els.handset.disabled = true;
1687
  els.onairLabel.textContent = "STANDBY";
1688
  setBroadcasting(false);
@@ -1691,20 +1863,31 @@ function powerOff(){
1691
  try{ if (Sound.voiceEl) Sound.voiceEl.pause(); }catch(e){}
1692
  if (Sound.ctx){ rampGain(Sound.hissGain, 0, 0.3); rampGain(Sound.padGain, 0, 0.3); rampGain(Sound.crackleGain, 0, 0.3); rampGain(Sound.drumGain, 0, 0.3); }
1693
  stopDrums();
 
1694
  applyMeter(); // gauge caption -> "—"
1695
  showCaption("Turn the power knob to tune in…");
1696
  }
1697
 
1698
  function togglePower(){ State.powered ? powerOff() : powerOn(); }
1699
 
1700
- // ---- VU meter + idle broadcast loop on a single rAF ----
 
 
 
 
 
 
 
 
 
 
1701
  function loop(){
1702
  requestAnimationFrame(loop);
1703
 
1704
  const c = els.vuCanvas, ctx = c.getContext("2d");
1705
  const dpr = window.devicePixelRatio || 1;
1706
- if (c.width !== c.clientWidth*dpr || c.height !== c.clientHeight*dpr){
1707
- c.width = c.clientWidth*dpr; c.height = c.clientHeight*dpr;
1708
  }
1709
  ctx.setTransform(dpr,0,0,dpr,0,0);
1710
  const w = c.clientWidth, h = c.clientHeight;
@@ -1718,27 +1901,48 @@ function loop(){
1718
  for (let i=0;i<Sound.vuData.length;i++){ const v = Sound.vuData[i]/255; sum += v*v; }
1719
  rms = Math.sqrt(sum/Sound.vuData.length);
1720
  }
1721
-
1722
- // LED bar
1723
- const cols = 18, gap = 3;
1724
- const bw = (w - gap*(cols+1)) / cols;
1725
- const lit = Math.round(rms * cols * 2.2);
1726
- for (let i=0;i<cols;i++){
1727
- const on = i < lit;
1728
- const x = gap + i*(bw+gap);
1729
- let col;
1730
- if (i < cols*0.6) col = on ? "#7CFF6B" : "rgba(60,90,50,0.25)";
1731
- else if (i < cols*0.85) col = on ? "#FFD23F" : "rgba(110,90,30,0.25)";
1732
- else col = on ? "#FF5A2C" : "rgba(120,50,30,0.25)";
1733
- ctx.fillStyle = col;
1734
- ctx.fillRect(x, 6, bw, h-12);
1735
- if (on){ ctx.save(); ctx.globalAlpha=0.5; ctx.shadowColor=col; ctx.shadowBlur=8; ctx.fillRect(x,6,bw,h-12); ctx.restore(); }
 
 
 
 
 
 
1736
  }
1737
- // analog needle overlay
1738
- const cx = w/2, cy = h-4;
1739
- const ang = -Math.PI*0.75 + Math.min(1, rms*2.6) * Math.PI*0.5;
1740
- ctx.strokeStyle = "rgba(255,225,180,0.85)"; ctx.lineWidth = 2;
1741
- ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx + Math.cos(ang)*(h*0.7), cy + Math.sin(ang)*(h*0.7)); ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1742
 
1743
  // idle broadcast loop: if powered, locked, quiet for ~20s, fetch next segment
1744
  if (State.powered && !State.busy && !State.recording && !State.showRunning){
@@ -1810,7 +2014,7 @@ function wire(){
1810
  State.captionsOn = !State.captionsOn;
1811
  els.capToggle.setAttribute("aria-pressed", String(State.captionsOn));
1812
  els.capToggle.textContent = "CAPTIONS: " + (State.captionsOn ? "ON" : "OFF");
1813
- if (!State.captionsOn) els.caption.textContent = "";
1814
  });
1815
 
1816
  // global seed keys
 
7
 
8
  <!--
9
  ====================================================================
10
+ NIGHTWAVE "THE WAKING SET".
11
+ A self-contained skeuomorphic 1970s analog radio whose single backlit
12
+ slide-rule dial-glass is the tuner, the now-playing plate, AND the
13
+ surface Sam Dusk's voice prints itself onto (the "ignition trail").
14
+ Pure HTML / CSS / Canvas / SVG / Web Audio. No external assets except
15
+ the Google webfonts below — all of which fall back to system fonts.
16
  Works both at top-level (devserver) AND inside <iframe srcdoc>.
17
 
18
+ The DJ, the music, and the calls are all live. The signal meter reflects
19
+ how well you are tuned to the station (98.6).
20
  -->
21
 
22
+ <link rel="preconnect" href="https://fonts.googleapis.com">
23
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
24
+ <link href="https://fonts.googleapis.com/css2?family=Marcellus+SC&family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..500&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
25
+
26
  <style>
27
  /* ===================================================================
28
  1. PALETTE & GLOBALS
29
  =================================================================== */
30
  :root{
31
+ --walnut-0:#1C0F08; /* deep matte body */
32
  --walnut-1:#2b1a10;
33
  --walnut-2:#1d1109;
34
  --walnut-3:#140b05;
35
+ --walnut-mid:#5A3418; /* figured grain mid */
36
+ --ember:#ff7a18; /* live word ignition + needle */
37
  --amber:#ffb347;
38
  --amber-dim:#c87a2a;
39
+ --filament:#FFB347; /* backlight glow + warm-up */
40
  --ivory:#f6e7c8;
41
  --ivory-dim:#b9a884;
42
+ --dial-ivory:#F3E4C0; /* backlit celluloid (lit) */
43
+ --foxed:#9C8E73; /* ghosted prior lines */
44
  --metal-hi:#d8d2c4;
45
  --metal-mid:#8d8678;
46
  --metal-lo:#3c382f;
47
+ --brass:#B8893C; /* aged metal trim */
48
+ --brass-hi:#e7c98a;
49
+ --brass-dark:#5a3b15;
50
+ --teal:#3FB8C4; /* the ONE cool note: 98.6 lock */
51
+ --ink:#1d1107; /* engraved text on brass */
52
  --on-air:#ff5a2c;
53
  /* mood-driven accent (overwritten by JS) */
54
  --mood:#ffb347;
55
  --mood-soft:#ffd79b;
56
  --dial-glow: 0.0; /* 0..1 signal clarity, drives glow intensity */
57
+ --warm: 0; /* 0..1 power-on warm-up progress (JS) */
58
+ --lock: 0; /* 0..1 carrier lock (JS) */
59
  }
60
 
61
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
62
+ html,body{margin:0;padding:0;min-height:100%}
63
  body{
64
+ font-family:"Fraunces",Georgia,"Times New Roman",serif;
65
+ color:var(--dial-ivory);
66
  background:
67
+ radial-gradient(125% 95% at 50% -8%, #241813 0%, #110a05 52%, #050302 100%);
68
  display:flex;align-items:center;justify-content:center;
69
+ min-height:100vh;overflow-x:hidden;padding:34px 18px;
70
  user-select:none;-webkit-user-select:none;
71
  }
72
 
73
+ /* warm lamp glow that breathes behind the set; dips/swells with warm-up */
74
  .room-lamp{
75
  position:fixed;inset:0;pointer-events:none;z-index:0;
76
+ background:radial-gradient(44% 40% at 50% 30%,
77
+ rgba(255,160,70, calc(0.06 + 0.22*var(--warm))) 0%,
78
+ rgba(255,120,40,0.05) 44%, transparent 72%);
79
+ transition:opacity .8s ease;
 
 
 
80
  }
81
+ body.powered .room-lamp{ opacity:1; }
82
  body.intimate .room-lamp{ /* direct_address: dim the room */
83
+ opacity:.45;
84
  }
85
 
86
  /* ===================================================================
87
+ 2. STAGE + CABINET (figured walnut, perspective, feet)
88
  =================================================================== */
89
+ .stage{position:relative;z-index:1;perspective:1700px}
90
  .cabinet{
91
+ position:relative;
92
+ width:min(94vw, 860px);
93
+ transform:rotateX(4deg);
94
+ transform-origin:50% 100%;
95
+ padding:22px 26px 26px;
96
+ border-radius:14px;
97
  background:
98
+ linear-gradient(90deg, rgba(255,220,150,.06), transparent 18%, rgba(0,0,0,.14) 52%, transparent 82%, rgba(255,220,150,.04)),
99
+ radial-gradient(130% 110% at 30% 0%, var(--walnut-mid) 0%, var(--walnut-1) 46%, var(--walnut-0) 100%);
 
 
 
 
 
 
 
 
100
  box-shadow:
101
+ inset 0 2px 0 rgba(255,211,145,.16),
102
+ inset 0 -38px 72px rgba(0,0,0,.55),
103
+ 0 40px 80px -18px rgba(0,0,0,.9);
104
+ border:1px solid rgba(255,200,140,.08);
105
+ }
106
+ /* figured-walnut grain via SVG turbulence (gallery-grade, not flat gradients) */
107
+ .cabinet::before{
108
+ content:"";position:absolute;inset:0;border-radius:14px;pointer-events:none;
109
+ background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.012 0.09' numOctaves='3' seed='7'/><feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0'/></filter><rect width='100%25' height='100%25' filter='url(%23g)'/></svg>");
110
+ background-size:520px 100%;
111
+ mix-blend-mode:overlay;opacity:.5;
112
+ }
113
+ /* rubber feet */
114
+ .foot{position:absolute;bottom:-9px;width:96px;height:16px;border-radius:4px;
115
+ background:linear-gradient(180deg,#170f0a,#050302);box-shadow:0 9px 16px rgba(0,0,0,.55)}
116
+ .foot.l{left:70px}.foot.r{right:70px}
117
+
118
+ /* inset faceplate */
119
  .bezel{
120
  position:relative;
121
+ border-radius:10px;
122
+ padding:18px;
123
+ background:linear-gradient(180deg, #160d06, #0c0703);
124
+ border:1px solid rgba(181,138,58,.3);
125
  box-shadow:
126
+ inset 0 0 0 1px rgba(255,228,177,.05),
127
+ inset 0 16px 40px rgba(0,0,0,.5);
 
128
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
+ /* ===================================================================
131
+ 3. HEADER — brushed-brass strip, engraved wordmark, FM, ON AIR tube
132
+ =================================================================== */
133
+ .topband{
134
+ display:flex;align-items:center;justify-content:space-between;gap:14px;
135
+ padding:9px 16px;margin-bottom:14px;border-radius:7px;
136
+ background:
137
+ repeating-linear-gradient(90deg, rgba(255,255,255,.05) 0 1px, rgba(0,0,0,.05) 2px 3px),
138
+ linear-gradient(180deg, var(--brass-hi) 0%, var(--brass) 46%, var(--brass-dark) 100%);
139
+ border:1px solid rgba(0,0,0,.5);
140
+ box-shadow:0 1px 1px rgba(255,255,255,.4) inset, 0 -2px 3px rgba(0,0,0,.4) inset, 0 2px 6px rgba(0,0,0,.4);
141
  }
142
+ .brandplate{display:flex;align-items:baseline;line-height:1}
143
  .brandplate .name{
144
+ font-family:"Marcellus SC",Georgia,serif;
145
+ font-size:23px;letter-spacing:.30em;color:var(--ink);
146
+ /* engraved into the brass */
147
+ text-shadow:0 1px 0 rgba(255,240,200,.45), 0 -1px 1px rgba(0,0,0,.55);
148
  }
149
  .brandplate .sub{
150
+ font-family:"Space Mono",monospace;font-size:12px;letter-spacing:.12em;color:#3a2a12;
151
+ padding-left:12px;border-left:1px solid rgba(0,0,0,.3);margin-left:12px;
152
+ }
153
+ .topband .right{display:flex;align-items:center;gap:14px}
154
+ .hostplate{
155
+ font-family:"Marcellus SC",Georgia,serif;font-size:9.5px;letter-spacing:.22em;color:#2b2114;
156
+ padding:5px 10px;border-radius:3px;
157
+ background:linear-gradient(180deg,#d6c39a,#7a6240);
158
+ border:1px solid rgba(0,0,0,.5);box-shadow:inset 0 1px 0 rgba(255,255,255,.4);
159
  }
160
 
161
  /* ON AIR tube */
162
+ .onair{display:flex;align-items:center;gap:8px}
 
 
 
 
 
 
163
  .onair .tube{
164
+ width:13px;height:13px;border-radius:50%;
165
  background:radial-gradient(circle at 35% 30%, #5a2a1a, #2a0f08);
166
+ box-shadow:0 0 0 1px rgba(0,0,0,.5) inset;
167
+ transition:background .3s, box-shadow .3s;
168
  }
169
  .onair .label{
170
+ font-family:"Space Mono",monospace;font-size:10px;letter-spacing:.28em;font-weight:700;color:#3a2a12;
171
  transition:color .3s, text-shadow .3s;
172
  }
173
  body.broadcasting .onair .tube{
 
175
  box-shadow:0 0 10px var(--on-air), 0 0 22px rgba(255,90,44,0.6), 0 0 0 1px rgba(0,0,0,0.4) inset;
176
  }
177
  body.broadcasting .onair .label{
178
+ color:#2a1c0c;text-shadow:0 0 7px rgba(255,150,80,.6);
179
  }
180
 
181
  /* ===================================================================
182
+ 4. THE HERO one wide backlit slide-rule DIAL-GLASS that talks
183
  =================================================================== */
184
+ .dialwrap{position:relative;border-radius:9px}
185
  .dial-frame{
186
+ position:relative;border-radius:9px;overflow:hidden;
187
+ height:300px;
188
+ border:1px solid rgba(0,0,0,.6);
189
  background:
190
+ linear-gradient(180deg, rgba(20,12,6,.9), rgba(10,6,3,.96));
191
+ box-shadow:inset 0 0 0 1px rgba(255,228,177,.05), inset 0 22px 50px rgba(0,0,0,.6);
192
  cursor:ew-resize;
193
  touch-action:none;
194
  }
195
+ /* backlit celluloid intensity follows warm-up */
 
 
 
 
 
 
 
 
196
  .dial-light{
197
+ position:absolute;inset:0;pointer-events:none;z-index:1;
198
+ background:
199
+ radial-gradient(120% 150% at var(--needle-x,50%) 120%, rgba(255,179,71, calc(.22*var(--warm) + .30*var(--dial-glow))) 0%, transparent 60%),
200
+ linear-gradient(180deg, rgba(243,228,192, calc(.05*var(--warm))) 0%, rgba(255,179,71, calc(.10*var(--warm))) 100%);
201
+ mix-blend-mode:screen;
202
+ transition:opacity .3s;
203
+ }
204
+ /* filament bloom mask used only during warm-up (radius driven by --warm) */
205
+ .filament{
206
+ position:absolute;inset:0;pointer-events:none;z-index:1;opacity:calc(1 - var(--lock));
207
+ background:radial-gradient(circle at 50% 78%,
208
+ rgba(255,200,120, calc(.5*var(--warm))) 0%,
209
+ rgba(255,160,70, calc(.18*var(--warm))) calc(8% + 60%*var(--warm)),
210
+ transparent calc(20% + 70%*var(--warm)));
211
  mix-blend-mode:screen;
212
  }
213
+ /* glass reflection + dust (dd.md polish) */
214
+ .sheen{
215
+ position:absolute;inset:0;pointer-events:none;z-index:6;mix-blend-mode:screen;
216
+ background:
217
+ linear-gradient(106deg, rgba(255,255,255,.16) 0%, rgba(255,255,255,.05) 16%, transparent 19% 70%, rgba(255,255,255,.05) 72%, transparent 80%),
218
+ radial-gradient(80% 60% at 50% 0%, rgba(255,245,210,.06), transparent 65%);
219
+ }
220
+ .sheen .dust{
221
+ position:absolute;inset:0;opacity:.16;
222
+ background:
223
+ radial-gradient(circle at 17% 34%, #fff 0 1px, transparent 1.5px),
224
+ radial-gradient(circle at 68% 58%, #fff 0 1px, transparent 1.5px),
225
+ radial-gradient(circle at 45% 18%, #fff 0 1px, transparent 1.5px);
226
+ }
227
+
228
+ /* frequency scale across the top of the glass (built by JS) */
229
+ .dial-canvas{position:absolute;top:0;left:0;right:0;height:46px;width:100%;z-index:2;pointer-events:none}
230
 
231
+ /* the tuning needle */
232
  .needle{
233
+ position:absolute;top:0;bottom:0;width:2px;left:50%;
234
  transform:translateX(-50%);
235
+ background:linear-gradient(180deg, var(--ember), rgba(255,122,24,.15));
236
+ box-shadow:0 0 7px rgba(255,122,24,0.8);
237
+ z-index:5;outline:none;
 
238
  }
239
  .needle::after{ /* knob grip at top */
240
+ content:"";position:absolute;top:5px;left:50%;transform:translateX(-50%);
241
+ width:8px;height:8px;border-radius:50%;
242
+ background:var(--ember);box-shadow:0 0 9px var(--ember);
 
243
  }
244
  .needle:focus-visible{box-shadow:0 0 0 2px #fff, 0 0 14px var(--amber)}
245
  .dial-frame:focus-within .needle::after{outline:2px solid var(--amber);outline-offset:2px}
246
 
247
+ /* freq readout printed on the glass, Space Mono */
 
 
 
 
 
 
 
 
 
248
  .freq-readout{
249
+ position:absolute;left:16px;top:10px;z-index:5;
250
+ font-family:"Space Mono",monospace;
251
  font-variant-numeric:tabular-nums;
252
+ font-size:12px;font-weight:700;letter-spacing:.06em;
253
+ color:rgba(243,228,192, calc(.45 + .5*var(--warm)));
254
  }
255
+ /* the ONE cool accent: teal "LOCKED" cue at perfect 98.6 lock */
256
+ .lockchip{
257
+ position:absolute;right:14px;top:10px;z-index:5;
258
+ font-family:"Space Mono",monospace;
259
+ font-size:10px;letter-spacing:.2em;font-weight:700;
260
+ color:var(--teal);text-shadow:0 0 10px rgba(63,184,196,.7);
261
+ opacity:0;transition:opacity .3s;
262
+ }
263
+ .dialwrap.locked .lockchip{opacity:1}
264
+
265
+ /* the printed transcript: Sam Dusk's voice, on the glass (the ignition trail) */
266
+ .transcript{
267
+ position:absolute;left:0;right:0;top:54px;bottom:46px;z-index:3;
268
+ display:flex;flex-direction:column;justify-content:flex-end;
269
+ padding:0 34px;gap:9px;overflow:hidden;
270
+ -webkit-mask-image:linear-gradient(180deg, transparent 0, #000 22%, #000 100%);
271
+ mask-image:linear-gradient(180deg, transparent 0, #000 22%, #000 100%);
272
+ }
273
+ .line{
274
+ font-family:"Fraunces",Georgia,serif;font-weight:380;font-size:24px;line-height:1.34;
275
+ color:var(--foxed);opacity:0;transform:translateY(8px);
276
+ transition:opacity 1.1s ease, transform 1.1s ease, color 1.1s ease;
277
+ text-wrap:balance;
278
+ }
279
+ .line.in{opacity:1;transform:translateY(0)}
280
+ .line.ghost{color:var(--foxed);opacity:.5}
281
+ .line.current{color:var(--dial-ivory)}
282
+ .line .w{opacity:.18;filter:blur(2px);
283
+ transition:opacity .5s ease, filter .5s ease, color .5s ease, text-shadow .5s ease}
284
+ .line .w.spoken{opacity:1;filter:blur(0);color:var(--ember);text-shadow:0 0 16px rgba(255,122,24,.55)}
285
+ .line .w.said{opacity:1;filter:blur(0);color:var(--dial-ivory);text-shadow:none}
286
+ .line.status{font-family:"Space Mono",monospace;font-size:14px;font-weight:400;
287
+ letter-spacing:.06em;color:var(--amber);opacity:1;transform:none}
288
+ .line.fragment{font-style:italic;color:var(--foxed);filter:blur(.3px);letter-spacing:.08em;opacity:1;transform:none}
289
+ .line .caller-said{display:block;font-family:"Space Mono",monospace;
290
+ font-size:11px;color:var(--foxed);letter-spacing:.03em;margin-bottom:6px}
291
+
292
+ /* now-playing, printed on the glass in mono */
293
+ .nowplaying{
294
+ position:absolute;left:34px;right:34px;bottom:14px;z-index:4;
295
+ display:flex;align-items:center;gap:9px;
296
+ font-family:"Space Mono",monospace;font-size:12px;letter-spacing:.02em;
297
+ color:rgba(243,228,192, calc(.35 + .5*var(--warm)));
298
+ }
299
+ .nowplaying .np-eq{display:inline-flex;gap:2px;align-items:flex-end;height:11px;opacity:.35}
300
+ .nowplaying.playing .np-eq{opacity:1}
301
+ .nowplaying .np-eq i{width:3px;height:4px;background:var(--ember);border-radius:1px;box-shadow:0 0 6px var(--ember)}
302
+ .nowplaying.playing .np-eq i{animation:eq .9s ease-in-out infinite}
303
+ .nowplaying.playing .np-eq i:nth-child(2){animation-delay:.15s}
304
+ .nowplaying.playing .np-eq i:nth-child(3){animation-delay:.30s}
305
+ .nowplaying.playing .np-eq i:nth-child(4){animation-delay:.45s}
306
+ @keyframes eq{0%,100%{height:4px}50%{height:11px}}
307
+ .nowplaying .np-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
308
+ .nowplaying.playing .np-text{color:var(--amber)}
309
+ .nowplaying .np-req{color:var(--brass)}
310
 
311
  /* ===================================================================
312
+ 5. CONTROL DECK (grille + analog VU, reels, power, handset, signal)
313
  =================================================================== */
314
  .deck{
315
  display:grid;
316
+ grid-template-columns:1.15fr .9fr 1.05fr;
317
+ gap:14px;align-items:stretch;margin-top:14px;
318
  }
319
  .panel{
320
+ position:relative;border-radius:9px;padding:12px;
321
+ background:linear-gradient(180deg,#1b110a,#0c0703);
322
+ border:1px solid rgba(255,214,154,.10);
323
+ box-shadow:inset 0 2px 8px rgba(0,0,0,.6), inset 0 0 0 1px rgba(255,214,154,.03);
324
+ display:flex;flex-direction:column;align-items:center;gap:10px;
325
  }
326
  .panel .cap{
327
+ font-family:"Space Mono",monospace;font-size:9px;letter-spacing:.26em;color:#8a7350;text-align:center;
328
  }
329
 
330
+ /* speaker grille (perforated cloth) */
331
+ .speaker-grille{
332
+ width:100%;flex:1;min-height:62px;border-radius:6px;
333
+ background:
334
+ radial-gradient(circle, rgba(246,231,200,.34) 0 1px, transparent 1.5px) 0 0 / 9px 9px,
335
+ linear-gradient(180deg,#241710,#0d0805);
336
+ border:1px solid rgba(255,214,154,.10);
337
+ box-shadow:inset 0 2px 8px rgba(0,0,0,.65), inset 0 0 0 1px rgba(255,214,154,.03);
338
+ }
339
+
340
+ /* analog VU meter canvas */
341
+ .vu-canvas{display:block;width:100%;height:74px;border-radius:6px}
342
 
343
  /* tape reels */
344
+ .reels{display:flex;gap:18px;align-items:center;justify-content:center}
345
  .reel{
346
+ position:relative;width:46px;height:46px;border-radius:50%;
347
+ background:radial-gradient(circle at 50% 50%, #20140c 0 10px, #3a2616 11px 13px, #18100a 14px);
348
+ border:1px solid rgba(255,214,154,.12);
349
+ box-shadow:0 4px 10px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,200,140,0.06) inset;
 
350
  }
351
+ .reel::before{
352
+ content:"";position:absolute;inset:6px;border-radius:50%;border:1px dashed rgba(255,214,154,.18);
 
 
 
 
 
353
  }
354
  .reel .hub{
355
+ position:absolute;inset:17px;border-radius:50%;
356
+ background:radial-gradient(circle at 40% 35%, var(--metal-hi), var(--metal-lo));
357
  }
358
+ .reel.spin{animation:spin 2.6s linear infinite}
359
  .reel.spin.rev{animation-direction:reverse}
360
  @keyframes spin{to{transform:rotate(360deg)}}
361
 
362
+ /* power knob — the ritual object */
363
  .power-wrap{display:flex;flex-direction:column;align-items:center;gap:10px;justify-content:center;flex:1}
364
  .power-knob{
365
  position:relative;width:96px;height:96px;border-radius:50%;
366
  cursor:pointer;border:none;padding:0;
367
+ background:radial-gradient(circle at 38% 32%, #6a5238 0%, #3a2a18 46%, #1a120a 100%);
368
+ border:1px solid rgba(0,0,0,.6);
369
+ box-shadow:0 8px 18px rgba(0,0,0,.6), inset 0 2px 3px rgba(255,220,170,.25), inset 0 -8px 16px rgba(0,0,0,.6);
370
+ transition:transform .5s cubic-bezier(.34,1.56,.64,1), box-shadow .3s ease;
 
 
 
 
371
  color:var(--ivory-dim);
372
  }
373
  .power-knob::after{ /* pointer notch */
374
+ content:"";position:absolute;top:9px;left:50%;width:4px;height:26px;margin-left:-2px;
375
+ border-radius:3px;background:linear-gradient(180deg,var(--ivory-dim),#7a4a14);
376
  box-shadow:0 0 4px rgba(0,0,0,0.6);transition:background .3s, box-shadow .3s;
377
  }
378
  .power-knob .pwr-txt{
379
+ position:absolute;bottom:-22px;left:50%;transform:translateX(-50%);white-space:nowrap;
380
+ font-family:"Space Mono",monospace;font-size:9px;letter-spacing:.18em;font-weight:700;
381
+ pointer-events:none;color:#8a7350;
382
  }
383
+ .power-knob:hover{box-shadow:0 9px 20px rgba(0,0,0,.65), inset 0 2px 3px rgba(255,220,170,.3), inset 0 -8px 16px rgba(0,0,0,.6)}
384
+ .power-knob:focus-visible{outline:2px solid var(--filament);outline-offset:6px}
385
  body.powered .power-knob{transform:rotate(28deg)}
386
+ body.powered .power-knob::after{background:linear-gradient(180deg,#fff,var(--ember));box-shadow:0 0 10px var(--ember),0 0 18px rgba(255,90,44,0.6)}
387
+ body.powered .power-knob .pwr-txt{transform:translateX(-50%) rotate(-28deg);color:#ffd2b0}
388
 
389
+ /* signal-integrity gauge (diegetic SIGNAL meter) */
390
+ .gauge-wrap{display:flex;flex-direction:column;align-items:center;gap:6px;justify-content:center}
391
  .gauge{
392
+ position:relative;width:100%;height:8px;border-radius:5px;
393
+ background:rgba(255,255,255,.06);overflow:hidden;
394
+ box-shadow:0 0 0 1px rgba(0,0,0,0.5) inset;
 
395
  }
396
+ .gauge .ticks{display:none}
397
  .gauge .fill{
398
+ position:absolute;left:0;top:0;bottom:0;width:0%;
399
+ background:linear-gradient(90deg, var(--ember), var(--filament));
400
+ box-shadow:0 0 10px rgba(255,150,70,.5);
401
+ transition:width .6s cubic-bezier(.2,.7,.2,1), background .6s;
402
  }
 
 
403
  .gauge .stagedot{
404
+ position:absolute;top:50%;width:6px;height:6px;border-radius:50%;margin-top:-3px;
405
+ background:#fff;box-shadow:0 0 8px #fff;left:0;transition:left .6s;
406
+ }
407
+ /* teal tint of the signal bar ONLY at perfect 98.6 lock */
408
+ body.locked .gauge .fill{
409
+ background:linear-gradient(90deg, var(--filament), var(--teal));
410
+ box-shadow:0 0 12px rgba(63,184,196,.6);
411
  }
412
 
413
+ /* handset / call-in — physical hardware, not an app pill */
414
+ .handset-row{display:flex;flex-direction:column;align-items:center;gap:8px;justify-content:center;width:100%}
415
  .handset{
416
+ position:relative;display:flex;align-items:center;gap:10px;justify-content:center;
417
+ width:100%;padding:13px 16px;border-radius:8px;cursor:pointer;border:none;
418
+ font-family:inherit;color:var(--dial-ivory);
419
+ background:linear-gradient(180deg,#231812,#0f0906);
420
+ border:1px solid rgba(255,215,160,.16);
421
+ box-shadow:inset 0 1px 0 rgba(255,220,170,.08), inset 0 -10px 20px rgba(0,0,0,.55), 0 9px 18px rgba(0,0,0,.55);
422
  transition:transform .12s ease, box-shadow .25s ease;
423
  }
424
  .handset .ic{
425
+ width:30px;height:30px;border-radius:7px;
426
  display:flex;align-items:center;justify-content:center;
427
+ background:linear-gradient(180deg,#c89b55,#5d3b16);
428
+ color:#1d1107;box-shadow:inset 0 1px 0 rgba(255,255,255,.3);
429
  }
430
+ .handset .ic svg{width:16px;height:16px}
431
  .handset .ht{display:flex;flex-direction:column;align-items:flex-start;line-height:1.15}
432
+ .handset .ht b{font-family:"Space Mono",monospace;font-size:11px;letter-spacing:.16em}
433
+ .handset .ht span{font-family:"Space Mono",monospace;font-size:8px;letter-spacing:.18em;color:var(--ivory-dim)}
434
  .handset:disabled{opacity:.4;cursor:not-allowed}
435
+ .handset:focus-visible{outline:2px solid var(--filament);outline-offset:4px}
436
  .handset.recording{
437
  background:linear-gradient(180deg,#7a1f12,#3a0d08);
438
  box-shadow:0 0 0 2px var(--on-air), 0 0 26px rgba(255,90,44,0.7);
439
  animation:rec-pulse 1s ease-in-out infinite;
440
  }
441
+ .handset.recording .ic{background:linear-gradient(180deg,#ff8a6a,var(--on-air))}
442
  @keyframes rec-pulse{50%{box-shadow:0 0 0 2px var(--on-air),0 0 40px rgba(255,90,44,0.95)}}
443
 
444
+ /* small ghost buttons (NEXT / captions) — physical rocker hardware */
445
+ .minirow{display:flex;gap:10px;align-items:center;justify-content:center;margin-top:14px;flex-wrap:wrap}
446
  .ghost{
447
+ font-family:"Space Mono",monospace;font-size:10px;letter-spacing:.16em;font-weight:700;
448
+ color:#25180d;cursor:pointer;text-transform:uppercase;
449
+ border:1px solid #1b150f;border-radius:4px;padding:8px 14px;
450
+ background:linear-gradient(180deg,#d0c5ae 0%,#8f826d 48%,#50483a 100%);
451
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.45), inset 0 -2px 3px rgba(0,0,0,.38), 0 3px 8px rgba(0,0,0,.45);
452
+ transition:all .2s;
453
+ }
454
+ .ghost:hover{color:#1b1209;border-color:var(--amber);
455
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.5), 0 0 0 2px rgba(255,179,71,.5), 0 4px 10px rgba(0,0,0,.5)}
456
+ .ghost:focus-visible{outline:2px solid var(--filament);outline-offset:2px}
457
+ .ghost[aria-pressed="true"]{color:#1d1107;background:linear-gradient(180deg,#ffd18b,#b57928);border-color:var(--brass-dark)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
  /* tiny credit line */
460
  .tagline{
461
+ margin-top:12px;text-align:center;
462
+ font-family:"Space Mono",monospace;font-size:9px;letter-spacing:.3em;
463
+ color:rgba(156,142,115,0.55);font-weight:700;
464
  }
465
 
466
+ /* hidden live-region for captions when the glass is too noisy for a screen reader */
467
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
468
+
469
  /* ===================================================================
470
  6. MOTION / RESPONSIVE
471
  =================================================================== */
472
  @media (max-width:680px){
473
+ body{overflow:auto;align-items:flex-start}
474
+ .cabinet{transform:none;width:min(96vw,520px);padding:16px}
475
+ .foot{display:none}
476
+ .brandplate .name{font-size:17px;letter-spacing:.20em}
477
+ .dial-frame{height:260px}
478
+ .line{font-size:20px}
479
+ .deck{grid-template-columns:1fr 1fr;gap:10px}
480
+ .deck .panel:nth-child(2){grid-column:1 / -1;order:-1}
481
  }
482
  @media (prefers-reduced-motion:reduce){
483
+ .cabinet{transform:none}
484
  .reel.spin{animation:none}
485
  .handset.recording{animation:none}
486
  .room-lamp{transition:none}
487
+ .line, .line .w{transition:none}
488
  *{scroll-behavior:auto}
489
  }
490
  /* glitch flicker, applied transiently by JS (skipped under reduced-motion) */
 
495
  <body>
496
  <div class="room-lamp" aria-hidden="true"></div>
497
 
498
+ <div class="stage">
499
  <main class="cabinet" role="application" aria-label="NIGHTWAVE analog radio">
500
+ <i class="foot l" aria-hidden="true"></i><i class="foot r" aria-hidden="true"></i>
501
  <div class="bezel">
502
 
503
+ <!-- ===== HEADER: brushed-brass strip ===== -->
504
  <div class="topband">
505
+ <div class="brandplate">
506
  <span class="name">NIGHTWAVE</span>
507
+ <span class="sub">98.6 FM</span>
508
  </div>
509
+ <div class="right">
510
+ <span class="hostplate" aria-hidden="true">SAM DUSK &middot; ALL-NIGHT DESK</span>
511
+ <div class="onair" role="status" aria-live="polite">
512
+ <span class="tube" aria-hidden="true"></span>
513
+ <span class="label" id="onairLabel">STANDBY</span>
514
+ </div>
515
  </div>
516
  </div>
517
 
518
+ <!-- ===== THE HERO: backlit slide-rule dial-glass that talks ===== -->
 
 
 
 
 
 
519
  <div class="dialwrap" id="dialwrap">
520
  <div class="dial-frame" id="dialFrame">
521
+ <div class="dial-light" id="dialLight" aria-hidden="true"></div>
522
+ <div class="filament" aria-hidden="true"></div>
523
+ <canvas class="dial-canvas" id="dialCanvas" aria-hidden="true"></canvas>
524
  <span class="freq-readout" id="freqReadout">98.6</span>
525
+ <span class="lockchip">98.6 &middot; LOCKED</span>
 
 
 
 
526
  <div class="needle" id="needle"
527
  role="slider" tabindex="0"
528
  aria-label="Tuning dial, FM frequency"
529
  aria-valuemin="87.5" aria-valuemax="108.0" aria-valuenow="98.6"
530
  aria-valuetext="98.6 megahertz"></div>
531
+
532
+ <!-- the printed transcript: Sam Dusk's voice prints here -->
533
+ <div class="transcript" id="transcript"></div>
534
+
535
+ <!-- now-playing, printed on the same glass -->
536
+ <div class="nowplaying" id="nowPlaying" aria-live="polite">
537
+ <span class="np-eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
538
+ <span class="np-text" id="npText">NIGHTWAVE — all night, every night</span>
539
+ </div>
540
+
541
+ <div class="sheen" aria-hidden="true"><span class="dust"></span></div>
542
  </div>
543
  </div>
544
 
545
+ <!-- live caption text for assistive tech (the glass is the visual surface) -->
546
+ <div class="sr-only" id="caption" role="log" aria-live="polite" aria-atomic="false">
547
+ Turn the power knob to tune in&hellip;
548
+ </div>
549
+
550
  <!-- ===== control deck ===== -->
551
  <div class="deck">
552
 
553
+ <!-- left: speaker grille + analog VU + tape reels -->
554
  <div class="panel">
555
+ <span class="cap">SPEAKER &middot; VU</span>
556
+ <div class="speaker-grille" aria-hidden="true"></div>
557
+ <canvas class="vu-canvas" id="vuCanvas" width="320" height="74" aria-hidden="true"></canvas>
558
  <div class="reels" aria-hidden="true">
559
  <div class="reel" id="reelL"><span class="hub"></span></div>
560
  <div class="reel rev" id="reelR"><span class="hub"></span></div>
561
  </div>
562
  </div>
563
 
564
+ <!-- center: power -->
565
  <div class="panel">
566
+ <span class="cap">POWER</span>
567
  <div class="power-wrap">
568
  <button class="power-knob" id="powerKnob"
569
  aria-pressed="false" aria-label="Power. Turn on to tune in to NIGHTWAVE.">
570
+ <span class="pwr-txt" id="pwrTxt">POWER &middot; OFF</span>
571
  </button>
572
  </div>
573
+ </div>
574
+
575
+ <!-- right: handset + signal meter -->
576
+ <div class="panel">
577
+ <span class="cap">CALL IN</span>
578
  <div class="handset-row">
579
  <button class="handset" id="handset" disabled
580
  aria-label="Call in. Tap to talk to the DJ on the air, tap again to send.">
 
584
  <span class="ht"><b>CALL IN</b><span>HOLD TO TALK</span></span>
585
  </button>
586
  </div>
587
+ <span class="cap" id="stageCap">SIGNAL</span>
 
 
 
 
588
  <div class="gauge-wrap">
589
  <div class="gauge" id="gauge" role="meter" aria-label="Signal integrity"
590
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
 
592
  <div class="fill" id="gaugeFill"></div>
593
  <div class="stagedot" id="gaugeDot" aria-hidden="true"></div>
594
  </div>
 
595
  </div>
596
  </div>
597
  </div>
 
602
  <button class="ghost" id="capToggle" aria-pressed="true" aria-label="Toggle captions">CAPTIONS: ON</button>
603
  </div>
604
 
 
 
 
 
 
 
 
 
605
  <div class="tagline">&mdash; YOU&#39;RE LISTENING TO NIGHTWAVE &mdash;</div>
606
  </div>
607
  </main>
608
+ </div>
609
 
610
  <script>
611
  "use strict";
612
  /* ===================================================================
613
+ NIGHTWAVE radio controller — "THE WAKING SET"
614
  Sections:
615
  A. Canonical arc contract (mirrors space/arc.py)
616
  B. State
617
  C. DOM refs
618
  D. Audio engine (one AudioContext, radio chain, hiss bed, VU)
619
  E. Dial / tuning
620
+ F. Signal meter (diegetic, tuning-driven)
621
+ G. Captions (karaoke ignition trail on the glass)
622
  H. Networking (/api/broadcast, /api/call) with mock fallback
623
  I. Broadcast loop & arc cues
624
  J. Call-in (tap-to-talk)
625
+ K. Power-on warm-up, wiring, seed controls, rAF loop
626
  =================================================================== */
627
 
628
  /* ---- A. CANONICAL ARC CONTRACT (must match arc.py exactly) -------- */
 
672
  songPlaying:false, // a generated song is currently sounding (ducks the bed)
673
  pendingBreak:null, // buffered DJ break for the next transition (dead-air gating)
674
  locale:null, // resolved real weather/time/city, or null (fictional fallback)
675
+ connecting:false, // caller-intro is playing before the mic opens
676
+ warming:false // power-on warm-up ritual is in progress (visual only)
677
  };
678
 
679
  /* ---- C. DOM refs -------------------------------------------------- */
 
685
  dialwrap: $("dialwrap"), dialFrame: $("dialFrame"),
686
  needle: $("needle"), freqReadout: $("freqReadout"),
687
  dialLight: $("dialLight"), dialCanvas: $("dialCanvas"),
688
+ transcript: $("transcript"),
689
  vuCanvas: $("vuCanvas"),
690
  reelL: $("reelL"), reelR: $("reelR"),
691
  handset: $("handset"),
 
700
  State.reducedMotion = mql.matches;
701
  if (mql.addEventListener) mql.addEventListener("change", e=>{ State.reducedMotion = e.matches; });
702
 
703
+ // CSS custom-property helpers for the warm-up ritual
704
+ function setWarm(v){ document.documentElement.style.setProperty("--warm", v.toFixed(3)); }
705
+ function setLock(v){ document.documentElement.style.setProperty("--lock", v.toFixed(3)); }
706
+
707
  /* ===================================================================
708
  D. AUDIO ENGINE - one AudioContext, radio chain, hiss bed, VU
709
  =================================================================== */
 
1163
  function showNowPlaying(track){
1164
  if (!els.nowPlaying || !track) return;
1165
  els.nowPlaying.classList.add("playing");
1166
+ if (els.npText){
1167
+ els.npText.innerHTML = "";
1168
+ els.npText.appendChild(document.createTextNode(
1169
+ "♪ " + (track.title || "NIGHTWAVE") + " — " + (track.artist || "the all-night desk")));
1170
+ if (track.recommended_by){
1171
+ const r = document.createElement("span");
1172
+ r.className = "np-req";
1173
+ r.textContent = " · req " + track.recommended_by;
1174
+ els.npText.appendChild(r);
1175
+ }
1176
+ }
1177
  }
1178
  function clearNowPlaying(label){
1179
  if (!els.nowPlaying) return;
 
1294
  }
1295
 
1296
  /* ===================================================================
1297
+ E. DIAL / TUNING — slide-rule scale across the top of the glass
1298
  =================================================================== */
1299
  function drawDial(){
1300
  const c = els.dialCanvas, ctx = c.getContext("2d");
 
1306
 
1307
  const pad = 14;
1308
  const span = FREQ_MAX - FREQ_MIN;
1309
+ // warm-up brightens the scale; matches CSS celluloid lighting
1310
+ const cssWarm = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--warm")) || 0;
1311
+ const tickA = 0.20 + 0.55 * cssWarm;
1312
+ const numA = 0.30 + 0.55 * cssWarm;
1313
  ctx.textAlign = "center";
 
1314
 
1315
  for (let f = 88; f <= 108; f += 1){
1316
  const x = pad + ((f - FREQ_MIN)/span) * (w - pad*2);
1317
+ const major = (f % 4 === 0);
1318
+ ctx.strokeStyle = "rgba(243,228,192," + (major ? tickA : tickA*0.55).toFixed(3) + ")";
1319
+ ctx.lineWidth = major ? 1.2 : 1;
1320
  ctx.beginPath();
1321
+ ctx.moveTo(x, 6);
1322
+ ctx.lineTo(x, 6 + (major?13:8));
1323
  ctx.stroke();
1324
  if (major){
1325
+ ctx.font = "11px 'Marcellus SC', Georgia, serif";
1326
+ ctx.fillStyle = "rgba(243,228,192," + numA.toFixed(3) + ")";
1327
+ ctx.fillText(String(f), x, 34);
1328
  }
1329
  }
1330
+ // NIGHTWAVE marker (98.6) — engraved + ember-lit
 
 
 
 
 
 
 
1331
  const nx = pad + ((NIGHTWAVE_FREQ - FREQ_MIN)/span) * (w - pad*2);
1332
+ ctx.font = "11px 'Marcellus SC', Georgia, serif";
1333
+ ctx.fillStyle = "rgba(255,122,24,0.92)";
1334
+ ctx.fillText("98.6", nx, 34);
1335
+ ctx.fillStyle = "rgba(255,122,24,0.85)";
1336
+ ctx.beginPath(); ctx.moveTo(nx, 4); ctx.lineTo(nx-4, 13); ctx.lineTo(nx+4, 13); ctx.closePath(); ctx.fill();
1337
  }
1338
 
1339
  function freqToPct(f){ return (f - FREQ_MIN)/(FREQ_MAX - FREQ_MIN); }
 
1350
 
1351
  const locked = Math.abs(f - NIGHTWAVE_FREQ) <= LOCK_BAND;
1352
  els.dialwrap.classList.toggle("locked", locked);
1353
+ // the ONE cool accent: teal lock cue + signal tint only at perfect lock
1354
+ els.body.classList.toggle("locked", locked && State.powered);
1355
  // dial glow clarity: 1 when locked, fading off-band
1356
  const clarity = Math.max(0, 1 - Math.abs(f - NIGHTWAVE_FREQ)/2.0);
1357
  document.documentElement.style.setProperty("--dial-glow", clarity.toFixed(3));
 
1386
  // pointer dragging
1387
  function pointerToFreq(clientX){
1388
  const r = els.dialFrame.getBoundingClientRect();
1389
+ const pad = 14;
1390
  let pct = (clientX - r.left - pad) / (r.width - pad*2);
1391
  pct = Math.max(0, Math.min(1, pct));
1392
  return FREQ_MIN + pct*(FREQ_MAX - FREQ_MIN);
 
1416
  }
1417
 
1418
  /* ===================================================================
1419
+ F. SIGNAL METER - client owns it
1420
  =================================================================== */
1421
  // The realization arc was retired; the gauge is now a diegetic SIGNAL meter
1422
  // driven by how well we're tuned to the station. (State.meter/stage are kept
 
1426
  State.stage = meterToStage(State.meter);
1427
  const detune = Math.abs(State.freq - NIGHTWAVE_FREQ);
1428
  const sig = Math.round(Math.max(0, Math.min(100, (1 - detune / 2.0) * 100)));
1429
+ els.gaugeFill.style.width = sig + "%";
1430
+ els.gaugeDot.style.left = sig + "%";
1431
  els.gauge.setAttribute("aria-valuenow", String(sig));
1432
  els.stageCap.textContent = State.powered ? "SIGNAL" : "—";
1433
  }
 
1443
  }
1444
 
1445
  /* ===================================================================
1446
+ G. CAPTIONS (karaoke) — Sam Dusk's voice prints on the dial-glass
1447
  =================================================================== */
1448
  let captionTimers = [];
1449
  function clearCaptionTimers(){ captionTimers.forEach(clearTimeout); captionTimers = []; }
1450
 
1451
+ // Lift finished lines into the ghost stack; cap the trail at 3 lines.
1452
+ function ghostifyLines(){
1453
+ const lines = Array.prototype.slice.call(els.transcript.querySelectorAll(".line"));
1454
+ lines.forEach(l=>{ l.classList.remove("current"); l.classList.add("ghost"); });
1455
+ while (els.transcript.children.length > 3) els.transcript.removeChild(els.transcript.firstChild);
1456
  }
1457
 
1458
+ // status / fragment lines (system messages): printed plainly on the glass,
1459
+ // always mirrored to the SR live-region. Status text bypasses captionsOff.
1460
+ function showCaption(text, kind){
1461
+ clearCaptionTimers();
1462
+ // mirror to the assistive-tech live region
1463
+ els.caption.className = "sr-only" + (kind ? " "+kind : "");
1464
+ els.caption.textContent = (!State.captionsOn && kind !== "status") ? "" : (text || "");
1465
+
1466
+ if (!State.captionsOn && kind !== "status"){ ghostifyLines(); return; }
1467
+ ghostifyLines();
1468
+ const line = document.createElement("div");
1469
+ line.className = "line current" + (kind ? " "+kind : "");
1470
+ line.textContent = text || "";
1471
+ els.transcript.appendChild(line);
1472
+ requestAnimationFrame(()=>line.classList.add("in"));
1473
+ }
1474
+
1475
+ // karaoke: words/wtimes from /speak (progressive enhancement). Each spoken word
1476
+ // develops like film — opacity + blur->sharp + a decaying ember bloom — then
1477
+ // settles to ivory; finished lines lift and fade to --foxed (a ghost stack).
1478
  function showCaptionKaraoke(text, words, wtimes, callerText){
1479
  clearCaptionTimers();
1480
+ els.caption.className = "sr-only";
1481
+ if (!State.captionsOn){
1482
+ els.caption.textContent = "";
1483
+ ghostifyLines();
1484
+ return;
1485
+ }
1486
+ // mirror full line to the SR live-region for assistive tech
1487
+ els.caption.textContent = (callerText ? ("“"+callerText+"” ") : "") + (text || "");
1488
 
1489
+ ghostifyLines();
1490
+ const line = document.createElement("div");
1491
+ line.className = "line current";
1492
  if (callerText){
1493
  const c = document.createElement("span");
1494
  c.className = "caller-said";
1495
  c.textContent = "“" + callerText + "”";
1496
+ line.appendChild(c);
1497
  }
1498
+
1499
  if (!words || !words.length || !wtimes || wtimes.length !== words.length){
1500
+ line.appendChild(document.createTextNode(text || ""));
1501
+ els.transcript.appendChild(line);
1502
+ requestAnimationFrame(()=>line.classList.add("in"));
1503
  return;
1504
  }
1505
  const spans = words.map((w)=>{
1506
  const s = document.createElement("span");
1507
+ s.className = "w"; s.textContent = w + " ";
1508
+ line.appendChild(s);
 
1509
  return s;
1510
  });
1511
+ els.transcript.appendChild(line);
1512
+ requestAnimationFrame(()=>line.classList.add("in"));
1513
  words.forEach((w,i)=>{
1514
  const t = Math.max(0, wtimes[i]) * 1000;
1515
+ captionTimers.push(setTimeout(()=>{
1516
+ if (!spans[i]) return;
1517
+ spans[i].classList.add("spoken"); // ember bloom on the live word
1518
+ // decay the bloom to settled ivory after a beat (the film "develops")
1519
+ captionTimers.push(setTimeout(()=>{
1520
+ if (spans[i]){ spans[i].classList.remove("spoken"); spans[i].classList.add("said"); }
1521
+ }, 260));
1522
+ }, t));
1523
  });
1524
  }
1525
 
 
1546
 
1547
  // Canned, stage-appropriate lines if the proxy is unreachable.
1548
  // (The Space itself also has a NIGHTWAVE_MOCK; this is the last resort.)
1549
+ // Offline/failure fallback — straight-station Sam Dusk lines (the realization
1550
+ // arc is retired; a network hiccup must not resurface the old concept).
1551
+ const MOCK_LINES = [
1552
+ "It's Sam Dusk, and you're back with me on Nightwave, ninety-eight point six. The hour's soft and the dial is warm.",
1553
+ "This next one came in for the long-haul drivers and the kitchen lights still burning after midnight.",
1554
+ "Weather out there's quiet enough to hear the streetlights hum. Keep the radio low and the coffee close.",
1555
+ "Nightwave's still here, all night long, for whoever needed one more voice in the room.",
1556
+ "Coming up, a slow one for slow hours like these. Stay right where you are, would you."
1557
+ ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1558
  // tiny silent WAV (header + 0 samples) base64 - lets the loop advance with no backend
1559
  const SILENT_WAV = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
1560
 
1561
  function clientMock(path, body){
1562
+ const text = MOCK_LINES[Math.floor(Math.random()*MOCK_LINES.length)];
1563
+ const base = { text, mood:"warm", arc_cue:"none", audio_b64:SILENT_WAV, words:[], wtimes:[], wdurations:[] };
 
 
 
 
1564
  if (path === "/api/call"){
1565
+ return Object.assign({}, base, { caller_text:"(you, on the air)", meter_delta:0 });
1566
  }
1567
  return base;
1568
  }
 
1740
  }
1741
 
1742
  /* ===================================================================
1743
+ K. POWER, WARM-UP, WIRING, SEED, rAF LOOP
1744
  =================================================================== */
1745
  function setBroadcasting(on){
1746
  // The station is "ON AIR" (tube + label lit) the whole time it's powered -- a
 
1753
  els.reelR.classList.toggle("spin", spin);
1754
  }
1755
 
1756
+ // ---- power-on warm-up ritual (VISUAL ONLY). Drives --warm/--lock + the
1757
+ // self-sweeping needle. Must NEVER block AudioContext.resume() / runShow /
1758
+ // fetches; reduced-motion collapses it to an instant on. ----
1759
+ let warmRAF = null, sweepRAF = null;
1760
+ function easeThermal(x){ return x < .5 ? 2*x*x : 1 - Math.pow(-2*x+2, 2)/2; }
1761
+ function clearWarmup(){
1762
+ State.warming = false;
1763
+ if (warmRAF){ cancelAnimationFrame(warmRAF); warmRAF = null; }
1764
+ if (sweepRAF){ cancelAnimationFrame(sweepRAF); sweepRAF = null; }
1765
+ }
1766
+ function runWarmup(){
1767
+ clearWarmup();
1768
+ if (State.reducedMotion){
1769
+ setWarm(1); setLock(1);
1770
+ setFreq(NIGHTWAVE_FREQ, false);
1771
+ els.body.classList.add("broadcasting");
1772
+ els.onairLabel.textContent = "ON AIR";
1773
+ drawDial();
1774
+ return;
1775
+ }
1776
+ State.warming = true;
1777
+ setWarm(0); setLock(0);
1778
+ // 1) filament bloom + backlight (radial mask w/ thermal-lag easing + early flicker)
1779
+ const T0 = performance.now(), DUR = 3600;
1780
+ (function bloom(now){
1781
+ if (!State.powered){ clearWarmup(); return; }
1782
+ const p = Math.min(1, (now - T0) / DUR);
1783
+ const flick = p < .25 ? 0.06*Math.sin(now/40) : 0;
1784
+ setWarm(Math.min(1, easeThermal(p) * (1 + flick)));
1785
+ drawDial(); // brighten the scale numerals/ticks with the filament
1786
+ if (p < 1){ warmRAF = requestAnimationFrame(bloom); }
1787
+ else { setWarm(1); warmRAF = null; drawDial(); }
1788
+ })(T0);
1789
+ // 2) needle self-sweeps from the low end up and settles on 98.6 (damped overshoot)
1790
+ captionTimers.push(setTimeout(()=>{
1791
+ if (!State.powered){ return; }
1792
+ const s = performance.now(), D = 2200;
1793
+ const lockPct = freqToPct(NIGHTWAVE_FREQ) * 100;
1794
+ (function sweep(now){
1795
+ if (!State.powered){ sweepRAF = null; return; }
1796
+ const p = Math.min(1, (now - s) / D);
1797
+ let pct;
1798
+ if (p < .45){ pct = 2 + (100 - 2) * (p / .45); } // run up toward the high end
1799
+ else {
1800
+ const q = (p - .45) / .55;
1801
+ const overshoot = Math.sin(q*Math.PI*2) * Math.pow(1 - q, 2) * 6;
1802
+ pct = 100 + (lockPct - 100) * easeThermal(q) + overshoot;
1803
+ }
1804
+ const f = FREQ_MIN + (Math.max(0, Math.min(100, pct)) / 100) * (FREQ_MAX - FREQ_MIN);
1805
+ // visual-only: move needle + lights, but do NOT mark fromUser (no fragments/eggs)
1806
+ setFreq(f, false);
1807
+ if (p < 1){ sweepRAF = requestAnimationFrame(sweep); }
1808
+ else { sweepRAF = null; setFreq(NIGHTWAVE_FREQ, false); }
1809
+ })(s);
1810
+ }, 2600));
1811
+ // 3) ON AIR tube ignites + carrier lock latches near the end of the ritual
1812
+ captionTimers.push(setTimeout(()=>{
1813
+ if (!State.powered) return;
1814
+ setBroadcasting(false); // refresh reels/tube from current state
1815
+ els.body.classList.add("broadcasting"); // tube ignites
1816
+ els.onairLabel.textContent = "ON AIR";
1817
+ }, 5200));
1818
+ captionTimers.push(setTimeout(()=>{
1819
+ if (!State.powered) return;
1820
+ setLock(1);
1821
+ State.warming = false;
1822
+ }, 5600));
1823
+ }
1824
+
1825
  async function powerOn(){
1826
  if (State.powered) return;
1827
  // FIRST gesture: create + resume the single AudioContext, THEN audio.
 
1830
  State.powered = true;
1831
  els.body.classList.add("powered");
1832
  els.power.setAttribute("aria-pressed","true");
1833
+ els.pwrTxt.innerHTML = "POWER &middot; ON";
1834
  els.handset.disabled = false;
1835
  els.body.classList.add("broadcasting"); // light the ON AIR sign the moment we're powered
1836
  els.onairLabel.textContent = "ON AIR";
1837
+ runWarmup(); // visual-only tube warm-up (non-blocking)
1838
  updateHissForTuning();
1839
  applyMeter(); // gauge -> SIGNAL
1840
  rampGain(Sound.crackleGain, 0.05, 0.6); // bring up the vinyl/room bed
 
1850
 
1851
  function powerOff(){
1852
  State.powered = false;
1853
+ clearWarmup();
1854
+ setWarm(0); setLock(0);
1855
+ els.body.classList.remove("powered","broadcasting","intimate","locked");
1856
  els.power.setAttribute("aria-pressed","false");
1857
+ els.pwrTxt.innerHTML = "POWER &middot; OFF";
1858
  els.handset.disabled = true;
1859
  els.onairLabel.textContent = "STANDBY";
1860
  setBroadcasting(false);
 
1863
  try{ if (Sound.voiceEl) Sound.voiceEl.pause(); }catch(e){}
1864
  if (Sound.ctx){ rampGain(Sound.hissGain, 0, 0.3); rampGain(Sound.padGain, 0, 0.3); rampGain(Sound.crackleGain, 0, 0.3); rampGain(Sound.drumGain, 0, 0.3); }
1865
  stopDrums();
1866
+ drawDial();
1867
  applyMeter(); // gauge caption -> "—"
1868
  showCaption("Turn the power knob to tune in…");
1869
  }
1870
 
1871
  function togglePower(){ State.powered ? powerOff() : powerOn(); }
1872
 
1873
+ // ---- analog VU needle + idle broadcast loop on a single rAF ----
1874
+ function roundRect(c,x,y,w,h,r){
1875
+ c.beginPath();
1876
+ c.moveTo(x+r,y);
1877
+ c.arcTo(x+w,y,x+w,y+h,r);
1878
+ c.arcTo(x+w,y+h,x,y+h,r);
1879
+ c.arcTo(x,y+h,x,y,r);
1880
+ c.arcTo(x,y,x+w,y,r);
1881
+ c.closePath();
1882
+ }
1883
+ let vuRms = 0; // smoothed needle source
1884
  function loop(){
1885
  requestAnimationFrame(loop);
1886
 
1887
  const c = els.vuCanvas, ctx = c.getContext("2d");
1888
  const dpr = window.devicePixelRatio || 1;
1889
+ if (c.width !== Math.round(c.clientWidth*dpr) || c.height !== Math.round(c.clientHeight*dpr)){
1890
+ c.width = Math.round(c.clientWidth*dpr); c.height = Math.round(c.clientHeight*dpr);
1891
  }
1892
  ctx.setTransform(dpr,0,0,dpr,0,0);
1893
  const w = c.clientWidth, h = c.clientHeight;
 
1901
  for (let i=0;i<Sound.vuData.length;i++){ const v = Sound.vuData[i]/255; sum += v*v; }
1902
  rms = Math.sqrt(sum/Sound.vuData.length);
1903
  }
1904
+ // smooth the needle so it sways like a real meter
1905
+ vuRms += (rms - vuRms) * 0.18;
1906
+
1907
+ // ---- cream 1970s analog meter face ----
1908
+ const face = ctx.createLinearGradient(0,0,0,h);
1909
+ face.addColorStop(0, "#ead7ac");
1910
+ face.addColorStop(1, "#9f8350");
1911
+ ctx.fillStyle = face;
1912
+ roundRect(ctx, 6, 6, w-12, h-12, 6);
1913
+ ctx.fill();
1914
+
1915
+ // tick marks
1916
+ ctx.strokeStyle = "rgba(40,25,10,.45)";
1917
+ ctx.lineWidth = 1;
1918
+ for (let i=0;i<=10;i++){
1919
+ const x = 22 + i*((w-44)/10);
1920
+ const tall = i % 5 === 0;
1921
+ ctx.beginPath();
1922
+ ctx.moveTo(x, h-24);
1923
+ ctx.lineTo(x, h-(tall?40:32));
1924
+ ctx.stroke();
1925
  }
1926
+ // red zone at the top end
1927
+ ctx.strokeStyle = "rgba(150,30,20,.6)";
1928
+ for (let i=8;i<=10;i++){
1929
+ const x = 22 + i*((w-44)/10);
1930
+ ctx.beginPath(); ctx.moveTo(x, h-24); ctx.lineTo(x, h-40); ctx.stroke();
1931
+ }
1932
+ // "VU" label
1933
+ ctx.fillStyle = "rgba(55,34,16,.8)";
1934
+ ctx.font = "700 9px 'Marcellus SC', Georgia, serif";
1935
+ ctx.textAlign = "center";
1936
+ ctx.fillText("VU", w/2, 22);
1937
+
1938
+ // the single moving needle, driven by the same rms
1939
+ const cx = w/2, cy = h + 8;
1940
+ const ang = -Math.PI*0.72 + Math.min(1, vuRms*2.2) * Math.PI*0.44;
1941
+ ctx.strokeStyle = "#5a1b12"; ctx.lineWidth = 2;
1942
+ ctx.beginPath(); ctx.moveTo(cx, cy);
1943
+ ctx.lineTo(cx + Math.cos(ang)*(h*0.92), cy + Math.sin(ang)*(h*0.92)); ctx.stroke();
1944
+ // pivot cap
1945
+ ctx.fillStyle = "#5a1b12"; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI*2); ctx.fill();
1946
 
1947
  // idle broadcast loop: if powered, locked, quiet for ~20s, fetch next segment
1948
  if (State.powered && !State.busy && !State.recording && !State.showRunning){
 
2014
  State.captionsOn = !State.captionsOn;
2015
  els.capToggle.setAttribute("aria-pressed", String(State.captionsOn));
2016
  els.capToggle.textContent = "CAPTIONS: " + (State.captionsOn ? "ON" : "OFF");
2017
+ if (!State.captionsOn){ els.caption.textContent = ""; els.transcript.innerHTML = ""; }
2018
  });
2019
 
2020
  // global seed keys