@joewinke/jatui 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -242,6 +242,16 @@ if (await showConfirm({ title: "Delete?", body: warnBody, danger: true })) { ...
242
242
  ## Utilities
243
243
 
244
244
  ```typescript
245
+ // Video playback advisors (ported from extension-library — mos-5lzns.27).
246
+ // Pure functions: no DOM, no settings reads, never write playbackRate. The
247
+ // player's rate composer / resume-at-sentence / audio-graph tasks consume
248
+ // them; hosts can call them directly too.
249
+ import { buildSilenceMap, boostAt, estimateSaving } from "jatui" // silence map (smart speed)
250
+ import { speakerPace, suggestRates, rateAt, describeRates } from "jatui" // measured per-speaker wpm
251
+ import { snapResume, resumeLabel } from "jatui" // resume at a sentence, never a second
252
+ import { createQuietChain, amountFor } from "jatui" // loudness-compensated tone (WebAudio)
253
+ import { qrMatrix, qrCanvas } from "jatui" // deep-link QR, ISO 18004 v1–10, no deps
254
+
245
255
  // Date formatting
246
256
  import { formatRelativeTime, formatDate, formatShortDate, formatDateTime } from "jatui"
247
257
 
@@ -13,6 +13,8 @@
13
13
  export type { BandAxis, BandState } from '../video/band/axis';
14
14
  export type { VideoLumaTrack } from '../video/band/luma';
15
15
  export type { VideoMarker } from '../video/band/markers';
16
+ // mos-5lzns.33 — night dimming.
17
+ export type { NightMode, NightWindow } from '../video/night/clock';
16
18
  </script>
17
19
 
18
20
  <script lang="ts">
@@ -135,6 +137,26 @@
135
137
  import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
136
138
  import { composeRate, formatSpeedLabel, stepRate } from '../video/rate';
137
139
  import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
140
+ import {
141
+ biasCells,
142
+ biasGradient,
143
+ frameLuma,
144
+ METER_START,
145
+ NIGHT_H,
146
+ NIGHT_W,
147
+ smoothMeter
148
+ } from '../video/night/meter';
149
+ import { NIGHT_DEPTH_DEFAULT, clampDepth, nudgeDepth, relativeLuminance, surroundLevel } from '../video/night/levels';
150
+ import { nextNightMode, nightArmed, nightModeLabel, type NightMode, type NightWindow } from '../video/night/clock';
151
+ import {
152
+ composeNight,
153
+ inFlash,
154
+ lumaAhead,
155
+ NIGHT_IDLE,
156
+ normalizeFlashes,
157
+ type NightFrame,
158
+ type NightState
159
+ } from '../video/night/track';
138
160
 
139
161
  let {
140
162
  src,
@@ -184,6 +206,14 @@
184
206
  onTimeUpdate = undefined,
185
207
  onAddBookmark = undefined,
186
208
  onBookmarkContext = undefined,
209
+ night = $bindable(),
210
+ nightDepth = $bindable(NIGHT_DEPTH_DEFAULT),
211
+ nightWindow = null,
212
+ biasLight = true,
213
+ predictive = true,
214
+ strobeGuard = true,
215
+ onNightModeChange = undefined,
216
+ onNightBlind = undefined,
187
217
  class: className = ''
188
218
  }: {
189
219
  src: string;
@@ -281,6 +311,45 @@
281
311
  clientX: number,
282
312
  clientY: number
283
313
  ) => void;
314
+ /**
315
+ * Night dimming (mos-5lzns.33) — a METER, not a preference: ~1×/s while
316
+ * playing it reads the frame's actual brightness (32×18 canvas) and pulls
317
+ * the picture down in proportion, warming it through multiply layers so
318
+ * blacks stay black. A dark scene passes through untouched.
319
+ *
320
+ * `'auto'` arms it from the local clock (20:00–07:00; 18:00–08:00 when the
321
+ * OS prefers dark), `'on'` always, `'off'` never. Omitted (default) ⇒ the
322
+ * feature does not exist on this player: no N key, no button, no layers.
323
+ * Bindable — `N` and the moon button cycle it; `onNightModeChange` reports.
324
+ *
325
+ * Frame reading needs a CORS-clean load: pass `crossorigin="anonymous"`
326
+ * for cross-origin media. A tainted canvas runs night on a fixed level
327
+ * (no meter, no bias light) and calls `onNightBlind`.
328
+ */
329
+ night?: NightMode;
330
+ /** How far night goes, 0.2–1 (default 0.7). Bindable — scrolling the moon button sets it. */
331
+ nightDepth?: number;
332
+ /** Override the auto window, local hours `{ from, to }` (wraps midnight when from > to). */
333
+ nightWindow?: NightWindow | null;
334
+ /** While night acts, spill the frame's own colours, blurred, behind the player. */
335
+ biasLight?: boolean;
336
+ /** Read the `luma` track ~1s ahead so dimming starts BEFORE a bright cut. Only ever adds dimming. */
337
+ predictive?: boolean;
338
+ /**
339
+ * Photosensitivity guard: across the `luma` track's flash intervals,
340
+ * drop the picture to about 55% brightness (instant in, released per
341
+ * tick — never a jump back up). Always at full strength: it does NOT
342
+ * follow `nightDepth`, and it acts whether night is on, off or absent,
343
+ * whenever the track carries `flashes`. `false` to disable.
344
+ *
345
+ * It REDUCES flashes; it does not make content WCAG 2.3.1-safe. Flash
346
+ * DETECTION is the host's job — the player guards only the intervals the
347
+ * host supplies in `luma.flashes`.
348
+ */
349
+ strobeGuard?: boolean;
350
+ onNightModeChange?: (mode: NightMode) => void;
351
+ /** Frame readback was refused (CORS-tainted canvas) — night fell back to a fixed level. */
352
+ onNightBlind?: () => void;
284
353
  class?: string;
285
354
  } = $props();
286
355
 
@@ -292,6 +361,8 @@
292
361
  // Autoplay forces muted — browsers refuse to autoplay audible video, and a
293
362
  // silent presentation clip is the point of the primitive anyway.
294
363
  const effectiveAutoplay = $derived(autoplay && !reduceMotion);
364
+ // The stage's locked ratio (null ⇒ the video's intrinsic ratio drives the box).
365
+ const stageAspect = $derived(aspectRatioCss(aspect));
295
366
  // `(pointer: coarse)` — a fingertip gets a wider comment-dot target.
296
367
  let coarsePointer = $state(false);
297
368
 
@@ -478,6 +549,7 @@
478
549
  if (row.when === 'bookmarks') return onAddBookmark != null;
479
550
  if (row.when === 'transcript') return sortedSegments.length > 0;
480
551
  if (row.when === 'comments') return socialMarkers.comments.length > 0;
552
+ if (row.when === 'night') return nightPresent;
481
553
  return true;
482
554
  })
483
555
  );
@@ -548,6 +620,208 @@
548
620
  flashOsd(formatSpeedLabel(baseRate));
549
621
  }
550
622
 
623
+ // ── Night (video/night/*) ───────────────────────────────────────────────
624
+ // The modules decide; this block samples the frame and applies the answer
625
+ // as CSS variables that exist ONLY while night (or the strobe guard) is
626
+ // acting — off leaves no trace. The meter canvas and the running levels
627
+ // are plain (non-reactive): written and read inside one tick.
628
+ const NIGHT_TICK_MS = 900;
629
+ /** Bias light refreshes at most this often — peripheral light, not a second picture. */
630
+ const BIAS_EVERY_MS = 1800;
631
+ const nightPresent = $derived(night !== undefined);
632
+ const nightFlashes = $derived(normalizeFlashes(luma));
633
+ const guardPresent = $derived(strobeGuard && nightFlashes.length > 0);
634
+ let prefersDark = $state(false);
635
+ let nightArmedNow = $state(false);
636
+ let nightBlind = $state(false);
637
+ let nightFrame = $state<NightFrame | null>(null);
638
+ let biasImage = $state('');
639
+ let surroundLuma = $state(0);
640
+ let nightMeter = METER_START;
641
+ /** The composer's running state — night's level and the guard's, released independently. */
642
+ let nightOut: NightState = { ...NIGHT_IDLE };
643
+ let biasAt = 0;
644
+ let meterCtx: CanvasRenderingContext2D | null = null;
645
+
646
+ /** 32×18 RGBA bytes of the current frame, or null (no frame yet / canvas tainted). */
647
+ function sampleFrame(v: HTMLVideoElement): Uint8ClampedArray | null {
648
+ if (v.readyState < 2 || !v.videoWidth || !v.videoHeight) return null;
649
+ if (!meterCtx) {
650
+ const c = document.createElement('canvas');
651
+ c.width = NIGHT_W;
652
+ c.height = NIGHT_H;
653
+ meterCtx = c.getContext('2d', { willReadFrequently: true });
654
+ if (!meterCtx) return null;
655
+ }
656
+ try {
657
+ meterCtx.drawImage(v, 0, 0, NIGHT_W, NIGHT_H);
658
+ return meterCtx.getImageData(0, 0, NIGHT_W, NIGHT_H).data;
659
+ } catch (err) {
660
+ // Only a SecurityError means the canvas is tainted (cross-origin
661
+ // without CORS). Anything else — a decoder hiccup, a frame mid-swap —
662
+ // is transient: skip this sample and try again next tick.
663
+ if (!(err instanceof DOMException && err.name === 'SecurityError')) return null;
664
+ // Tainted: escalate to the host (it owns `crossorigin`) and run blind
665
+ // on a fixed level — never guess brightness. Bias light has nothing to
666
+ // sample, so it stands down.
667
+ nightBlind = true;
668
+ biasImage = '';
669
+ onNightBlind?.();
670
+ return null;
671
+ }
672
+ }
673
+
674
+ function nightTick() {
675
+ const v = videoEl;
676
+ if (!v) return;
677
+ const armed = night !== undefined && nightArmed(night, new Date(), prefersDark, nightWindow);
678
+ if (nightArmedNow !== armed) nightArmedNow = armed;
679
+ const hidden = typeof document !== 'undefined' && document.hidden;
680
+ const t = v.currentTime || 0;
681
+ // One tick's worth of media time: prediction and the guard look this
682
+ // much further ahead, so a coarse tick can never let the first flash through.
683
+ const horizon = (NIGHT_TICK_MS / 1000) * (v.playbackRate > 0 ? v.playbackRate : 1);
684
+ const flash = strobeGuard && inFlash(nightFlashes, t, horizon);
685
+ if (armed && !nightBlind && !v.paused && !hidden) {
686
+ const px = sampleFrame(v);
687
+ if (px) {
688
+ nightMeter = smoothMeter(nightMeter, frameLuma(px));
689
+ // The bias light reads the bytes the meter JUST drew — any other
690
+ // time the canvas is stale or empty, and empty reads as pitch black.
691
+ // Frozen while the guard is down: the glow's background-image swap
692
+ // is un-faded, and a strobe would drive it at the strobe's own rate.
693
+ if (biasLight && !flash && Date.now() - biasAt > BIAS_EVERY_MS) {
694
+ const g = biasGradient(biasCells(px));
695
+ if (g) {
696
+ biasImage = g;
697
+ biasAt = Date.now();
698
+ }
699
+ }
700
+ }
701
+ }
702
+ const f = composeNight(nightOut, {
703
+ armed,
704
+ blind: nightBlind,
705
+ meter: nightMeter,
706
+ ahead: armed && predictive ? lumaAhead(lumaSeries, t, horizon) : null,
707
+ flash,
708
+ depth: nightDepth
709
+ });
710
+ nightOut = { luma: f.luma, guardLuma: f.guardLuma };
711
+ nightFrame = f.mode === 'none' ? null : f;
712
+ }
713
+
714
+ /**
715
+ * Drop night's traces at once. The guard's level is kept unless `all`:
716
+ * switching night off is a statement about night, and must not cut short a
717
+ * guard that is still releasing (that would be a jump back up).
718
+ */
719
+ function nightClear(all = false) {
720
+ nightOut = { luma: 0, guardLuma: all ? 0 : nightOut.guardLuma };
721
+ nightFrame = null;
722
+ biasImage = '';
723
+ biasAt = 0;
724
+ }
725
+
726
+ // The tick runs only while the feature exists on this player (night opted
727
+ // in, or a track with flashes for the guard). Visibility changes re-tick at
728
+ // once so a returning tab doesn't wait out the interval.
729
+ $effect(() => {
730
+ const on = nightPresent || guardPresent;
731
+ const v = videoEl;
732
+ if (!on || !v) return;
733
+ const id = setInterval(nightTick, NIGHT_TICK_MS);
734
+ const onVis = () => nightTick();
735
+ document.addEventListener('visibilitychange', onVis);
736
+ return () => {
737
+ clearInterval(id);
738
+ document.removeEventListener('visibilitychange', onVis);
739
+ untrack(() => nightClear(true));
740
+ };
741
+ });
742
+
743
+ // A new source (or CORS mode) is a new picture: forget the meter, the
744
+ // blind verdict and the glow.
745
+ $effect(() => {
746
+ void src;
747
+ void crossorigin;
748
+ untrack(() => {
749
+ nightBlind = false;
750
+ nightMeter = METER_START;
751
+ nightClear(true);
752
+ });
753
+ });
754
+
755
+ // Knob changes re-evaluate now. Switching night off clears at once rather
756
+ // than releasing tick by tick — off is a statement, not a fade.
757
+ $effect(() => {
758
+ const mode = night;
759
+ void nightDepth;
760
+ void nightWindow;
761
+ void predictive;
762
+ void strobeGuard;
763
+ void biasLight;
764
+ void prefersDark;
765
+ void videoEl;
766
+ untrack(() => {
767
+ if (mode === 'off' || mode === undefined) nightClear();
768
+ else if (!biasLight) biasImage = '';
769
+ nightTick();
770
+ });
771
+ });
772
+
773
+ // The theme's surround (base-200) as relative luminance: on a light theme
774
+ // the surround is pulled down with the picture (levels.ts surroundLevel).
775
+ $effect(() => {
776
+ void themeTick;
777
+ if (!nightPresent) return;
778
+ untrack(() => {
779
+ surroundLuma = colorLuminance(getDaisyUIColor('base-200'));
780
+ });
781
+ });
782
+
783
+ /** Any CSS colour → relative luminance, via a 1×1 same-origin canvas (never tainted). */
784
+ function colorLuminance(color: string): number {
785
+ if (typeof document === 'undefined') return 0;
786
+ try {
787
+ const c = document.createElement('canvas');
788
+ c.width = c.height = 1;
789
+ const ctx = c.getContext('2d', { willReadFrequently: true });
790
+ if (!ctx) return 0;
791
+ ctx.fillStyle = color; // unparseable ⇒ stays the default black ⇒ 0 ⇒ surround untouched
792
+ ctx.fillRect(0, 0, 1, 1);
793
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
794
+ return relativeLuminance(r, g, b);
795
+ } catch {
796
+ return 0;
797
+ }
798
+ }
799
+
800
+ const nightLayersOn = $derived(nightFrame?.mode === 'night');
801
+ const biasOn = $derived(nightLayersOn && biasLight && !nightBlind && biasImage !== '');
802
+ const surroundMix = $derived(nightLayersOn ? surroundLevel(surroundLuma, nightDepth) : 0);
803
+ const nightLabel = $derived(nightModeLabel(night, nightArmedNow));
804
+
805
+ function setNightMode(mode: NightMode) {
806
+ night = mode;
807
+ onNightModeChange?.(mode);
808
+ }
809
+ function cycleNight() {
810
+ if (night === undefined) return;
811
+ const next = nextNightMode(night);
812
+ setNightMode(next);
813
+ flashOsd(nightModeLabel(next, nightArmed(next, new Date(), prefersDark, nightWindow)));
814
+ }
815
+ /** Scroll the moon: the depth dial. Reaching for it means you want to see it work, whatever the clock says. */
816
+ function onNightWheel(e: WheelEvent) {
817
+ if (night === undefined) return;
818
+ e.preventDefault();
819
+ e.stopPropagation();
820
+ if (!nightArmedNow) setNightMode('on');
821
+ nightDepth = nudgeDepth(nightDepth, e.deltaY < 0 ? 1 : -1);
822
+ flashOsd(`Night ${Math.round(clampDepth(nightDepth) * 100)}%`);
823
+ }
824
+
551
825
  // ── Canvas ──────────────────────────────────────────────────────────────
552
826
  // Repaint on any input the canvas reads. Deps are listed explicitly and the
553
827
  // body is untracked — letting paintState's own reads register as deps is
@@ -628,6 +902,15 @@
628
902
  mq.addEventListener('change', onChange);
629
903
  stopReduce = () => mq.removeEventListener('change', onChange);
630
904
  }
905
+ let stopDark: (() => void) | undefined;
906
+ if (typeof window !== 'undefined' && window.matchMedia) {
907
+ // Only ever WIDENS night's auto window (night/clock.ts) — never the trigger.
908
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
909
+ prefersDark = mq.matches;
910
+ const onChange = () => (prefersDark = mq.matches);
911
+ mq.addEventListener('change', onChange);
912
+ stopDark = () => mq.removeEventListener('change', onChange);
913
+ }
631
914
  let stopCoarse: (() => void) | undefined;
632
915
  if (typeof window !== 'undefined' && window.matchMedia) {
633
916
  const mq = window.matchMedia('(pointer: coarse)');
@@ -641,6 +924,7 @@
641
924
  stopTheme();
642
925
  stopReduce?.();
643
926
  stopCoarse?.();
927
+ stopDark?.();
644
928
  if (dragRaf) cancelAnimationFrame(dragRaf);
645
929
  if (zoomTimer) clearTimeout(zoomTimer);
646
930
  if (osdTimer) clearTimeout(osdTimer);
@@ -1060,6 +1344,13 @@
1060
1344
  flashOsd(transcriptVisible ? 'Transcript shown' : 'Transcript hidden');
1061
1345
  }
1062
1346
  return;
1347
+ case 'n':
1348
+ case 'N':
1349
+ if (night !== undefined) {
1350
+ e.preventDefault();
1351
+ cycleNight();
1352
+ }
1353
+ return;
1063
1354
  case 'Home':
1064
1355
  seekTo(0);
1065
1356
  break;
@@ -1155,6 +1446,9 @@
1155
1446
  class="vp-root {className}"
1156
1447
  class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
1157
1448
  class:vp-one-axis={nativeBarHidden}
1449
+ class:vp-night-capable={nightPresent}
1450
+ class:vp-night-surround={surroundMix > 0.001}
1451
+ style:--vp-night-surround={surroundMix > 0.001 ? surroundMix.toFixed(3) : undefined}
1158
1452
  data-band-axis={currentBandState.axis}
1159
1453
  role="region"
1160
1454
  aria-label="Video player"
@@ -1162,11 +1456,30 @@
1162
1456
  onkeydown={onRootKeyDown}
1163
1457
  >
1164
1458
  <div class="vp-main">
1459
+ <div class="vp-stage-wrap">
1460
+ {#if nightPresent}
1461
+ <!-- Bias light: the frame's own colours, blurred, spilled behind the
1462
+ picture. Earlier in the DOM than the (positioned) stage, so the
1463
+ stage paints over it with no z-index and no stacking context. -->
1464
+ <div
1465
+ class="vp-bias"
1466
+ class:on={biasOn}
1467
+ style:--vp-bias-image={biasOn ? biasImage : undefined}
1468
+ aria-hidden="true"
1469
+ ></div>
1470
+ {/if}
1165
1471
  <!-- svelte-ignore a11y_no_static_element_interactions -->
1166
1472
  <div
1167
1473
  class="vp-stage"
1168
- class:vp-stage-locked={aspectRatioCss != null}
1169
- style={aspectRatioCss != null ? `--vp-aspect: ${aspectRatioCss};` : undefined}
1474
+ class:vp-stage-locked={stageAspect != null}
1475
+ class:vp-night-capable={nightPresent || guardPresent}
1476
+ class:vp-night-on={nightFrame != null}
1477
+ class:vp-flash-guard={nightFrame?.guard === true}
1478
+ style={stageAspect != null ? `--vp-aspect: ${stageAspect};` : undefined}
1479
+ style:--vp-night-bright={nightFrame ? nightFrame.levels.bright.toFixed(3) : undefined}
1480
+ style:--vp-night-sat={nightFrame ? nightFrame.levels.sat.toFixed(3) : undefined}
1481
+ style:--vp-night-warm={nightLayersOn && nightFrame ? nightFrame.levels.warm.toFixed(3) : undefined}
1482
+ style:--vp-night-vig={nightLayersOn && nightFrame ? nightFrame.levels.vig.toFixed(3) : undefined}
1170
1483
  onwheel={onStageWheel}
1171
1484
  >
1172
1485
  <video
@@ -1175,7 +1488,13 @@
1175
1488
  bind:duration
1176
1489
  ontimeupdate={handleTimeUpdate}
1177
1490
  onloadedmetadata={onLoadedMetadata}
1178
- onseeked={syncUrlNow}
1491
+ onseeked={() => {
1492
+ syncUrlNow();
1493
+ if (nightPresent || guardPresent) nightTick();
1494
+ }}
1495
+ onplay={() => {
1496
+ if (nightPresent || guardPresent) nightTick();
1497
+ }}
1179
1498
  onpause={syncUrlNow}
1180
1499
  {src}
1181
1500
  {loop}
@@ -1194,8 +1513,16 @@
1194
1513
  menu but isn't painted on top of the frames' own text (jat-abwim). -->
1195
1514
  <track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc && captionsDefault} />
1196
1515
  </video>
1516
+ {#if nightPresent}
1517
+ <!-- Night veils. Both MULTIPLY: a grey scrim would raise the black
1518
+ point and fog the picture; multiplying leaves blacks black. At
1519
+ opacity 0 (their variables unset) they change no pixel. -->
1520
+ <div class="vp-night-layer vp-night-warm" aria-hidden="true"></div>
1521
+ <div class="vp-night-layer vp-night-vignette" aria-hidden="true"></div>
1522
+ {/if}
1197
1523
  <div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
1198
1524
  </div>
1525
+ </div>
1199
1526
 
1200
1527
  <!-- The band: one time axis for the envelope, chapters, speakers and
1201
1528
  bookmarks. Its height is reserved from the first frame, so data
@@ -1314,11 +1641,31 @@
1314
1641
 
1315
1642
  <div class="vp-times">
1316
1643
  <span>{formatTime(currentTime, useHours)}</span>
1317
- {#if isZoomedNow()}
1318
- <button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
1319
- Reset zoom
1320
- </button>
1321
- {/if}
1644
+ <span class="vp-times-mid">
1645
+ {#if isZoomedNow()}
1646
+ <button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
1647
+ Reset zoom
1648
+ </button>
1649
+ {/if}
1650
+ {#if nightPresent}
1651
+ <!-- The veil's switch: click (or N) cycles auto → on → off; scroll
1652
+ sets the depth. Lit in the ribbon's warm token while dimming. -->
1653
+ <button
1654
+ type="button"
1655
+ class="vp-night-btn"
1656
+ class:active={nightLayersOn}
1657
+ aria-label={nightLabel}
1658
+ title="{nightLabel} · N cycles, scroll sets depth"
1659
+ onclick={cycleNight}
1660
+ onwheel={onNightWheel}
1661
+ >
1662
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1663
+ <path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5Z" />
1664
+ {#if night === 'off'}<path d="M4 20 20 4" />{/if}
1665
+ </svg>
1666
+ </button>
1667
+ {/if}
1668
+ </span>
1322
1669
  <span>{formatTime(effectiveDuration, useHours)}</span>
1323
1670
  </div>
1324
1671
 
@@ -2053,4 +2400,153 @@
2053
2400
  transition: none;
2054
2401
  }
2055
2402
  }
2403
+
2404
+ /* ── Night (mos-5lzns.33) ─────────────────────────────────────────────
2405
+ Every value arrives as a custom property that exists ONLY while night
2406
+ (or the strobe guard) acts; unset, each layer falls back to identity —
2407
+ brightness(1), opacity 0 — so off changes no pixel. The long ramps are
2408
+ the point: the eye must never catch the change, only notice afterwards
2409
+ that the room got easier. */
2410
+
2411
+ /* The transition lives on the video whenever the feature exists and the
2412
+ filter only under .vp-night-on, so switching off eases back up the way
2413
+ it came down (a rule carrying both would snap on the way out). */
2414
+ .vp-night-capable .vp-video {
2415
+ transition: filter 900ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
2416
+ }
2417
+
2418
+ .vp-night-on .vp-video {
2419
+ filter: brightness(var(--vp-night-bright, 1)) saturate(var(--vp-night-sat, 1));
2420
+ }
2421
+
2422
+ .vp-night-layer {
2423
+ position: absolute;
2424
+ inset: 0;
2425
+ pointer-events: none;
2426
+ border-radius: inherit;
2427
+ mix-blend-mode: multiply;
2428
+ opacity: 0;
2429
+ transition: opacity 900ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
2430
+ }
2431
+
2432
+ /* Warmth: multiplying by the warm token (the ribbon's own colour, so the
2433
+ ribbon reads as "how much night is coming") leaves red intact and takes
2434
+ most of the blue — the night-shift lever, applied per pixel. */
2435
+ .vp-night-warm {
2436
+ background: var(--color-warning);
2437
+ opacity: var(--vp-night-warm, 0);
2438
+ }
2439
+
2440
+ /* A lens falloff, off-centre high where a face usually sits; nothing until
2441
+ 42% out, so the subject never darkens. */
2442
+ .vp-night-vignette {
2443
+ background: radial-gradient(
2444
+ ellipse 82% 86% at 50% 44%,
2445
+ transparent 42%,
2446
+ color-mix(in oklch, var(--color-neutral) 55%, transparent) 100%
2447
+ );
2448
+ opacity: var(--vp-night-vig, 0);
2449
+ }
2450
+
2451
+ /* Strobe guard: protection has to arrive before the flash, so this one
2452
+ moment is fast. Everything else about night is deliberately slow. */
2453
+ .vp-flash-guard .vp-video {
2454
+ transition-duration: 120ms;
2455
+ }
2456
+
2457
+ .vp-flash-guard .vp-night-layer {
2458
+ transition-duration: 160ms;
2459
+ }
2460
+
2461
+ .vp-stage-wrap {
2462
+ position: relative;
2463
+ }
2464
+
2465
+ /* Bias light: peripheral light, so the blur is large on purpose — any
2466
+ detail in it reads as a distracting second image. */
2467
+ .vp-bias {
2468
+ position: absolute;
2469
+ inset: -6% -4%;
2470
+ pointer-events: none;
2471
+ background-image: var(--vp-bias-image, none);
2472
+ filter: blur(46px) saturate(1.15);
2473
+ opacity: 0;
2474
+ transition: opacity 1200ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
2475
+ }
2476
+
2477
+ .vp-bias.on {
2478
+ opacity: 0.5;
2479
+ }
2480
+
2481
+ /* The surround. On a LIGHT theme a dimmed picture in a white frame makes
2482
+ the screen-to-surround ratio worse, so the player's own surface is pulled
2483
+ toward neutral with it (levels.ts surroundLevel — zero on dark themes,
2484
+ capped well short of dark because text sits here). */
2485
+ .vp-root.vp-night-capable {
2486
+ transition: background-color 900ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
2487
+ }
2488
+
2489
+ .vp-root.vp-night-surround {
2490
+ background-color: color-mix(
2491
+ in oklch,
2492
+ var(--color-base-200),
2493
+ var(--color-neutral) calc(var(--vp-night-surround, 0) * 100%)
2494
+ );
2495
+ }
2496
+
2497
+ .vp-times-mid {
2498
+ display: inline-flex;
2499
+ align-items: center;
2500
+ gap: var(--jatui-space-base, 8px);
2501
+ }
2502
+
2503
+ .vp-night-btn {
2504
+ display: inline-flex;
2505
+ align-items: center;
2506
+ justify-content: center;
2507
+ background: none;
2508
+ border: none;
2509
+ padding: var(--jatui-space-xs, 2px);
2510
+ border-radius: var(--jatui-radius-field, 0.25rem);
2511
+ color: inherit;
2512
+ cursor: pointer;
2513
+ transition: color var(--motion-move, 300ms) var(--motion-glide, ease-out);
2514
+ }
2515
+
2516
+ .vp-night-btn:hover {
2517
+ color: var(--color-base-content);
2518
+ }
2519
+
2520
+ .vp-night-btn:focus-visible {
2521
+ outline: 2px solid var(--color-primary);
2522
+ outline-offset: 1px;
2523
+ }
2524
+
2525
+ /* Lit in the veil's own warm token while dimming is actually happening. */
2526
+ .vp-night-btn.active {
2527
+ color: var(--color-warning);
2528
+ }
2529
+
2530
+ /* Under reduced motion the veil still dims — the dimming IS the feature —
2531
+ but the long 900ms/1200ms ramps shrink to a short luminance fade. A
2532
+ change in brightness is not motion, and a hard step every tick would be
2533
+ harsher on the eye than the fade it replaced. The strobe guard keeps its
2534
+ own faster ramp. (The composer also releases brightness a fraction per
2535
+ tick, so the end of a dim is never a jump back up.) */
2536
+ @media (prefers-reduced-motion: reduce) {
2537
+ .vp-night-capable .vp-video,
2538
+ .vp-night-layer,
2539
+ .vp-bias,
2540
+ .vp-root.vp-night-capable {
2541
+ transition-duration: 150ms;
2542
+ }
2543
+
2544
+ .vp-flash-guard .vp-video {
2545
+ transition-duration: 120ms;
2546
+ }
2547
+
2548
+ .vp-night-btn {
2549
+ transition: none;
2550
+ }
2551
+ }
2056
2552
  </style>