dsh-theme-endfield 1.2.2 → 1.2.4

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/client.js CHANGED
@@ -133,7 +133,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
133
133
 
134
134
  Both are loopback pages, so DSH resolves the connection to 'host' mode
135
135
  and the values land on disk; a change of port does not move them because
136
- nothing lives in browser storage any more.
136
+ nothing lives in browser storage any more. (0.2.1-rc.2 removed the `mode`
137
+ field from the form snapshot entirely — writes then always ride
138
+ `remote.settings.mutate()` to the Host document, so "is this page
139
+ loopback?" is no longer a question that generation can answer or needs.)
137
140
 
138
141
  Value model. Namespace fields are the tails of the old localStorage keys
139
142
  and are stored as the same strings, so the semantics (and any older
@@ -187,15 +190,17 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
187
190
  floating element the user did not ask for. */
188
191
  balanceCapsule: '0',
189
192
  /* 渠道额度胶囊的右侧百分比读数选「已用」还是「剩余」——两个值都有真实语义,
190
- 不存在默认就错的答案,所以默认 used(与插件自身徽章一致的读法)。
191
- 仅影响 credits 模式的右侧百分比槽位;左侧主数字始终为剩余额度。 */
193
+ 不存在默认就错的答案,所以默认 remaining(与左侧主数字的剩余额度语义一致,
194
+ 也与 features.md 的字段表一致)。仅影响 credits 模式的右侧百分比槽位。 */
192
195
  creditDisplay: 'remaining',
193
196
  /* 余额胶囊的停靠位 —— center(顶部居中)| left(靠左)| right(靠右)。
194
197
  仅当胶囊开启时有意义:选择靠边停靠可让开侧边栏与顶部工具栏的交叠区。 */
195
198
  balanceCapsulePosition: 'center',
196
199
  /* 防遮挡的两个子开关 —— 侧边栏展开自动隐藏 / 指针靠近自动隐藏。
197
- default-ON:用户抱怨的就是遮挡,两个行为同时默认生效;单独关掉
198
- 其中一个的出口保留在这里(开关语义与其余 default-ON 一致:!== '0')。 */
200
+ 侧栏展开是主要遮挡源但属于「用户主动打开」的动作,默认关;指针靠近
201
+ 则在用户不知情时收起内容,默认开。用户抱怨的遮挡由 hover-hide 兜底,
202
+ 单独关掉其中一个的出口保留在这里(开关语义不一致是有意的:
203
+ sidebarHide 读 === '1',hoverHide 读 !== '0',见 11-prefs-fields.js)。 */
199
204
  balanceCapsuleSidebarHide: '0',
200
205
  balanceCapsuleHoverHide: '1',
201
206
  /* 音频通知 (host half: lib/audio.js). Two live slots — the prompt that
@@ -305,13 +310,16 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
305
310
  buggy build already wrote: 'contourAnim' -> 'contour-anim'. Derived from
306
311
  the table (not from the schema, which cannot know it) so the two can never
307
312
  drift, and only for fields that really do have a distinct legacy double:
308
- every single-word field maps to itself and is skipped. */
313
+ every single-word field maps to itself and is skipped, and so is the bare
314
+ localStorage-era key ('contourRenderer') whose prefix-strip yields the
315
+ empty string — it never had a legacy spelling, and admitting '' as a
316
+ "legacy key" would put a nonsense entry in the map. */
309
317
  const PREFS_FIELD_TO_LEGACY_KEY = (() => {
310
318
  const m = {}
311
319
  for (const field of Object.keys(PREFS_KEY_TO_FIELD)) {
312
320
  const f = PREFS_KEY_TO_FIELD[field]
313
321
  const legacy = field.slice(PREFS_NS.length + 1)
314
- if (legacy !== f) m[f] = legacy
322
+ if (legacy !== '' && legacy !== f) m[f] = legacy
315
323
  }
316
324
  return m
317
325
  })()
@@ -370,7 +378,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
370
378
  const v = prefsGet(BALANCE_POSITION_KEY)
371
379
  return BALANCE_POSITION_OPTIONS.indexOf(v) >= 0 ? v : 'center'
372
380
  }
373
- /* 防遮挡子开关:default-ON(!== '0'),与主开关的读取惯例一致。 */
381
+ /* 防遮挡子开关。两者默认值不同,读取语义也随之不同:sidebarHide 默认关
382
+ (读 === '1'),hoverHide 默认开(读 !== '0')——见 10-prefs-store.js 的注释。 */
374
383
  const BALANCE_SIDEBAR_HIDE_KEY = 'balanceCapsuleSidebarHide'
375
384
  const BALANCE_HOVER_HIDE_KEY = 'balanceCapsuleHoverHide'
376
385
  const isBalanceSidebarHideOn = () => prefsGet(BALANCE_SIDEBAR_HIDE_KEY) === '1'
@@ -384,7 +393,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
384
393
  so session credentials and the host-side badge cache (TTL 120s, failure
385
394
  TTL 15s) come for free; the page only ever names the provider.
386
395
  The id table IS the plugin's contract: usage.badge answers for these
387
- twelve, and anything else — notably the built-in DeepSeek models — keeps
396
+ thirteen, and anything else — notably the built-in DeepSeek models — keeps
388
397
  the wallet display, so an absent plugin can never blank the capsule. */
389
398
  const JET_HUB_RPC_SCOPE = '/api'
390
399
  const JET_HUB_RPC_CHANNEL = 'jet-hub'
@@ -554,6 +563,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
554
563
  writable, mode }. set() returns Promise<boolean>: false
555
564
  means the Host refused or SKIPPED the write (the classic
556
565
  case being memory mode on a non-loopback page).
566
+ 0.2.1-rc.2 keeps this seam but DELETED `mode`: the
567
+ snapshot is { status, value, base, user, revision,
568
+ writable } and every accepted set() is a
569
+ remote.settings.mutate() against the Host document.
570
+ Treat a missing `mode` as durable; only an explicit
571
+ mode:'memory' (0.1.7 on a non-loopback page) refuses.
557
572
  <=0.1.5-rc.2 binder = ctx.get('settingsScope') / ctx.settingsScope
558
573
  binder.bind({ namespace, decode }) -> scope with the same
559
574
  snapshot fields, and a fire-and-forget set().
@@ -732,10 +747,9 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
732
747
  const dbg = (...a) => { try { if (typeof console !== 'undefined' && console.warn) console.warn('[dsh-theme-endfield:prefs]', ...a) } catch (e) { /* noop */ } }
733
748
 
734
749
  /* --- Durable write gate -----------------------------------------------
735
- A scope snapshot from @deepseek-ai/dsh-client-ui-settings carries three
736
- flags that must ALL hold before a scope.set can durably land:
737
- mode === 'host' loopback page syncing the Host document.
738
- 'memory' (non-loopback) never persists.
750
+ A scope snapshot from @deepseek-ai/dsh-client-ui-settings carries the
751
+ flags below; the ones that must ALL hold before a scope.set can durably
752
+ land are:
739
753
  status === 'ready' the Host's describe view actually SERVES this
740
754
  namespace and a decoded value stands. It stays
741
755
  'loading' before the first accepted section and
@@ -746,6 +760,23 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
746
760
  a 'loading'/'unavailable' host scope reaches no
747
761
  durable store.
748
762
  writable=== true the Host document accepts writes.
763
+ mode === 'host' OPTIONAL. Loopback page syncing the Host
764
+ document; 'memory' (a non-loopback page) never
765
+ persists. @deepseek-ai/dsh-client-ui-settings
766
+ 0.2.1-rc.2 DELETED this field (and the whole
767
+ `persistence` concept) along with the CSS
768
+ 'memory' mode, so on that generation the
769
+ snapshot is only { status, value, base, user,
770
+ revision, writable } and a missing `mode` means
771
+ "no persistence policy to consult": every
772
+ write rides ctx.remote.settings.mutate() to the
773
+ Host document. Only a snapshot that explicitly
774
+ says mode:'memory' (0.1.7-rc.*, a non-loopback
775
+ page) may refuse the write; `undefined` must be
776
+ treated as durable, or a 0.2.1 page would hold
777
+ every edit forever and log exactly the
778
+ `mode= undefined` boot report this gate is
779
+ meant to explain.
749
780
  Gating on snap.writable ALONE is the old bug: a host-mode describe view
750
781
  answers writable=true even while this namespace is still unserved, so the
751
782
  old code fired scope.set into a namespace the host had not registered,
@@ -759,7 +790,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
759
790
  if (!scope) return null
760
791
  try { return scope.getSnapshot() } catch (e) { return null }
761
792
  }
762
- const prefsDurablyServed = (snap) => !!snap && snap.mode === 'host' && snap.status === 'ready' && !!snap.writable
793
+ /* `mode` is a 0.1.7-only snapshot field (see the gate note above): it is
794
+ absent on 0.2.1-rc.2+, where absence means "always durable", and only an
795
+ explicit mode:'memory' still vetoes a write. */
796
+ const prefsDurablyServed = (snap) => !!snap
797
+ && (snap.mode === undefined || snap.mode === 'host')
798
+ && snap.status === 'ready'
799
+ && !!snap.writable
763
800
  /* One field write through the bound transport, with one settlement contract
764
801
  for both generations. The legacy scope.set() is fire-and-forget; a
765
802
  ConfigForm's set() returns Promise<boolean>, where false means the Host
@@ -772,6 +809,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
772
809
  promise resolves, so a synchronous dirty mark would make every toggle
773
810
  emit a redundant second write. A late repair beats a duplicate write.
774
811
  @returns true when the write was issued (not when it was accepted). */
812
+ /* Set by the dispose effect below. A scope.set() promise that settles after
813
+ the run ended must not re-arm the retry loop: every reader it would consult
814
+ is nulled, so the loop would tick its full budget against a dead run. */
815
+ let prefsDisposed = false
775
816
  const prefsWriteField = (field, value) => {
776
817
  const scope = prefsScope
777
818
  if (!scope || typeof scope.set !== 'function') return false
@@ -783,6 +824,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
783
824
  }
784
825
  if (result && typeof result.then === 'function') {
785
826
  result.then((ok) => {
827
+ if (prefsDisposed) return
786
828
  if (ok === false) {
787
829
  dbg('set REFUSED by the host', field, value)
788
830
  prefsDirty.add(field)
@@ -791,6 +833,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
791
833
  prefsDirty.delete(field)
792
834
  }
793
835
  }, (e) => {
836
+ if (prefsDisposed) return
794
837
  dbg('set REJECTED', field, value, String(e && e.message || e))
795
838
  prefsDirty.add(field)
796
839
  })
@@ -858,6 +901,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
858
901
  prefsRetryCount = 0
859
902
  }
860
903
  const prefsScheduleRetry = () => {
904
+ if (prefsDisposed) return
861
905
  // Nothing held any more: no reason to keep ticking.
862
906
  if (prefsDirty.size === 0) { prefsStopRetry(); return }
863
907
  // A durably-served scope needs no timer — its subscription replays fast.
@@ -1168,8 +1212,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
1168
1212
  it to guesswork. */
1169
1213
  const prefsReportBoot = () => {
1170
1214
  const snap = prefsSnapshotOf(prefsScope)
1171
- const healthy = snap !== null && snap.status === 'ready' && snap.mode === 'host'
1172
- && snap.writable === true && prefsDirty.size === 0
1215
+ // Health is the same predicate the write gate uses — `mode` is absent on
1216
+ // 0.2.1-rc.2+, so requiring mode:'host' here would print a boot report on
1217
+ // a perfectly working page (that is exactly the `mode= undefined` line
1218
+ // this report itself used to emit).
1219
+ const healthy = prefsDurablyServed(snap) && prefsDirty.size === 0
1173
1220
  if (healthy) return
1174
1221
  dbg('boot report (preferences did NOT reach a durable section):',
1175
1222
  'boundNs=', prefsScopeNs, 'kind=', prefsScopeKind,
@@ -1235,6 +1282,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
1235
1282
  // provider-owned, so leaving our listener behind would leak it into the
1236
1283
  // next run of this plugin in the same page.
1237
1284
  ctx.effect(() => () => {
1285
+ prefsDisposed = true
1238
1286
  prefsListeners.length = 0
1239
1287
  prefsReleaseSubscription()
1240
1288
  prefsScope = null
@@ -1812,6 +1860,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
1812
1860
  let contourTrailListening = false
1813
1861
  let contourTrailPainted = false
1814
1862
  let contourMotionQuery = null
1863
+ /* A DPR change (window moved between monitors, browser zoom) leaves the CSS
1864
+ geometry untouched, so the ResizeObserver never fires and the sheet keeps
1865
+ rasterizing at the old device ratio. The 'resolution' query fires exactly
1866
+ when devicePixelRatio changes. */
1867
+ let contourResolutionQuery = null
1815
1868
  /* Last applied animation state. Declared HERE, above every function that touches
1816
1869
  it, because contourTeardown() assigns it and is itself reachable from
1817
1870
  unmount() — a `let` declared further down would still be in its temporal dead
@@ -1827,9 +1880,9 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
1827
1880
  of one sheet roughly constant in frame AREA. The cap is set so every ordinary
1828
1881
  window keeps the shipped 6px grid byte-for-byte (1440x900 is 36k cells,
1829
1882
  1920x1080 is 58k); only genuinely large canvases coarsen, and the spline still
1830
- rounds the coarser polyline at a 1px stroke. It also keeps a large HiDPI
1831
- canvas inside the worker's own backing-store limit, so the sheet stays on the
1832
- off-main-thread path instead of falling back to the synchronous painter. */
1883
+ rounds the coarser polyline at a 1px stroke. (The worker's backing-store
1884
+ limit is a different axis — the canvas is frame-sized whatever the grid —
1885
+ and is handled where frames are sent; see contourWorkerDprFor below.) */
1833
1886
  const CONTOUR_MAX_CELLS = 60000
1834
1887
  /* Bump radius floor, in grid samples. The radius was `(0.05..0.14) * min(w, h)`,
1835
1888
  which ties the terrain's feature size to the frame's SHORTER side: in a short
@@ -1867,6 +1920,25 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
1867
1920
  while (step < 40 && cells(step) > CONTOUR_MAX_CELLS) step += 1
1868
1921
  return step
1869
1922
  }
1923
+ /* DEVICE-RATIO BUDGET for the worker renderer. The worker rejects any frame
1924
+ whose backing store would pass its 8M-pixel budget (src/contour/
1925
+ contour-worker.js), and a canvas at the frame's full device size blows past
1926
+ it on the common large/HiDPI viewports — 1080p@2x, 1440p@1.5x and 4K@1x are
1927
+ all ~8.3M device pixels, so the first frame threw 'invalid frame', latched
1928
+ the failure, and the advertised Worker/WebGL option never engaged for those
1929
+ users at all. The sampling step cannot help: the canvas is the whole frame
1930
+ at device resolution regardless of the grid. Scale the device ratio down to
1931
+ the budget instead of dooming the off-main-thread renderer — the canvas
1932
+ keeps its CSS size, and at these sizes the few percent of missing device
1933
+ pixels are invisible at a 1px stroke. Below the budget the ratio passes
1934
+ through unchanged, so every ordinary window is bit-identical. */
1935
+ const CONTOUR_WORKER_PIXEL_BUDGET = 8000000
1936
+ const contourWorkerDprFor = (w, h, dpr) => {
1937
+ /* 99% of the budget: the worker rounds each axis to whole device pixels,
1938
+ and the two roundings can push the product a fraction of a percent over. */
1939
+ const budget = Math.sqrt((CONTOUR_WORKER_PIXEL_BUDGET * 0.99) / (w * h))
1940
+ return budget < dpr ? Math.max(0.1, budget) : dpr
1941
+ }
1870
1942
  /* VIEWPORT-ADAPTIVE DENSITY. The shipped look was tuned on a desktop frame:
1871
1943
  20 levels over the +/-1.45 span and 22 gaussian bumps. Those are per-AREA
1872
1944
  constants, but the sheet is judged per-VIEWPORT -- a phone-width frame
@@ -2933,7 +3005,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
2933
3005
  const contourRefresh = (geometry) => {
2934
3006
  if (contourWorker !== null) {
2935
3007
  if (!contourGeom || document.hidden) return
2936
- const ratio = Math.min(2, Math.max(.1, window.devicePixelRatio || 1))
3008
+ const ratio = contourWorkerDprFor(contourGeom.w, contourGeom.h,
3009
+ Math.min(2, Math.max(.1, window.devicePixelRatio || 1)))
2937
3010
  const pending = contourWorker.pending
2938
3011
  contourWorker.pending = {w:contourGeom.w,h:contourGeom.h,dpr:ratio,phase:contourPhase,
2939
3012
  color:contourStroke(),geometry:geometry || !!(pending && pending.geometry)}
@@ -3025,6 +3098,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3025
3098
  contourSwitchSig = ''
3026
3099
  contourApplySwitches()
3027
3100
  }
3101
+ /* A DPR-only change re-rasterizes at the new ratio; a change landing during a
3102
+ scroll pause is remembered and applied by the resume path, exactly like a
3103
+ ResizeObserver callback. */
3104
+ const onContourResolutionChange = () => {
3105
+ if (contourHost === null) return
3106
+ if (contourScrollPaused) { contourResizePending = true; return }
3107
+ if (contourSizeTo(contourHost)) contourRefresh(true)
3108
+ }
3028
3109
  const contourSizeTo = (host) => {
3029
3110
  const r = host.getBoundingClientRect()
3030
3111
  const w = Math.max(1, Math.round(r.width))
@@ -3130,6 +3211,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3130
3211
  contourMotionQuery.removeEventListener('change', onContourEnvironmentChange)
3131
3212
  }
3132
3213
  contourMotionQuery = null
3214
+ if (contourResolutionQuery !== null && typeof contourResolutionQuery.removeEventListener === 'function') {
3215
+ contourResolutionQuery.removeEventListener('change', onContourResolutionChange)
3216
+ }
3217
+ contourResolutionQuery = null
3133
3218
  contourDisposeWorker()
3134
3219
  if (contourRo !== null) {
3135
3220
  contourRo.disconnect()
@@ -3149,6 +3234,15 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3149
3234
  landscape would visibly snap backwards before resuming. A fresh mount at
3150
3235
  phase 0 has no such transient — which is exactly what this restores. */
3151
3236
  contourPhase = 0
3237
+ /* A scroll pause engaged when the layer died must not outlive it: a remount
3238
+ would come up gated by contourWantsAnim's paused check with no scrollend
3239
+ ever coming to lift it (the scrolled element may itself be gone), so the
3240
+ sheet would render but never morph until the user scrolled again. The
3241
+ pending timer goes with the flags. */
3242
+ if (contourScrollTimer !== null && typeof clearTimeout === 'function') clearTimeout(contourScrollTimer)
3243
+ contourScrollTimer = null
3244
+ contourScrollPaused = false
3245
+ contourResizePending = false
3152
3246
  // Nothing is drawn any more, so the next mount must re-apply the switch rather
3153
3247
  // than trust a signature describing a canvas that no longer exists.
3154
3248
  contourSwitchSig = ''
@@ -3228,6 +3322,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3228
3322
  if (typeof contourMotionQuery.addEventListener === 'function') {
3229
3323
  contourMotionQuery.addEventListener('change', onContourEnvironmentChange)
3230
3324
  }
3325
+ contourResolutionQuery = window.matchMedia('(resolution)')
3326
+ if (typeof contourResolutionQuery.addEventListener === 'function') {
3327
+ contourResolutionQuery.addEventListener('change', onContourResolutionChange)
3328
+ }
3231
3329
  }
3232
3330
  contourStartWorker(line)
3233
3331
  wrap.setAttribute('data-endfield-renderer', contourWorker ? 'starting' : 'main-canvas2d')
@@ -3287,7 +3385,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3287
3385
  // Let the page observer declared above re-attach the layer as the app renders.
3288
3386
  contourSyncHook = syncContour
3289
3387
  /* BEGIN GENERATED CONTOUR WORKER */
3290
- const CONTOUR_WORKER_SOURCE = "/* Dedicated contour worker. Kernel is extracted from client.js by the build script.\n * MIT; original terrain Copyright (c) 2026 ymh0000123. */\nconst CONTOUR_STEP = 6, CONTOUR_LEVELS = 20, CONTOUR_SPAN = 1.45\nconst CONTOUR_MIN_LEN = 40, CONTOUR_MIN_RING_BOX = 21\nconst CONTOUR_KEEP_LEN = CONTOUR_MIN_LEN * 1.35, CONTOUR_KEEP_RING = CONTOUR_MIN_RING_BOX * 1.5\nconst CONTOUR_MIN_CROSSINGS = 3\n/* The kernel's own constants. The build script extracts FUNCTIONS only, so anything\n contourBuild / contourBuildCandidate / contourDrawLines close over has to be mirrored\n here by hand: the grid cap and the bump-radius floor, plus the Chaikin pass budget\n `smoothPath` reads. A missing name is a ReferenceError inside the worker. */\nconst CONTOUR_MAX_CELLS = 60000, CONTOUR_MIN_BUMPSAMPLES = 4\nconst CONTOUR_SMOOTH_FULL = 8000, CONTOUR_SMOOTH_LIMIT = 20000\nconst CONTOUR_MIN_INK = 80\n/* Viewport-adaptive density (see src/client/20-contour.js): contourBuildCandidate,\n contourCoverageScore and contourExtract all call these, so the mirrored names\n have to come across or the worker throws at its first frame. The expressions are\n identical to the kernel's. */\nconst CONTOUR_DENSITY_ANCHOR = 640\nconst contourMinSide = (w, h) => {\n return Math.min(w, h)\n}\nconst contourDensityT = (w, h) => {\n return Math.max(0, Math.min(1,\n (contourMinSide(w, h) - 320) / (CONTOUR_DENSITY_ANCHOR - 320)))\n}\nconst contourLevelsFor = (w, h) => {\n const t = contourDensityT(w, h)\n return Math.max(6, Math.round(10 + t * (CONTOUR_LEVELS - 10)))\n}\nconst contourBumpsFor = (w, h) => {\n const t = contourDensityT(w, h)\n return Math.max(6, Math.round(10 + t * (22 - 10)))\n}\nlet contourSeed = 1, contourField = null, contourGeom = null, contourPaths = []\nlet contourLineCv = null, canvas = null, painter = null, stroke = 'rgba(0,0,0,0)', rasterizer = null\nconst contourStroke = () => stroke\nconst contourRng = (seed) => {\n let a = seed >>> 0\n return () => {\n a = (a + 0x6D2B79F5) >>> 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n }\nconst contourStepFor = (w, h) => {\n let step = CONTOUR_STEP\n const cells = (s) => (Math.ceil(w / s) + 1) * (Math.ceil(h / s) + 1)\n while (step < 40 && cells(step) > CONTOUR_MAX_CELLS) step += 1\n return step\n }\nconst contourBuild = (w, h) => {\n /* ACCEPT-OR-REROLL. A random layout is not automatically a GOOD layout, and\n this is the concrete lesson from making the seed per-load: the old fixed\n seed had silently guaranteed a well-spread field, and once real randomness\n arrived, some layouts left regions with no contour lines at all. Measured on\n the 8x5 coverage grid (\"near-empty\" = under 0.6% ink):\n independent uniform placement 5 failures in 12 seeds (up to 3 cells)\n stratified placement alone 6 failures in 24 seeds (down to 0.00%)\n Stratification fixes clumping but cannot fix the real mechanism: lines\n appear only where the field CROSSES one of the 21 fixed levels, so a region\n that is locally flat between two levels is blank no matter how the bumps\n sit. Forcing a gradient steep enough to guarantee a crossing per cell would\n take ~9.5 parallel lines across the width, which reads as stripes, not\n terrain -- so distorting the field is the wrong lever.\n Instead the candidate layout is CHECKED against the same invariant the test\n asserts, and rejected if it fails. Each attempt is cheap (one field\n evaluation on a coarse grid, no extraction, no drawing) and bounded, so the\n worst case is a handful of evaluations at mount/resize time only.\n\n The two halves are BOTH load-bearing, which was verified rather than\n assumed -- with the validator in place but placement reverted to uniform,\n 4 of 8 loads exhausted the 12-attempt cap and shipped a fallback layout\n (one run in four still rendered a blank cell). Stratification is what makes\n an acceptable layout the common case: mean 2.5 candidates, max 6, never at\n the cap. Validation is what makes it a guarantee. */\n const attempts = 32\n const step = contourStepFor(w, h)\n let best = null\n for (let attempt = 0; attempt < attempts; attempt++) {\n const cand = contourBuildCandidate(w, h, attempt, step)\n const score = contourCoverageScore(cand, w, h)\n /* Prefer more crossing bands, then more ink in the thinnest region. The\n tiebreak only matters if every attempt fails validation and the best of the\n 32 ships — measured at 0 of 200 seeds with the floor in place. */\n if (best === null || score.worst > best.score.worst\n || (score.worst === best.score.worst && score.ink > best.score.ink)) best = { cand, score }\n // Comfortably above the 0.6%-ink failure line, in field terms: every cell\n // must contain a spread of values wider than one level gap, so at least one\n // level is guaranteed to cross it.\n if (score.ok) break\n }\n contourField = best.cand.field\n contourGeom = { w, h, cols: best.cand.cols, rows: best.cand.rows, step }\n }\nconst contourBuildCandidate = (w, h, salt, step) => {\n const cols = Math.ceil(w / step) + 1\n const rows = Math.ceil(h / step) + 1\n /* Bump count adapts to the viewport -- see the note on VIEWPORT-ADAPTIVE\n DENSITY. At or above the 640px short side this is the shipped 22, and a\n narrow frame gets fewer islands so they stay separated instead of\n stacking into bullseyes. */\n const K = contourBumpsFor(w, h)\n const rnd = contourRng((contourSeed + salt * 0x9E3779B1) >>> 0)\n const m = Math.min(w, h)\n const bx = new Float32Array(K), by = new Float32Array(K)\n const ba = new Float32Array(K), bs = new Float32Array(K)\n const dx = new Float32Array(K), dy = new Float32Array(K)\n /* STRATIFIED placement, not independent uniform draws.\n\n Uniform sampling clumps: measured over 12 random seeds it left up to 3\n near-empty cells. Jittered grid instead -- the viewport is cut into a\n near-square lattice of at least K cells and each bump is placed at a random\n point inside its own cell. That keeps placement random while making a large\n empty patch geometrically impossible. Cells are ordered by a Fisher-Yates\n shuffle so the bump INDEX carries no positional bias: index drives\n amplitude, radius and drift below, and walking cells in raster order would\n correlate \"left side of the screen\" with \"first sizes drawn\".\n The lattice spans the same -0.1..1.1 over-scan as before, so islands are\n still cut by the viewport edges rather than all sitting fully inside. */\n const gx = Math.max(1, Math.round(Math.sqrt(K * (w / Math.max(1, h)))))\n const gy = Math.max(1, Math.ceil(K / gx))\n const cells = []\n for (let j = 0; j < gy; j++) for (let i = 0; i < gx; i++) cells.push(i + j * gx)\n for (let i = cells.length - 1; i > 0; i--) {\n const j = Math.floor(rnd() * (i + 1))\n const t = cells[i]; cells[i] = cells[j]; cells[j] = t\n }\n const spanX = 1.2 * w, spanY = 1.2 * h\n for (let k = 0; k < K; k++) {\n const cell = cells[k % cells.length]\n const ci = cell % gx\n const cj = Math.floor(cell / gx)\n // Random point inside this cell, in the over-scanned -0.1..1.1 space.\n bx[k] = -0.1 * w + ((ci + rnd()) / gx) * spanX\n by[k] = -0.1 * h + ((cj + rnd()) / gy) * spanY\n // Mixed sign gives peaks AND basins; equal signs would read as one blob.\n ba[k] = (rnd() < 0.5 ? -1 : 1) * (0.6 + rnd() * 0.9)\n /* Radius: relative to the frame for the shipped look, but never below\n CONTOUR_MIN_BUMPSAMPLES grid samples or the field aliases — see the note on\n that constant. This floor is the fix for the tangled/piled-up lines. */\n bs[k] = Math.max(step * CONTOUR_MIN_BUMPSAMPLES, (0.05 + rnd() * 0.09) * m)\n dx[k] = rnd() * 2 - 1\n dy[k] = rnd() * 2 - 1\n }\n /* Base undulation: three long, low-amplitude sine ridges spanning the whole\n viewport. Reason this exists, from reviewing the first render: a sum of\n gaussians decays to EXACTLY zero between islands, so the field there is\n perfectly flat, no level ever crosses it, and the result had large blank\n patches that exposed the construction. Real terrain has no such voids. The\n ridges are far too gentle to create islands of their own — they just tilt\n the whole sheet enough that contour lines keep running through the gaps,\n which is what turns isolated bullseyes into one continuous landscape. */\n const W2 = new Float32Array(9)\n for (let i = 0; i < 3; i++) {\n W2[i * 3] = (0.35 + rnd() * 0.5) * (Math.PI * 2) / Math.max(1, w) // x freq\n W2[i * 3 + 1] = (0.35 + rnd() * 0.5) * (Math.PI * 2) / Math.max(1, h) // y freq\n W2[i * 3 + 2] = rnd() * Math.PI * 2 // phase\n }\n const hCount = (cols - 1) * rows\n const eCount = hCount + cols * (rows - 1)\n const field = {\n cols, rows, step, K, bx, by, ba, bs, dx, dy, hCount, W2,\n F: new Float32Array(cols * rows),\n previous: new Float32Array(cols * rows),\n hasPrevious: false,\n smooth: new Float32Array(cols * rows),\n // Exact row-block bounds include both rows and the shared right vertex.\n // They reject empty regions without changing retained cells or path order.\n blockCols: Math.ceil((cols - 1) / 16),\n blockMin: new Float32Array(Math.ceil((cols - 1) / 16) * (rows - 1)),\n blockMax: new Float32Array(Math.ceil((cols - 1) / 16) * (rows - 1)),\n boundsReady: false,\n ex: new Float32Array(eCount),\n ey: new Float32Array(eCount),\n es: new Int32Array(eCount).fill(-1),\n n1: new Int32Array(eCount).fill(-1),\n n2: new Int32Array(eCount).fill(-1),\n seen: new Int32Array(eCount).fill(-1),\n touched: new Int32Array(eCount),\n seq: 0,\n }\n return { field, cols, rows }\n }\nconst contourCoverageScore = (cand, w, h) => {\n const f = cand.field\n // Evaluate at phase 0: the accepted layout must be sound as first painted.\n const prev = contourField\n contourField = f\n contourEvaluate(0)\n contourField = prev\n const { cols, rows, F } = f\n const GX = 8, GY = 5\n const span = CONTOUR_SPAN\n /* Judge against the density this viewport will actually DRAW, not the\n shipped constant: a narrow frame extracts fewer levels, so its per-quad\n incidence count is proportionally smaller. Scoring it on CONTOUR_LEVELS\n would demand 2x the ink of a 10-level sheet and push the re-roll loop to\n its cap on every small window. */\n const levelStep = (span * 2) / contourLevelsFor(w, h)\n let worst = Infinity\n let ok = true\n let ink = Infinity\n for (let gy = 0; gy < GY; gy++) {\n for (let gx = 0; gx < GX; gx++) {\n const i0 = Math.floor(gx * (cols - 1) / GX), i1 = Math.ceil((gx + 1) * (cols - 1) / GX)\n const j0 = Math.floor(gy * (rows - 1) / GY), j1 = Math.ceil((gy + 1) * (rows - 1) / GY)\n let mn = Infinity, mx = -Infinity\n /* The ink proxy is accumulated over the QUADS of this region, in the same\n walk that takes its min/max — see CONTOUR_MIN_INK. */\n let regionInk = 0\n for (let j = j0; j <= j1 && j < rows; j++) {\n const row = j * cols\n for (let i = i0; i <= i1 && i < cols; i++) {\n const v = F[row + i]\n if (v < mn) mn = v\n if (v > mx) mx = v\n if (j < j1 && j < rows - 1 && i < i1 && i < cols - 1) {\n const a = v, b = F[row + i + 1]\n const c = F[row + cols + i], d = F[row + cols + i + 1]\n let qmn = a < b ? a : b, qmx = a > b ? a : b\n if (c < qmn) qmn = c; if (c > qmx) qmx = c\n if (d < qmn) qmn = d; if (d > qmx) qmx = d\n if (qmn < -span) qmn = -span\n if (qmx > span) qmx = span\n if (qmx > qmn) regionInk += Math.floor(qmx / levelStep) - Math.ceil(qmn / levelStep) + 1\n }\n }\n }\n // Clamp to the drawn level range: values beyond +/-SPAN produce no lines.\n const lo = Math.max(mn, -span), hi = Math.min(mx, span)\n // How many level boundaries fall inside this cell's clamped range.\n const crossings = hi <= lo ? 0\n : Math.floor(hi / levelStep) - Math.ceil(lo / levelStep) + 1\n if (crossings < worst) worst = crossings\n if (crossings < CONTOUR_MIN_CROSSINGS) ok = false\n if (regionInk < ink) ink = regionInk\n if (regionInk < CONTOUR_MIN_INK) ok = false\n }\n }\n return { ok, worst, ink }\n }\nconst contourEvaluate = (phase) => {\n const f = contourField\n if (f !== null) f.boundsReady = false\n if (f === null) return\n const { cols, rows, step, K, bx, by, ba, bs, dx, dy, F, W2 } = f\n /* Seed the sheet with the base undulation instead of zero, so the gaps\n between islands still have a gradient for the levels to cross. Separable\n evaluation: sin(a+b) is expanded so the y term is computed once per row\n rather than once per cell, which keeps this pass cheap. */\n const BASE = 0.62\n for (let i = 0; i < 3; i++) {\n const fx = W2[i * 3], fy = W2[i * 3 + 1], ph = W2[i * 3 + 2] + phase * 0.11\n const amp = BASE / 3\n for (let j = 0; j < rows; j++) {\n const yb = fy * (j * step) + ph\n const sy = Math.sin(yb), cy2 = Math.cos(yb)\n const row = j * cols\n for (let c2 = 0; c2 < cols; c2++) {\n const xb = fx * (c2 * step)\n // sin(xb + yb) without a per-cell sin() of the sum\n const v = Math.sin(xb) * cy2 + Math.cos(xb) * sy\n if (i === 0) F[row + c2] = amp * v\n else F[row + c2] += amp * v\n }\n }\n }\n for (let k = 0; k < K; k++) {\n const s = bs[k]\n const amp = s * 0.55\n const cx = bx[k] + Math.sin(phase * dx[k] + k * 1.7) * amp\n const cy = by[k] + Math.cos(phase * dy[k] + k * 2.3) * amp\n const a = ba[k]\n const inv = 1 / (2 * s * s)\n const rad = 2.6 * s\n let i0 = Math.floor((cx - rad) / step)\n let i1 = Math.ceil((cx + rad) / step)\n let j0 = Math.floor((cy - rad) / step)\n let j1 = Math.ceil((cy + rad) / step)\n if (i0 < 0) i0 = 0\n if (j0 < 0) j0 = 0\n if (i1 > cols - 1) i1 = cols - 1\n if (j1 > rows - 1) j1 = rows - 1\n for (let j = j0; j <= j1; j++) {\n const ddy = j * step - cy\n const dy2 = ddy * ddy\n const row = j * cols\n for (let i = i0; i <= i1; i++) {\n const ddx = i * step - cx\n const q = (ddx * ddx + dy2) * inv\n if (q < 6.76) {\n let weight = Math.exp(-q)\n if (q > 4.8) {\n const t = (q - 4.8) / (6.76 - 4.8)\n const fade = 1 - t * t * (3 - 2 * t)\n weight *= fade\n }\n F[row + i] += a * weight\n }\n }\n }\n }\n const smooth = f.smooth\n for (let pass = 0; pass < 5; pass++) {\n for (let j = 0; j < rows; j++) {\n const row = j * cols\n for (let i = 0; i < cols; i++) {\n const left = F[row + Math.max(0, i - 1)]\n const center = F[row + i]\n const right = F[row + Math.min(cols - 1, i + 1)]\n smooth[row + i] = (left + 2 * center + right) * 0.25\n }\n }\n for (let j = 0; j < rows; j++) {\n const row = j * cols\n const up = Math.max(0, j - 1) * cols\n const down = Math.min(rows - 1, j + 1) * cols\n for (let i = 0; i < cols; i++) {\n F[row + i] = (smooth[up + i] + 2 * smooth[row + i] + smooth[down + i]) * 0.25\n }\n }\n }\n /* Track the field continuously between animation samples. Marching squares\n can change an entire path at once when a saddle crosses a level; blending\n the sampled field keeps that topology change from appearing as a twitch. */\n if (f.hasPrevious && f.previous !== undefined) {\n for (let i = 0; i < F.length; i++) {\n f.previous[i] = f.previous[i] * 0.65 + F[i] * 0.35\n F[i] = f.previous[i]\n }\n } else if (f.previous !== undefined) {\n f.previous.set(F)\n }\n f.hasPrevious = true\n }\nconst contourExtractLevel = (L, out) => {\n const f = contourField\n const { cols, rows, step, F, ex, ey, es, n1, n2, seen, touched, hCount } = f\n const st = ++f.seq\n let tn = 0\n const pt = (id, i0, j0, i1, j1) => {\n if (es[id] === st) return id\n const a = F[j0 * cols + i0]\n const b = F[j1 * cols + i1]\n let t = (L - a) / (b - a)\n if (!(t >= 0)) t = 0\n else if (t > 1) t = 1\n ex[id] = (i0 + (i1 - i0) * t) * step\n ey[id] = (j0 + (j1 - j0) * t) * step\n es[id] = st\n n1[id] = -1\n n2[id] = -1\n touched[tn++] = id\n return id\n }\n const link = (a, b) => {\n if (n1[a] < 0) n1[a] = b\n else if (n2[a] < 0) n2[a] = b\n if (n1[b] < 0) n1[b] = a\n else if (n2[b] < 0) n2[b] = a\n }\n for (let j = 0; j < rows - 1; j++) {\n const row = j * cols\n for (let i = 0; i < cols - 1; i++) {\n if (f.boundsReady && i % 16 === 0) {\n const block = j * f.blockCols + Math.floor(i / 16)\n if (L <= f.blockMin[block] || L > f.blockMax[block]) {\n i = Math.min(i + 16, cols - 1) - 1\n continue\n }\n }\n const p0 = row + i\n const p1 = p0 + 1\n const p3 = p0 + cols\n const p2 = p3 + 1\n const v0 = F[p0], v1 = F[p1], v2 = F[p2], v3 = F[p3]\n let mn = v0, mx = v0\n if (v1 < mn) mn = v1; else if (v1 > mx) mx = v1\n if (v2 < mn) mn = v2; else if (v2 > mx) mx = v2\n if (v3 < mn) mn = v3; else if (v3 > mx) mx = v3\n // Whole cell on one side of the level: nothing crosses it.\n if (L <= mn || L > mx) continue\n const idx = (v0 > L ? 1 : 0) | (v1 > L ? 2 : 0) | (v2 > L ? 4 : 0) | (v3 > L ? 8 : 0)\n const T = () => pt(j * (cols - 1) + i, i, j, i + 1, j)\n const B = () => pt((j + 1) * (cols - 1) + i, i, j + 1, i + 1, j + 1)\n const Le = () => pt(hCount + j * cols + i, i, j, i, j + 1)\n const Ri = () => pt(hCount + j * cols + i + 1, i + 1, j, i + 1, j + 1)\n switch (idx) {\n case 1: case 14: link(T(), Le()); break\n case 2: case 13: link(T(), Ri()); break\n case 3: case 12: link(Le(), Ri()); break\n case 4: case 11: link(Ri(), B()); break\n case 6: case 9: link(T(), B()); break\n case 7: case 8: link(Le(), B()); break\n // Ambiguous saddles use the bilinear asymptotic decider. The sign of\n // a*c-b*d selects whether the diagonal high/low regions are connected;\n // using the cell average alone is wrong when opposite corners differ in\n // magnitude and produces the long V-shaped joins seen in the render.\n case 5: {\n const a = v0 - L, b = v1 - L, c = v2 - L, d = v3 - L\n const saddle = a * c - b * d\n if (saddle > 0) { link(T(), Ri()); link(Le(), B()) }\n else { link(T(), Le()); link(Ri(), B()) }\n break\n }\n case 10: {\n const a = v0 - L, b = v1 - L, c = v2 - L, d = v3 - L\n const saddle = a * c - b * d\n if (saddle < 0) { link(T(), Le()); link(Ri(), B()) }\n else { link(T(), Ri()); link(Le(), B()) }\n break\n }\n }\n }\n }\n const walk = (start) => {\n const path = []\n let cur = start\n let prev = -1\n for (;;) {\n path.push(ex[cur], ey[cur])\n seen[cur] = st\n const a = n1[cur]\n const b = n2[cur]\n let nx = -1\n if (a >= 0 && a !== prev && seen[a] !== st) nx = a\n else if (b >= 0 && b !== prev && seen[b] !== st) nx = b\n if (nx < 0) {\n // Closed loop: step back onto the first point so the ring has no gap.\n if ((a === start || b === start) && path.length > 4) path.push(ex[start], ey[start])\n break\n }\n prev = cur\n cur = nx\n }\n return path\n }\n /* Reject debris before it reaches the draw list. Judged on the path's\n ON-CANVAS geometry, so an off-grid sliver with no visible pixels is\n dropped even when its raw length looks respectable. See the note on\n CONTOUR_MIN_LEN / CONTOUR_MIN_RING_BOX for the measurements behind both\n thresholds. */\n const W = contourGeom !== null ? contourGeom.w : 0\n const H = contourGeom !== null ? contourGeom.h : 0\n const keep = (p) => {\n if (p.length < 8) return false\n // Visible length, plus the bounding box of the part actually on screen.\n let vis = 0\n let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity\n let seenIn = false\n for (let k = 0; k < p.length; k += 2) {\n const x = p[k], y = p[k + 1]\n const inside = x >= 0 && x <= W && y >= 0 && y <= H\n if (inside) {\n seenIn = true\n if (x < minx) minx = x\n if (x > maxx) maxx = x\n if (y < miny) miny = y\n if (y > maxy) maxy = y\n }\n if (k >= 2) {\n const px2 = p[k - 2], py2 = p[k - 1]\n const prevIn = px2 >= 0 && px2 <= W && py2 >= 0 && py2 <= H\n if (inside && prevIn) {\n const dx = x - px2, dy = y - py2\n vis += Math.sqrt(dx * dx + dy * dy)\n }\n }\n }\n if (!seenIn) return false // entirely off-canvas: pure debris\n if (vis < CONTOUR_KEEP_LEN) return false\n /* A tiny CLOSED ring is an apex bullseye and reads as a dot. Open chains of\n the same extent are left alone: they are the visible corner of a stroke\n that continues off-canvas, and clipping one would punch a hole in a line\n the user can see running to the edge. */\n const gapx = p[0] - p[p.length - 2]\n const gapy = p[1] - p[p.length - 1]\n const closed = (gapx * gapx + gapy * gapy) < 4\n if (closed && (maxx - minx) < CONTOUR_KEEP_RING\n && (maxy - miny) < CONTOUR_KEEP_RING) return false\n return true\n }\n /* TANGENCY NEEDLES. Where a level runs nearly TANGENT to the field, the true\n isoline has a smooth, very high curvature tip. Marching squares interpolates\n linearly on a 10px grid, so it cannot represent that tip: it emits a hairpin\n that goes out and comes straight back, with a BASE (the gap between the\n apex's two neighbours) far narrower than the 1px stroke. Measured on the real\n output, worst case: apex 6.26px out from a base of 0.831px.\n\n At that width the outbound and return strokes paint the SAME pixels, so the\n pair does not read as a narrow valley — only the protruding whisker shows,\n which is precisely the \"irregular sharp angle\" in issue #3. Smoothing cannot\n help: the midpoint spline faithfully reproduces a feature that is genuinely\n in the geometry, so it has to be removed here, at the source.\n\n The whole hairpin is collapsed (see the note on the merge below). Both tests\n are required and were measured over 24 frames (152.6k vertices, 1.18Mpx of\n ink):\n base < 2px the stroke cannot resolve it (a 1px line is ~1px wide)\n turn > 90 it doubles back rather than merely turning a corner\n That is 0.7 vertices per frame and 0.0037% of total ink -- artifact removal,\n not thinning. Real narrow features are untouched: turns over 90 degrees have\n a median base of 4.03px, well clear of the cutoff, and the whole 8-12px base\n band (29562 vertices) has a p99 turn of only 21.7 degrees. */\n const deneedle = (p) => {\n const n = p.length / 2\n if (n < 4) return p\n /* Scan first and return the ORIGINAL array when there is nothing to do, so\n the overwhelmingly common path allocates nothing at 24fps. */\n let found = false\n for (let k = 1; k < n - 1; k++) {\n const bx = p[(k + 1) * 2] - p[(k - 1) * 2]\n const by = p[(k + 1) * 2 + 1] - p[(k - 1) * 2 + 1]\n if (bx * bx + by * by >= 4) continue // base >= 2px: keep\n const ax = p[k * 2] - p[(k - 1) * 2]\n const ay = p[k * 2 + 1] - p[(k - 1) * 2 + 1]\n const cx = p[(k + 1) * 2] - p[k * 2]\n const cy = p[(k + 1) * 2 + 1] - p[k * 2 + 1]\n // turn > 90 degrees <=> the two segment vectors point against each other.\n if (ax * cx + ay * cy < 0) { found = true; break }\n }\n if (!found) return p\n const gx = p[0] - p[(n - 1) * 2]\n const gy = p[1] - p[(n - 1) * 2 + 1]\n const closed = (gx * gx + gy * gy) < 4\n /* COLLAPSE THE WHOLE NEEDLE, not just its tip. Dropping the apex alone leaves\n the base itself as a real segment, and that was measured to be worse than\n the disease: a 0.831px stub inherits the reversal as TWO ~78-degree turns\n (10.20 -> 0.83 -> 10.25px). The apex AND its far neighbour are therefore\n both consumed, and the surviving previous vertex is pulled onto the base\n midpoint -- a sub-pixel move (half of at most 2px) that no 1px stroke can\n show, leaving one smooth vertex where the hairpin was.\n Each test uses the SURVIVING previous vertex, so a run of needles collapses\n progressively instead of each test being fooled by a neighbour that is\n itself about to be consumed. */\n const q = [p[0], p[1]]\n let k = 1\n while (k < n - 1) {\n const px = q[q.length - 2], py = q[q.length - 1]\n const bx = p[(k + 1) * 2] - px, by = p[(k + 1) * 2 + 1] - py\n if (bx * bx + by * by < 4) {\n const ax = p[k * 2] - px, ay = p[k * 2 + 1] - py\n const cx = p[(k + 1) * 2] - p[k * 2], cy = p[(k + 1) * 2 + 1] - p[k * 2 + 1]\n if (ax * cx + ay * cy < 0) {\n if (k + 1 < n - 1) {\n q[q.length - 2] = (px + p[(k + 1) * 2]) / 2\n q[q.length - 1] = (py + p[(k + 1) * 2 + 1]) / 2\n k += 2\n continue\n }\n // The far neighbour is the final vertex, which must survive to keep an\n // endpoint (or a ring's closure) intact: consume only the apex.\n k += 1\n continue\n }\n }\n q.push(p[k * 2], p[k * 2 + 1])\n k += 1\n }\n q.push(p[(n - 1) * 2], p[(n - 1) * 2 + 1])\n /* A ring is closed by REPEATING its start vertex, and the merge above may have\n nudged that start. Re-anchor the repeat so the ring stays exactly closed and\n both keep() and the cyclic draw path still classify it as one. */\n if (closed) {\n q[q.length - 2] = q[0]\n q[q.length - 1] = q[1]\n }\n return q\n }\n // Open chains first (they have a free end), then whatever remains is a loop.\n // Doing it in this order stops a ring being entered mid-way and split in two.\n for (let k = 0; k < tn; k++) {\n const id = touched[k]\n if (seen[id] !== st && n2[id] < 0) {\n const p = deneedle(walk(id))\n if (keep(p)) out.push(p)\n }\n }\n for (let k = 0; k < tn; k++) {\n const id = touched[k]\n if (seen[id] !== st) {\n const p = deneedle(walk(id))\n if (keep(p)) out.push(p)\n }\n }\n }\nconst contourExtract = (phase, trailPoints, trailNow) => {\n if (contourField === null) return\n contourEvaluate(phase)\n if (trailPoints && trailPoints.length) contourOverlayTrail(contourField, trailPoints, trailNow)\n const f = contourField\n // One O(cells) range pass is shared by all 21 extraction levels. Scratch\n // survives across frames; no resolution, smoothing or density is reduced.\n for (let j = 0; j < f.rows - 1; j++) {\n const row = j * f.cols\n for (let block = 0; block < f.blockCols; block++) {\n const begin = block * 16\n const end = Math.min(begin + 16, f.cols - 1)\n let min = Infinity, max = -Infinity\n for (let i = begin; i <= end; i++) {\n const a = f.F[row + i], b = f.F[row + f.cols + i]\n if (a < min) min = a\n if (a > max) max = a\n if (b < min) min = b\n if (b > max) max = b\n }\n const k = j * f.blockCols + block\n f.blockMin[k] = min\n f.blockMax[k] = max\n }\n }\n f.boundsReady = true\n contourPaths = []\n const span = CONTOUR_SPAN\n /* Level count follows the viewport: the same VIEWPORT-ADAPTIVE DENSITY\n contourBuildCandidate() laid the bumps out with. At or above the 640px\n short side this is exactly the shipped loop, so desktop output is\n unchanged. */\n const levels = contourLevelsFor(contourGeom.w, contourGeom.h)\n const stepL = (span * 2) / levels\n for (let n = 0; n <= levels; n++) {\n contourExtractLevel(-span + n * stepL, contourPaths)\n }\n }\nconst contourDrawLines = () => {\n if (contourLineCv === null || contourGeom === null) return\n const ctx = contourLineCv.getContext('2d')\n if (!ctx) return\n const { w, h } = contourGeom\n /* Geometry stays in CSS px; scale the context to the backing store that\n contourSizeTo sized at (capped) devicePixelRatio, so strokes rasterise\n at device resolution instead of being upsampled into blur on HiDPI\n screens. Derived from the canvas itself, and skipped entirely at a 1x\n store or when the context has no setTransform (the spliced-in test\n harnesses), so a 1x render is byte-identical to before. */\n const scale = (w > 0 && typeof contourLineCv.width === 'number' && contourLineCv.width > 0 && contourLineCv.width !== w)\n ? contourLineCv.width / w : 1\n if (scale !== 1 && typeof ctx.setTransform === 'function') ctx.setTransform(scale, 0, 0, scale, 0, 0)\n ctx.clearRect(0, 0, w, h)\n ctx.strokeStyle = contourStroke()\n ctx.lineWidth = 1\n ctx.lineJoin = 'round'\n /* Marching squares emits one vertex per grid-cell edge. Three Chaikin passes\n cut local corners before the clamped cubic B-spline rounds broad bends.\n This reduces angularity without changing the field or adding another\n extraction pass. Open endpoints remain fixed; closed rings wrap cyclically.\n\n PASS COUNT IS SIZE-ADAPTIVE: see CONTOUR_SMOOTH_FULL. Every ordinary window\n keeps three passes; a bigger sheet drops to two and then one. */\n const smoothPath = (source) => {\n const count = source.length / 2\n if (count < 3) return source\n const closed = (source[0] - source[source.length - 2]) ** 2\n + (source[1] - source[source.length - 1]) ** 2 < 4\n /* Flat scratch throughout: the previous shape built one [x, y] array per\n point PER PASS — ~14 small arrays per source vertex, ~65k per sheet at\n 1440x900 — which is pure GC inside the animation frame on both painters. */\n let points = closed ? source.slice(0, source.length - 2) : source.slice()\n const passes = count <= CONTOUR_SMOOTH_FULL ? 3 : (count <= CONTOUR_SMOOTH_LIMIT ? 2 : 1)\n for (let pass = 0; pass < passes; pass++) {\n const n = points.length / 2\n const limit = closed ? n : n - 1\n const next = new Array(n * 4)\n let w2 = 0\n if (!closed) { next[w2++] = points[0]; next[w2++] = points[1] }\n for (let k = 0; k < limit; k++) {\n const a = k * 2\n const b = ((k + 1) % n) * 2\n const ax = points[a], ay = points[a + 1]\n const bx = points[b], by = points[b + 1]\n next[w2++] = ax * 0.75 + bx * 0.25\n next[w2++] = ay * 0.75 + by * 0.25\n next[w2++] = ax * 0.25 + bx * 0.75\n next[w2++] = ay * 0.25 + by * 0.75\n }\n if (!closed) { next[w2++] = points[points.length - 2]; next[w2++] = points[points.length - 1] }\n points = w2 === next.length ? next : next.slice(0, w2)\n }\n if (!closed) return points\n // Re-anchor the repeated start vertex so the ring stays exactly closed.\n const out = points.slice()\n out.push(out[0], out[1])\n return out\n }\n /* Chaikin removes local grid noise. A constrained Catmull-Rom cubic then\n gives each join one shared tangent. The handle cap prevents overshoot at\n narrow saddles while the larger tangent factor removes long rounded-polygon\n bends that remain visible with midpoint quadratics. */\n const drawSmoothPath = (source) => {\n const count = source.length / 2\n if (count < 3) {\n ctx.moveTo(source[0], source[1])\n for (let k = 2; k < source.length; k += 2) ctx.lineTo(source[k], source[k + 1])\n return\n }\n const closed = (source[0] - source[source.length - 2]) ** 2\n + (source[1] - source[source.length - 1]) ** 2 < 4\n const limit = closed ? count - 1 : count\n const at = (index) => {\n const k = closed\n ? (index + limit) % limit\n : Math.max(0, Math.min(limit - 1, index))\n return k * 2\n }\n /* Tangents from scalars, with squared lengths wherever the comparison allows\n it. The previous shape allocated eight [x, y] arrays and ran up to six\n Math.hypot per SEGMENT — tens of thousands of segments per frame, on both\n the WebGL painter and the canvas fallback. Math.sqrt replaces hypot because\n every argument here is a pixel delta, far from hypot's overflow range. */\n const tangentAt = (index, out) => {\n const i = at(index)\n const p = at(index - 1)\n const n = at(index + 1)\n const cx = source[i], cy = source[i + 1]\n const inX = cx - source[p], inY = cy - source[p + 1]\n const outX = source[n] - cx, outY = source[n + 1] - cy\n let tx, ty, cap\n if (!closed && index === 0) {\n tx = outX * 0.4\n ty = outY * 0.4\n cap = Math.sqrt(outX * outX + outY * outY) * 0.55\n } else if (!closed && index === limit - 1) {\n tx = inX * 0.4\n ty = inY * 0.4\n cap = Math.sqrt(inX * inX + inY * inY) * 0.55\n } else {\n tx = (source[n] - source[p]) * 0.32\n ty = (source[n + 1] - source[p + 1]) * 0.32\n cap = Math.sqrt(Math.min(inX * inX + inY * inY, outX * outX + outY * outY)) * 0.62\n }\n const len2 = tx * tx + ty * ty\n if (len2 > cap * cap && len2 > 0) {\n const s = cap / Math.sqrt(len2)\n tx *= s\n ty *= s\n }\n out[0] = tx\n out[1] = ty\n }\n ctx.moveTo(source[0], source[1])\n const segments = closed ? limit : limit - 1\n const t0 = [0, 0]\n const t1 = [0, 0]\n for (let k = 0; k < segments; k++) {\n const s = at(k)\n const e = at(k + 1)\n tangentAt(k, t0)\n tangentAt(k + 1, t1)\n ctx.bezierCurveTo(\n source[s] + t0[0], source[s + 1] + t0[1],\n source[e] - t1[0], source[e + 1] - t1[1],\n source[e], source[e + 1],\n )\n }\n /* Mark a ring as a RING. The cyclic tangents above already make the seam C1\n and the final span already lands exactly on the start point, so this adds\n no geometry — but without it the canvas treats the path as open and butts\n two caps together at the seam instead of joining them, which is defect (2)\n of issue #3. contour-cusps.test.js guards this. */\n if (closed) ctx.closePath()\n }\n const isRing = (p) => {\n const n = p.length - 2\n return n >= 2 && (p[0] - p[n]) ** 2 + (p[1] - p[n + 1]) ** 2 < 4\n }\n /* ONE STROKE, RINGS FIRST — and the ORDER matters for the canvas fallback, not\n for looks. `closePath()` on a real 2D context finalises the current subpath by\n splicing it into the path accumulated so far, so its cost grows with\n everything already in that path (measured on the canvas fallback: 18 calls per\n sheet averaging 0.278 ms, max 1.2 ms, ~16% of the frame). Drawing the rings\n while the path still holds only other rings bounds every splice to ring\n geometry. Output is unchanged either way: canvas stroke coverage is a union,\n and the WebGL painter blends with MAX (commutative), so subpath order cannot\n affect the result. Stroking each path separately would also fix the cost but\n double-composites overlapping strokes and visibly darkens every crossing —\n the very \"piled-up lines\" look this avoids. */\n ctx.beginPath()\n for (let i = 0; i < contourPaths.length; i++) {\n const p = contourPaths[i]\n if (isRing(p)) drawSmoothPath(smoothPath(p))\n }\n for (let i = 0; i < contourPaths.length; i++) {\n const p = contourPaths[i]\n if (!isRing(p)) drawSmoothPath(smoothPath(p))\n }\n ctx.stroke()\n }\n/* MIT; contributed by higekibaka. GPU stroke painter from Endfield Glass. */\nconst CURVE_TOLERANCE_PX = 0.04;\nconst STRIDE = 6;\nclass StrokeSegments {\n data = new Float32Array(4096 * STRIDE);\n used = 0;\n tolerance = CURVE_TOLERANCE_PX;\n x = 0;\n y = 0;\n startX = 0;\n startY = 0;\n first = 0;\n reset() {\n this.used = 0;\n this.first = 0;\n }\n moveTo(x, y) {\n this.x = this.startX = x;\n this.y = this.startY = y;\n this.first = this.used;\n }\n lineTo(x, y) {\n if (x === this.x && y === this.y) return;\n if (this.used + STRIDE > this.data.length) {\n const next = new Float32Array(this.data.length * 2);\n next.set(this.data);\n this.data = next;\n }\n const i = this.used;\n this.data[i] = this.x;\n this.data[i + 1] = this.y;\n this.data[i + 2] = x;\n this.data[i + 3] = y;\n this.data[i + 4] = i === this.first ? 1 : 0;\n this.data[i + 5] = 1;\n if (i > this.first) this.data[i - 1] = 0;\n this.used += STRIDE;\n this.x = x;\n this.y = y;\n }\n closePath() {\n this.lineTo(this.startX, this.startY);\n if (this.used > this.first) {\n this.data[this.first + 4] = 0;\n this.data[this.used - 1] = 0;\n }\n }\n bezierCurveTo(ax, ay, bx, by, x, y) {\n this.cubic(this.x, this.y, ax, ay, bx, by, x, y, 0);\n }\n cubic(x0, y0, x1, y1, x2, y2, x3, y3, depth) {\n const dx = x3 - x0, dy = y3 - y0, length2 = dx * dx + dy * dy;\n const tolerance2 = this.tolerance * this.tolerance;\n const t1 = length2 > 0 ? Math.max(0, Math.min(1, ((x1 - x0) * dx + (y1 - y0) * dy) / length2)) : 0;\n const t2 = length2 > 0 ? Math.max(0, Math.min(1, ((x2 - x0) * dx + (y2 - y0) * dy) / length2)) : 0;\n const e1x = x1 - x0 - t1 * dx, e1y = y1 - y0 - t1 * dy;\n const e2x = x2 - x0 - t2 * dx, e2y = y2 - y0 - t2 * dy;\n if (e1x * e1x + e1y * e1y <= tolerance2 && e2x * e2x + e2y * e2y <= tolerance2 || depth >= 16) {\n this.lineTo(x3, y3);\n return;\n }\n const a = (x0 + x1) / 2, b = (y0 + y1) / 2, c = (x1 + x2) / 2, d = (y1 + y2) / 2;\n const e = (x2 + x3) / 2, f = (y2 + y3) / 2, g = (a + c) / 2, h = (b + d) / 2;\n const i = (c + e) / 2, j = (d + f) / 2, k = (g + i) / 2, l = (h + j) / 2;\n this.cubic(x0, y0, a, b, g, h, k, l, depth + 1);\n this.cubic(k, l, i, j, e, f, x3, y3, depth + 1);\n }\n}\nfunction parseStrokeColor(value) {\n const m = value.match(/^rgba?\\(\\s*([\\d.]+)\\s*,\\s*([\\d.]+)\\s*,\\s*([\\d.]+)(?:\\s*,\\s*([\\d.]+))?\\s*\\)$/);\n if (!m) return null;\n const values = [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255, m[4] === void 0 ? 1 : Number(m[4])];\n return values.every((v) => Number.isFinite(v) && v >= 0 && v <= 1) ? values : null;\n}\nconst VERTEX = `#version 300 es\nprecision highp float;\nlayout(location=0) in vec4 segment;\nlayout(location=1) in vec2 caps;\nuniform vec2 viewportSize;\nuniform vec2 scale;\nuniform float width;\nout vec2 local;\nflat out float segmentLength;\nflat out float radius;\nflat out vec2 endCaps;\nvoid main() {\n vec2 a = segment.xy * scale, b = segment.zw * scale;\n vec2 delta = b-a;\n segmentLength = max(length(delta), 0.000001);\n vec2 tangent = delta / segmentLength;\n vec2 normal = vec2(-tangent.y, tangent.x);\n radius = 0.5 * width * scale.x * scale.y * length(segment.zw-segment.xy) / segmentLength;\n float margin = radius + 1.0;\n // Two triangles per segment, generated without a second vertex buffer.\n vec2 corners[6] = vec2[6](vec2(0,-1),vec2(1,-1),vec2(0,1),vec2(0,1),vec2(1,-1),vec2(1,1));\n vec2 corner = corners[gl_VertexID];\n local = vec2(mix(-margin, segmentLength+margin, corner.x), corner.y * margin);\n vec2 pixel = a + tangent * local.x + normal * local.y;\n gl_Position = vec4(pixel.x/viewportSize.x*2.0-1.0, 1.0-pixel.y/viewportSize.y*2.0, 0, 1);\n endCaps = caps;\n}`;\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nin vec2 local;\nflat in float segmentLength;\nflat in float radius;\nflat in vec2 endCaps;\nuniform vec4 color;\nout vec4 outputColor;\nvoid main() {\n vec2 nearest = vec2(clamp(local.x, 0.0, segmentLength), 0);\n float distance = length(local-nearest)-radius;\n if (endCaps.x > 0.5) distance = max(distance, -local.x);\n if (endCaps.y > 0.5) distance = max(distance, local.x-segmentLength);\n float coverage = clamp(0.5-distance, 0.0, 1.0);\n float alpha = color.a * coverage;\n outputColor = vec4(color.rgb * alpha, alpha);\n}`;\nfunction createWebGLContourContext(canvas, onContextLost) {\n const gl = canvas.getContext(\"webgl2\", {\n alpha: true,\n premultipliedAlpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n preserveDrawingBuffer: false\n });\n if (!gl) return null;\n let program = null;\n let buffer = null;\n let vao = null;\n const shaders = [];\n const events = canvas;\n const lost = () => onContextLost?.();\n const releaseContext = gl.getExtension(\"WEBGL_lose_context\");\n const release = () => {\n events.removeEventListener?.(\"webglcontextlost\", lost);\n if (buffer) gl.deleteBuffer(buffer);\n if (vao) gl.deleteVertexArray(vao);\n if (program) gl.deleteProgram(program);\n for (const shader of shaders) gl.deleteShader(shader);\n buffer = null;\n vao = null;\n program = null;\n shaders.length = 0;\n releaseContext?.loseContext();\n };\n try {\n const compile = (type, source) => {\n const shader = gl.createShader(type);\n if (!shader) throw new Error(\"WebGL shader allocation failed\");\n shaders.push(shader);\n gl.shaderSource(shader, source);\n gl.compileShader(shader);\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(\"Contour shader: \" + gl.getShaderInfoLog(shader));\n return shader;\n };\n const vs = compile(gl.VERTEX_SHADER, VERTEX), fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);\n program = gl.createProgram();\n buffer = gl.createBuffer();\n vao = gl.createVertexArray();\n if (!program || !buffer || !vao) throw new Error(\"WebGL allocation failed\");\n gl.attachShader(program, vs);\n gl.attachShader(program, fs);\n gl.linkProgram(program);\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(\"Contour shader link: \" + gl.getProgramInfoLog(program));\n gl.useProgram(program);\n gl.bindVertexArray(vao);\n gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n gl.enableVertexAttribArray(0);\n gl.vertexAttribPointer(0, 4, gl.FLOAT, false, STRIDE * 4, 0);\n gl.vertexAttribDivisor(0, 1);\n gl.enableVertexAttribArray(1);\n gl.vertexAttribPointer(1, 2, gl.FLOAT, false, STRIDE * 4, 16);\n gl.vertexAttribDivisor(1, 1);\n gl.disable(gl.DEPTH_TEST);\n gl.disable(gl.CULL_FACE);\n gl.disable(gl.DITHER);\n gl.enable(gl.BLEND);\n gl.blendEquation(gl.MAX);\n gl.blendFunc(gl.ONE, gl.ONE);\n const viewport = gl.getUniformLocation(program, \"viewportSize\");\n const scaling = gl.getUniformLocation(program, \"scale\");\n const color = gl.getUniformLocation(program, \"color\");\n const width = gl.getUniformLocation(program, \"width\");\n const segments = new StrokeSegments();\n let sx = 1, sy = 1, capacity = 0, disposed = false;\n const context = {\n strokeStyle: \"rgba(16,17,16,0.16)\",\n lineWidth: 1,\n lineJoin: \"round\",\n setTransform(a, b, c, d, e, f) {\n if (b !== 0 || c !== 0 || e !== 0 || f !== 0 || a <= 0 || d <= 0) throw new Error(\"Unsupported contour transform\");\n sx = a;\n sy = d;\n segments.tolerance = CURVE_TOLERANCE_PX / Math.max(sx, sy);\n },\n clearRect() {\n if (disposed || gl.isContextLost()) throw new Error(\"Contour WebGL context lost\");\n gl.viewport(0, 0, canvas.width, canvas.height);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n },\n beginPath: () => segments.reset(),\n moveTo: (x, y) => segments.moveTo(x, y),\n lineTo: (x, y) => segments.lineTo(x, y),\n bezierCurveTo: (a, b, c, d, e, f) => segments.bezierCurveTo(a, b, c, d, e, f),\n closePath: () => segments.closePath(),\n stroke() {\n const rgba = parseStrokeColor(context.strokeStyle);\n if (!rgba) throw new Error(\"Unsupported contour stroke color\");\n if (segments.used === 0) {\n gl.flush();\n return;\n }\n gl.uniform2f(viewport, canvas.width, canvas.height);\n gl.uniform2f(scaling, sx, sy);\n gl.uniform4fv(color, rgba);\n gl.uniform1f(width, context.lineWidth);\n if (capacity < segments.data.byteLength) {\n capacity = segments.data.byteLength;\n gl.bufferData(gl.ARRAY_BUFFER, capacity, gl.STREAM_DRAW);\n }\n gl.bufferSubData(gl.ARRAY_BUFFER, 0, segments.data, 0, segments.used);\n gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, segments.used / STRIDE);\n gl.flush();\n },\n dispose() {\n if (disposed) return;\n disposed = true;\n segments.data = new Float32Array(0);\n segments.used = 0;\n release();\n }\n };\n events.addEventListener?.(\"webglcontextlost\", lost);\n return context;\n } catch (error) {\n release();\n throw error;\n }\n}\n\nself.onmessage = ({data}) => {\n try {\n if (data.type === 'init') {\n if (canvas !== null) throw new Error('duplicate worker init')\n canvas = data.canvas; contourSeed = data.seed\n painter = createWebGLContourContext(canvas, () => self.postMessage({type:'error',message:'WebGL context lost'}))\n rasterizer = painter ? 'worker-webgl2' : 'worker-canvas2d'\n if (!painter) painter = canvas.getContext('2d', {willReadFrequently:true})\n if (!painter) throw new Error('worker canvas unavailable')\n contourLineCv = {getContext:()=>painter, get width(){return canvas.width},get height(){return canvas.height}}\n self.postMessage({type:'initialized',rasterizer})\n } else if (data.type === 'frame') {\n if (!canvas || !painter) throw new Error('worker not initialized')\n const {w,h,dpr,phase,geometry,color,seq}=data\n if (![w,h,dpr,phase,seq].every(Number.isFinite) || w<1 || h<1 || dpr<=0 || dpr>2\n || Math.round(w*dpr)*Math.round(h*dpr)>8000000 || !/^#[0-9a-f]{8}$/i.test(color)) throw new Error('invalid frame')\n const resized = !contourGeom || contourGeom.w!==w || contourGeom.h!==h\n if (resized) contourBuild(w,h)\n const width=Math.round(w*dpr),height=Math.round(h*dpr)\n if(canvas.width!==width)canvas.width=width\n if(canvas.height!==height)canvas.height=height\n const c=color.slice(1)\n stroke=`rgba(${parseInt(c.slice(0,2),16)},${parseInt(c.slice(2,4),16)},${parseInt(c.slice(4,6),16)},${parseInt(c.slice(6,8),16)/255})`\n if(geometry || resized)contourExtract(phase)\n painter.setTransform(1,0,0,1,0,0)\n contourDrawLines()\n self.postMessage({type:'painted',seq,rasterizer})\n } else if (data.type === 'dispose') {\n if(painter && painter.dispose)painter.dispose()\n contourField=null;contourPaths=[];painter=null;canvas=null\n self.close()\n }\n } catch(error) { self.postMessage({type:'error',message:String(error.message || error)}) }\n}\nself.postMessage({type:'ready'})\n"
3388
+ const CONTOUR_WORKER_SOURCE = "/* Dedicated contour worker. Kernel is extracted from client.js by the build script.\n * MIT; original terrain Copyright (c) 2026 ymh0000123. */\nconst CONTOUR_STEP = 6, CONTOUR_LEVELS = 20, CONTOUR_SPAN = 1.45\nconst CONTOUR_MIN_LEN = 40, CONTOUR_MIN_RING_BOX = 21\nconst CONTOUR_KEEP_LEN = CONTOUR_MIN_LEN * 1.35, CONTOUR_KEEP_RING = CONTOUR_MIN_RING_BOX * 1.5\nconst CONTOUR_MIN_CROSSINGS = 3\n/* The kernel's own constants. The build script extracts FUNCTIONS only, so anything\n contourBuild / contourBuildCandidate / contourDrawLines close over has to be mirrored\n here by hand: the grid cap and the bump-radius floor, plus the Chaikin pass budget\n `smoothPath` reads. A missing name is a ReferenceError inside the worker. */\nconst CONTOUR_MAX_CELLS = 60000, CONTOUR_MIN_BUMPSAMPLES = 4\nconst CONTOUR_SMOOTH_FULL = 8000, CONTOUR_SMOOTH_LIMIT = 20000\nconst CONTOUR_MIN_INK = 80\n/* Viewport-adaptive density (see src/client/20-contour.js): contourBuildCandidate,\n contourCoverageScore and contourExtract all call these, so the mirrored names\n have to come across or the worker throws at its first frame. The expressions are\n identical to the kernel's. */\nconst CONTOUR_DENSITY_ANCHOR = 640\nconst contourMinSide = (w, h) => {\n return Math.min(w, h)\n}\nconst contourDensityT = (w, h) => {\n return Math.max(0, Math.min(1,\n (contourMinSide(w, h) - 320) / (CONTOUR_DENSITY_ANCHOR - 320)))\n}\nconst contourLevelsFor = (w, h) => {\n const t = contourDensityT(w, h)\n return Math.max(6, Math.round(10 + t * (CONTOUR_LEVELS - 10)))\n}\nconst contourBumpsFor = (w, h) => {\n const t = contourDensityT(w, h)\n return Math.max(6, Math.round(10 + t * (22 - 10)))\n}\nlet contourSeed = 1, contourField = null, contourGeom = null, contourPaths = []\nlet contourLineCv = null, canvas = null, painter = null, stroke = 'rgba(0,0,0,0)', rasterizer = null\nconst contourStroke = () => stroke\nconst contourRng = (seed) => {\n let a = seed >>> 0\n return () => {\n a = (a + 0x6D2B79F5) >>> 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n }\nconst contourStepFor = (w, h) => {\n let step = CONTOUR_STEP\n const cells = (s) => (Math.ceil(w / s) + 1) * (Math.ceil(h / s) + 1)\n while (step < 40 && cells(step) > CONTOUR_MAX_CELLS) step += 1\n return step\n }\nconst contourOverlayTrail = (field, points, now) => {\n const { cols, rows, step, F } = field\n // A smooth Gaussian is added AFTER the ambient smoothing/EMA. It never\n // enters previous, so it responds immediately and leaves no temporal ghost.\n // 28 CSS px approximates the original 26 px kernel after spatial smoothing.\n const sigma = 28, radius = sigma * Math.sqrt(2 * 6.76)\n const inv = 1 / (2 * sigma * sigma)\n for (let rank = 0; rank < points.length; rank++) {\n const point = points[points.length - 1 - rank]\n const age = now - point.t\n if (age < 0 || age > 2700) continue\n const quota = rank === 0 ? .9 : .6 * .2 * Math.pow(.8, rank - 1)\n const amplitude = quota * Math.exp(-age / 900)\n const i0 = Math.max(0, Math.floor((point.x - radius) / step))\n const i1 = Math.min(cols - 1, Math.ceil((point.x + radius) / step))\n const j0 = Math.max(0, Math.floor((point.y - radius) / step))\n const j1 = Math.min(rows - 1, Math.ceil((point.y + radius) / step))\n for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {\n const q = ((i * step - point.x) ** 2 + (j * step - point.y) ** 2) * inv\n if (q >= 6.76) continue\n const t = Math.max(0, (q - 4.8) / (6.76 - 4.8))\n F[j * cols + i] += amplitude * Math.exp(-q) * (1 - t * t * (3 - 2 * t))\n }\n }\n }\nconst contourBuild = (w, h) => {\n /* ACCEPT-OR-REROLL. A random layout is not automatically a GOOD layout, and\n this is the concrete lesson from making the seed per-load: the old fixed\n seed had silently guaranteed a well-spread field, and once real randomness\n arrived, some layouts left regions with no contour lines at all. Measured on\n the 8x5 coverage grid (\"near-empty\" = under 0.6% ink):\n independent uniform placement 5 failures in 12 seeds (up to 3 cells)\n stratified placement alone 6 failures in 24 seeds (down to 0.00%)\n Stratification fixes clumping but cannot fix the real mechanism: lines\n appear only where the field CROSSES one of the 21 fixed levels, so a region\n that is locally flat between two levels is blank no matter how the bumps\n sit. Forcing a gradient steep enough to guarantee a crossing per cell would\n take ~9.5 parallel lines across the width, which reads as stripes, not\n terrain -- so distorting the field is the wrong lever.\n Instead the candidate layout is CHECKED against the same invariant the test\n asserts, and rejected if it fails. Each attempt is cheap (one field\n evaluation on a coarse grid, no extraction, no drawing) and bounded, so the\n worst case is a handful of evaluations at mount/resize time only.\n\n The two halves are BOTH load-bearing, which was verified rather than\n assumed -- with the validator in place but placement reverted to uniform,\n 4 of 8 loads exhausted the 12-attempt cap and shipped a fallback layout\n (one run in four still rendered a blank cell). Stratification is what makes\n an acceptable layout the common case: mean 2.5 candidates, max 6, never at\n the cap. Validation is what makes it a guarantee. */\n const attempts = 32\n const step = contourStepFor(w, h)\n let best = null\n for (let attempt = 0; attempt < attempts; attempt++) {\n const cand = contourBuildCandidate(w, h, attempt, step)\n const score = contourCoverageScore(cand, w, h)\n /* Prefer more crossing bands, then more ink in the thinnest region. The\n tiebreak only matters if every attempt fails validation and the best of the\n 32 ships — measured at 0 of 200 seeds with the floor in place. */\n if (best === null || score.worst > best.score.worst\n || (score.worst === best.score.worst && score.ink > best.score.ink)) best = { cand, score }\n // Comfortably above the 0.6%-ink failure line, in field terms: every cell\n // must contain a spread of values wider than one level gap, so at least one\n // level is guaranteed to cross it.\n if (score.ok) break\n }\n contourField = best.cand.field\n contourGeom = { w, h, cols: best.cand.cols, rows: best.cand.rows, step }\n }\nconst contourBuildCandidate = (w, h, salt, step) => {\n const cols = Math.ceil(w / step) + 1\n const rows = Math.ceil(h / step) + 1\n /* Bump count adapts to the viewport -- see the note on VIEWPORT-ADAPTIVE\n DENSITY. At or above the 640px short side this is the shipped 22, and a\n narrow frame gets fewer islands so they stay separated instead of\n stacking into bullseyes. */\n const K = contourBumpsFor(w, h)\n const rnd = contourRng((contourSeed + salt * 0x9E3779B1) >>> 0)\n const m = Math.min(w, h)\n const bx = new Float32Array(K), by = new Float32Array(K)\n const ba = new Float32Array(K), bs = new Float32Array(K)\n const dx = new Float32Array(K), dy = new Float32Array(K)\n /* STRATIFIED placement, not independent uniform draws.\n\n Uniform sampling clumps: measured over 12 random seeds it left up to 3\n near-empty cells. Jittered grid instead -- the viewport is cut into a\n near-square lattice of at least K cells and each bump is placed at a random\n point inside its own cell. That keeps placement random while making a large\n empty patch geometrically impossible. Cells are ordered by a Fisher-Yates\n shuffle so the bump INDEX carries no positional bias: index drives\n amplitude, radius and drift below, and walking cells in raster order would\n correlate \"left side of the screen\" with \"first sizes drawn\".\n The lattice spans the same -0.1..1.1 over-scan as before, so islands are\n still cut by the viewport edges rather than all sitting fully inside. */\n const gx = Math.max(1, Math.round(Math.sqrt(K * (w / Math.max(1, h)))))\n const gy = Math.max(1, Math.ceil(K / gx))\n const cells = []\n for (let j = 0; j < gy; j++) for (let i = 0; i < gx; i++) cells.push(i + j * gx)\n for (let i = cells.length - 1; i > 0; i--) {\n const j = Math.floor(rnd() * (i + 1))\n const t = cells[i]; cells[i] = cells[j]; cells[j] = t\n }\n const spanX = 1.2 * w, spanY = 1.2 * h\n for (let k = 0; k < K; k++) {\n const cell = cells[k % cells.length]\n const ci = cell % gx\n const cj = Math.floor(cell / gx)\n // Random point inside this cell, in the over-scanned -0.1..1.1 space.\n bx[k] = -0.1 * w + ((ci + rnd()) / gx) * spanX\n by[k] = -0.1 * h + ((cj + rnd()) / gy) * spanY\n // Mixed sign gives peaks AND basins; equal signs would read as one blob.\n ba[k] = (rnd() < 0.5 ? -1 : 1) * (0.6 + rnd() * 0.9)\n /* Radius: relative to the frame for the shipped look, but never below\n CONTOUR_MIN_BUMPSAMPLES grid samples or the field aliases — see the note on\n that constant. This floor is the fix for the tangled/piled-up lines. */\n bs[k] = Math.max(step * CONTOUR_MIN_BUMPSAMPLES, (0.05 + rnd() * 0.09) * m)\n dx[k] = rnd() * 2 - 1\n dy[k] = rnd() * 2 - 1\n }\n /* Base undulation: three long, low-amplitude sine ridges spanning the whole\n viewport. Reason this exists, from reviewing the first render: a sum of\n gaussians decays to EXACTLY zero between islands, so the field there is\n perfectly flat, no level ever crosses it, and the result had large blank\n patches that exposed the construction. Real terrain has no such voids. The\n ridges are far too gentle to create islands of their own — they just tilt\n the whole sheet enough that contour lines keep running through the gaps,\n which is what turns isolated bullseyes into one continuous landscape. */\n const W2 = new Float32Array(9)\n for (let i = 0; i < 3; i++) {\n W2[i * 3] = (0.35 + rnd() * 0.5) * (Math.PI * 2) / Math.max(1, w) // x freq\n W2[i * 3 + 1] = (0.35 + rnd() * 0.5) * (Math.PI * 2) / Math.max(1, h) // y freq\n W2[i * 3 + 2] = rnd() * Math.PI * 2 // phase\n }\n const hCount = (cols - 1) * rows\n const eCount = hCount + cols * (rows - 1)\n const field = {\n cols, rows, step, K, bx, by, ba, bs, dx, dy, hCount, W2,\n F: new Float32Array(cols * rows),\n previous: new Float32Array(cols * rows),\n hasPrevious: false,\n smooth: new Float32Array(cols * rows),\n // Exact row-block bounds include both rows and the shared right vertex.\n // They reject empty regions without changing retained cells or path order.\n blockCols: Math.ceil((cols - 1) / 16),\n blockMin: new Float32Array(Math.ceil((cols - 1) / 16) * (rows - 1)),\n blockMax: new Float32Array(Math.ceil((cols - 1) / 16) * (rows - 1)),\n boundsReady: false,\n ex: new Float32Array(eCount),\n ey: new Float32Array(eCount),\n es: new Int32Array(eCount).fill(-1),\n n1: new Int32Array(eCount).fill(-1),\n n2: new Int32Array(eCount).fill(-1),\n seen: new Int32Array(eCount).fill(-1),\n touched: new Int32Array(eCount),\n seq: 0,\n }\n return { field, cols, rows }\n }\nconst contourCoverageScore = (cand, w, h) => {\n const f = cand.field\n // Evaluate at phase 0: the accepted layout must be sound as first painted.\n const prev = contourField\n contourField = f\n contourEvaluate(0)\n contourField = prev\n const { cols, rows, F } = f\n const GX = 8, GY = 5\n const span = CONTOUR_SPAN\n /* Judge against the density this viewport will actually DRAW, not the\n shipped constant: a narrow frame extracts fewer levels, so its per-quad\n incidence count is proportionally smaller. Scoring it on CONTOUR_LEVELS\n would demand 2x the ink of a 10-level sheet and push the re-roll loop to\n its cap on every small window. */\n const levelStep = (span * 2) / contourLevelsFor(w, h)\n let worst = Infinity\n let ok = true\n let ink = Infinity\n for (let gy = 0; gy < GY; gy++) {\n for (let gx = 0; gx < GX; gx++) {\n const i0 = Math.floor(gx * (cols - 1) / GX), i1 = Math.ceil((gx + 1) * (cols - 1) / GX)\n const j0 = Math.floor(gy * (rows - 1) / GY), j1 = Math.ceil((gy + 1) * (rows - 1) / GY)\n let mn = Infinity, mx = -Infinity\n /* The ink proxy is accumulated over the QUADS of this region, in the same\n walk that takes its min/max — see CONTOUR_MIN_INK. */\n let regionInk = 0\n for (let j = j0; j <= j1 && j < rows; j++) {\n const row = j * cols\n for (let i = i0; i <= i1 && i < cols; i++) {\n const v = F[row + i]\n if (v < mn) mn = v\n if (v > mx) mx = v\n if (j < j1 && j < rows - 1 && i < i1 && i < cols - 1) {\n const a = v, b = F[row + i + 1]\n const c = F[row + cols + i], d = F[row + cols + i + 1]\n let qmn = a < b ? a : b, qmx = a > b ? a : b\n if (c < qmn) qmn = c; if (c > qmx) qmx = c\n if (d < qmn) qmn = d; if (d > qmx) qmx = d\n if (qmn < -span) qmn = -span\n if (qmx > span) qmx = span\n if (qmx > qmn) regionInk += Math.floor(qmx / levelStep) - Math.ceil(qmn / levelStep) + 1\n }\n }\n }\n // Clamp to the drawn level range: values beyond +/-SPAN produce no lines.\n const lo = Math.max(mn, -span), hi = Math.min(mx, span)\n // How many level boundaries fall inside this cell's clamped range.\n const crossings = hi <= lo ? 0\n : Math.floor(hi / levelStep) - Math.ceil(lo / levelStep) + 1\n if (crossings < worst) worst = crossings\n if (crossings < CONTOUR_MIN_CROSSINGS) ok = false\n if (regionInk < ink) ink = regionInk\n if (regionInk < CONTOUR_MIN_INK) ok = false\n }\n }\n return { ok, worst, ink }\n }\nconst contourEvaluate = (phase) => {\n const f = contourField\n if (f !== null) f.boundsReady = false\n if (f === null) return\n const { cols, rows, step, K, bx, by, ba, bs, dx, dy, F, W2 } = f\n /* Seed the sheet with the base undulation instead of zero, so the gaps\n between islands still have a gradient for the levels to cross. Separable\n evaluation: sin(a+b) is expanded so the y term is computed once per row\n rather than once per cell, which keeps this pass cheap. */\n const BASE = 0.62\n for (let i = 0; i < 3; i++) {\n const fx = W2[i * 3], fy = W2[i * 3 + 1], ph = W2[i * 3 + 2] + phase * 0.11\n const amp = BASE / 3\n for (let j = 0; j < rows; j++) {\n const yb = fy * (j * step) + ph\n const sy = Math.sin(yb), cy2 = Math.cos(yb)\n const row = j * cols\n for (let c2 = 0; c2 < cols; c2++) {\n const xb = fx * (c2 * step)\n // sin(xb + yb) without a per-cell sin() of the sum\n const v = Math.sin(xb) * cy2 + Math.cos(xb) * sy\n if (i === 0) F[row + c2] = amp * v\n else F[row + c2] += amp * v\n }\n }\n }\n for (let k = 0; k < K; k++) {\n const s = bs[k]\n const amp = s * 0.55\n const cx = bx[k] + Math.sin(phase * dx[k] + k * 1.7) * amp\n const cy = by[k] + Math.cos(phase * dy[k] + k * 2.3) * amp\n const a = ba[k]\n const inv = 1 / (2 * s * s)\n const rad = 2.6 * s\n let i0 = Math.floor((cx - rad) / step)\n let i1 = Math.ceil((cx + rad) / step)\n let j0 = Math.floor((cy - rad) / step)\n let j1 = Math.ceil((cy + rad) / step)\n if (i0 < 0) i0 = 0\n if (j0 < 0) j0 = 0\n if (i1 > cols - 1) i1 = cols - 1\n if (j1 > rows - 1) j1 = rows - 1\n for (let j = j0; j <= j1; j++) {\n const ddy = j * step - cy\n const dy2 = ddy * ddy\n const row = j * cols\n for (let i = i0; i <= i1; i++) {\n const ddx = i * step - cx\n const q = (ddx * ddx + dy2) * inv\n if (q < 6.76) {\n let weight = Math.exp(-q)\n if (q > 4.8) {\n const t = (q - 4.8) / (6.76 - 4.8)\n const fade = 1 - t * t * (3 - 2 * t)\n weight *= fade\n }\n F[row + i] += a * weight\n }\n }\n }\n }\n const smooth = f.smooth\n for (let pass = 0; pass < 5; pass++) {\n for (let j = 0; j < rows; j++) {\n const row = j * cols\n for (let i = 0; i < cols; i++) {\n const left = F[row + Math.max(0, i - 1)]\n const center = F[row + i]\n const right = F[row + Math.min(cols - 1, i + 1)]\n smooth[row + i] = (left + 2 * center + right) * 0.25\n }\n }\n for (let j = 0; j < rows; j++) {\n const row = j * cols\n const up = Math.max(0, j - 1) * cols\n const down = Math.min(rows - 1, j + 1) * cols\n for (let i = 0; i < cols; i++) {\n F[row + i] = (smooth[up + i] + 2 * smooth[row + i] + smooth[down + i]) * 0.25\n }\n }\n }\n /* Track the field continuously between animation samples. Marching squares\n can change an entire path at once when a saddle crosses a level; blending\n the sampled field keeps that topology change from appearing as a twitch. */\n if (f.hasPrevious && f.previous !== undefined) {\n for (let i = 0; i < F.length; i++) {\n f.previous[i] = f.previous[i] * 0.65 + F[i] * 0.35\n F[i] = f.previous[i]\n }\n } else if (f.previous !== undefined) {\n f.previous.set(F)\n }\n f.hasPrevious = true\n }\nconst contourExtractLevel = (L, out) => {\n const f = contourField\n const { cols, rows, step, F, ex, ey, es, n1, n2, seen, touched, hCount } = f\n const st = ++f.seq\n let tn = 0\n const pt = (id, i0, j0, i1, j1) => {\n if (es[id] === st) return id\n const a = F[j0 * cols + i0]\n const b = F[j1 * cols + i1]\n let t = (L - a) / (b - a)\n if (!(t >= 0)) t = 0\n else if (t > 1) t = 1\n ex[id] = (i0 + (i1 - i0) * t) * step\n ey[id] = (j0 + (j1 - j0) * t) * step\n es[id] = st\n n1[id] = -1\n n2[id] = -1\n touched[tn++] = id\n return id\n }\n const link = (a, b) => {\n if (n1[a] < 0) n1[a] = b\n else if (n2[a] < 0) n2[a] = b\n if (n1[b] < 0) n1[b] = a\n else if (n2[b] < 0) n2[b] = a\n }\n for (let j = 0; j < rows - 1; j++) {\n const row = j * cols\n for (let i = 0; i < cols - 1; i++) {\n if (f.boundsReady && i % 16 === 0) {\n const block = j * f.blockCols + Math.floor(i / 16)\n if (L <= f.blockMin[block] || L > f.blockMax[block]) {\n i = Math.min(i + 16, cols - 1) - 1\n continue\n }\n }\n const p0 = row + i\n const p1 = p0 + 1\n const p3 = p0 + cols\n const p2 = p3 + 1\n const v0 = F[p0], v1 = F[p1], v2 = F[p2], v3 = F[p3]\n let mn = v0, mx = v0\n if (v1 < mn) mn = v1; else if (v1 > mx) mx = v1\n if (v2 < mn) mn = v2; else if (v2 > mx) mx = v2\n if (v3 < mn) mn = v3; else if (v3 > mx) mx = v3\n // Whole cell on one side of the level: nothing crosses it.\n if (L <= mn || L > mx) continue\n const idx = (v0 > L ? 1 : 0) | (v1 > L ? 2 : 0) | (v2 > L ? 4 : 0) | (v3 > L ? 8 : 0)\n const T = () => pt(j * (cols - 1) + i, i, j, i + 1, j)\n const B = () => pt((j + 1) * (cols - 1) + i, i, j + 1, i + 1, j + 1)\n const Le = () => pt(hCount + j * cols + i, i, j, i, j + 1)\n const Ri = () => pt(hCount + j * cols + i + 1, i + 1, j, i + 1, j + 1)\n switch (idx) {\n case 1: case 14: link(T(), Le()); break\n case 2: case 13: link(T(), Ri()); break\n case 3: case 12: link(Le(), Ri()); break\n case 4: case 11: link(Ri(), B()); break\n case 6: case 9: link(T(), B()); break\n case 7: case 8: link(Le(), B()); break\n // Ambiguous saddles use the bilinear asymptotic decider. The sign of\n // a*c-b*d selects whether the diagonal high/low regions are connected;\n // using the cell average alone is wrong when opposite corners differ in\n // magnitude and produces the long V-shaped joins seen in the render.\n case 5: {\n const a = v0 - L, b = v1 - L, c = v2 - L, d = v3 - L\n const saddle = a * c - b * d\n if (saddle > 0) { link(T(), Ri()); link(Le(), B()) }\n else { link(T(), Le()); link(Ri(), B()) }\n break\n }\n case 10: {\n const a = v0 - L, b = v1 - L, c = v2 - L, d = v3 - L\n const saddle = a * c - b * d\n if (saddle < 0) { link(T(), Le()); link(Ri(), B()) }\n else { link(T(), Ri()); link(Le(), B()) }\n break\n }\n }\n }\n }\n const walk = (start) => {\n const path = []\n let cur = start\n let prev = -1\n for (;;) {\n path.push(ex[cur], ey[cur])\n seen[cur] = st\n const a = n1[cur]\n const b = n2[cur]\n let nx = -1\n if (a >= 0 && a !== prev && seen[a] !== st) nx = a\n else if (b >= 0 && b !== prev && seen[b] !== st) nx = b\n if (nx < 0) {\n // Closed loop: step back onto the first point so the ring has no gap.\n if ((a === start || b === start) && path.length > 4) path.push(ex[start], ey[start])\n break\n }\n prev = cur\n cur = nx\n }\n return path\n }\n /* Reject debris before it reaches the draw list. Judged on the path's\n ON-CANVAS geometry, so an off-grid sliver with no visible pixels is\n dropped even when its raw length looks respectable. See the note on\n CONTOUR_MIN_LEN / CONTOUR_MIN_RING_BOX for the measurements behind both\n thresholds. */\n const W = contourGeom !== null ? contourGeom.w : 0\n const H = contourGeom !== null ? contourGeom.h : 0\n const keep = (p) => {\n if (p.length < 8) return false\n // Visible length, plus the bounding box of the part actually on screen.\n let vis = 0\n let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity\n let seenIn = false\n for (let k = 0; k < p.length; k += 2) {\n const x = p[k], y = p[k + 1]\n const inside = x >= 0 && x <= W && y >= 0 && y <= H\n if (inside) {\n seenIn = true\n if (x < minx) minx = x\n if (x > maxx) maxx = x\n if (y < miny) miny = y\n if (y > maxy) maxy = y\n }\n if (k >= 2) {\n const px2 = p[k - 2], py2 = p[k - 1]\n const prevIn = px2 >= 0 && px2 <= W && py2 >= 0 && py2 <= H\n if (inside && prevIn) {\n const dx = x - px2, dy = y - py2\n vis += Math.sqrt(dx * dx + dy * dy)\n }\n }\n }\n if (!seenIn) return false // entirely off-canvas: pure debris\n if (vis < CONTOUR_KEEP_LEN) return false\n /* A tiny CLOSED ring is an apex bullseye and reads as a dot. Open chains of\n the same extent are left alone: they are the visible corner of a stroke\n that continues off-canvas, and clipping one would punch a hole in a line\n the user can see running to the edge. */\n const gapx = p[0] - p[p.length - 2]\n const gapy = p[1] - p[p.length - 1]\n const closed = (gapx * gapx + gapy * gapy) < 4\n if (closed && (maxx - minx) < CONTOUR_KEEP_RING\n && (maxy - miny) < CONTOUR_KEEP_RING) return false\n return true\n }\n /* TANGENCY NEEDLES. Where a level runs nearly TANGENT to the field, the true\n isoline has a smooth, very high curvature tip. Marching squares interpolates\n linearly on a 10px grid, so it cannot represent that tip: it emits a hairpin\n that goes out and comes straight back, with a BASE (the gap between the\n apex's two neighbours) far narrower than the 1px stroke. Measured on the real\n output, worst case: apex 6.26px out from a base of 0.831px.\n\n At that width the outbound and return strokes paint the SAME pixels, so the\n pair does not read as a narrow valley — only the protruding whisker shows,\n which is precisely the \"irregular sharp angle\" in issue #3. Smoothing cannot\n help: the midpoint spline faithfully reproduces a feature that is genuinely\n in the geometry, so it has to be removed here, at the source.\n\n The whole hairpin is collapsed (see the note on the merge below). Both tests\n are required and were measured over 24 frames (152.6k vertices, 1.18Mpx of\n ink):\n base < 2px the stroke cannot resolve it (a 1px line is ~1px wide)\n turn > 90 it doubles back rather than merely turning a corner\n That is 0.7 vertices per frame and 0.0037% of total ink -- artifact removal,\n not thinning. Real narrow features are untouched: turns over 90 degrees have\n a median base of 4.03px, well clear of the cutoff, and the whole 8-12px base\n band (29562 vertices) has a p99 turn of only 21.7 degrees. */\n const deneedle = (p) => {\n const n = p.length / 2\n if (n < 4) return p\n /* Scan first and return the ORIGINAL array when there is nothing to do, so\n the overwhelmingly common path allocates nothing at 24fps. */\n let found = false\n for (let k = 1; k < n - 1; k++) {\n const bx = p[(k + 1) * 2] - p[(k - 1) * 2]\n const by = p[(k + 1) * 2 + 1] - p[(k - 1) * 2 + 1]\n if (bx * bx + by * by >= 4) continue // base >= 2px: keep\n const ax = p[k * 2] - p[(k - 1) * 2]\n const ay = p[k * 2 + 1] - p[(k - 1) * 2 + 1]\n const cx = p[(k + 1) * 2] - p[k * 2]\n const cy = p[(k + 1) * 2 + 1] - p[k * 2 + 1]\n // turn > 90 degrees <=> the two segment vectors point against each other.\n if (ax * cx + ay * cy < 0) { found = true; break }\n }\n if (!found) return p\n const gx = p[0] - p[(n - 1) * 2]\n const gy = p[1] - p[(n - 1) * 2 + 1]\n const closed = (gx * gx + gy * gy) < 4\n /* COLLAPSE THE WHOLE NEEDLE, not just its tip. Dropping the apex alone leaves\n the base itself as a real segment, and that was measured to be worse than\n the disease: a 0.831px stub inherits the reversal as TWO ~78-degree turns\n (10.20 -> 0.83 -> 10.25px). The apex AND its far neighbour are therefore\n both consumed, and the surviving previous vertex is pulled onto the base\n midpoint -- a sub-pixel move (half of at most 2px) that no 1px stroke can\n show, leaving one smooth vertex where the hairpin was.\n Each test uses the SURVIVING previous vertex, so a run of needles collapses\n progressively instead of each test being fooled by a neighbour that is\n itself about to be consumed. */\n const q = [p[0], p[1]]\n let k = 1\n while (k < n - 1) {\n const px = q[q.length - 2], py = q[q.length - 1]\n const bx = p[(k + 1) * 2] - px, by = p[(k + 1) * 2 + 1] - py\n if (bx * bx + by * by < 4) {\n const ax = p[k * 2] - px, ay = p[k * 2 + 1] - py\n const cx = p[(k + 1) * 2] - p[k * 2], cy = p[(k + 1) * 2 + 1] - p[k * 2 + 1]\n if (ax * cx + ay * cy < 0) {\n if (k + 1 < n - 1) {\n q[q.length - 2] = (px + p[(k + 1) * 2]) / 2\n q[q.length - 1] = (py + p[(k + 1) * 2 + 1]) / 2\n k += 2\n continue\n }\n // The far neighbour is the final vertex, which must survive to keep an\n // endpoint (or a ring's closure) intact: consume only the apex.\n k += 1\n continue\n }\n }\n q.push(p[k * 2], p[k * 2 + 1])\n k += 1\n }\n q.push(p[(n - 1) * 2], p[(n - 1) * 2 + 1])\n /* A ring is closed by REPEATING its start vertex, and the merge above may have\n nudged that start. Re-anchor the repeat so the ring stays exactly closed and\n both keep() and the cyclic draw path still classify it as one. */\n if (closed) {\n q[q.length - 2] = q[0]\n q[q.length - 1] = q[1]\n }\n return q\n }\n // Open chains first (they have a free end), then whatever remains is a loop.\n // Doing it in this order stops a ring being entered mid-way and split in two.\n for (let k = 0; k < tn; k++) {\n const id = touched[k]\n if (seen[id] !== st && n2[id] < 0) {\n const p = deneedle(walk(id))\n if (keep(p)) out.push(p)\n }\n }\n for (let k = 0; k < tn; k++) {\n const id = touched[k]\n if (seen[id] !== st) {\n const p = deneedle(walk(id))\n if (keep(p)) out.push(p)\n }\n }\n }\nconst contourExtract = (phase, trailPoints, trailNow) => {\n if (contourField === null) return\n contourEvaluate(phase)\n if (trailPoints && trailPoints.length) contourOverlayTrail(contourField, trailPoints, trailNow)\n const f = contourField\n // One O(cells) range pass is shared by all 21 extraction levels. Scratch\n // survives across frames; no resolution, smoothing or density is reduced.\n for (let j = 0; j < f.rows - 1; j++) {\n const row = j * f.cols\n for (let block = 0; block < f.blockCols; block++) {\n const begin = block * 16\n const end = Math.min(begin + 16, f.cols - 1)\n let min = Infinity, max = -Infinity\n for (let i = begin; i <= end; i++) {\n const a = f.F[row + i], b = f.F[row + f.cols + i]\n if (a < min) min = a\n if (a > max) max = a\n if (b < min) min = b\n if (b > max) max = b\n }\n const k = j * f.blockCols + block\n f.blockMin[k] = min\n f.blockMax[k] = max\n }\n }\n f.boundsReady = true\n contourPaths = []\n const span = CONTOUR_SPAN\n /* Level count follows the viewport: the same VIEWPORT-ADAPTIVE DENSITY\n contourBuildCandidate() laid the bumps out with. At or above the 640px\n short side this is exactly the shipped loop, so desktop output is\n unchanged. */\n const levels = contourLevelsFor(contourGeom.w, contourGeom.h)\n const stepL = (span * 2) / levels\n for (let n = 0; n <= levels; n++) {\n contourExtractLevel(-span + n * stepL, contourPaths)\n }\n }\nconst contourDrawLines = () => {\n if (contourLineCv === null || contourGeom === null) return\n const ctx = contourLineCv.getContext('2d')\n if (!ctx) return\n const { w, h } = contourGeom\n /* Geometry stays in CSS px; scale the context to the backing store that\n contourSizeTo sized at (capped) devicePixelRatio, so strokes rasterise\n at device resolution instead of being upsampled into blur on HiDPI\n screens. Derived from the canvas itself, and skipped entirely at a 1x\n store or when the context has no setTransform (the spliced-in test\n harnesses), so a 1x render is byte-identical to before. */\n const scale = (w > 0 && typeof contourLineCv.width === 'number' && contourLineCv.width > 0 && contourLineCv.width !== w)\n ? contourLineCv.width / w : 1\n if (scale !== 1 && typeof ctx.setTransform === 'function') ctx.setTransform(scale, 0, 0, scale, 0, 0)\n ctx.clearRect(0, 0, w, h)\n ctx.strokeStyle = contourStroke()\n ctx.lineWidth = 1\n ctx.lineJoin = 'round'\n /* Marching squares emits one vertex per grid-cell edge. Three Chaikin passes\n cut local corners before the clamped cubic B-spline rounds broad bends.\n This reduces angularity without changing the field or adding another\n extraction pass. Open endpoints remain fixed; closed rings wrap cyclically.\n\n PASS COUNT IS SIZE-ADAPTIVE: see CONTOUR_SMOOTH_FULL. Every ordinary window\n keeps three passes; a bigger sheet drops to two and then one. */\n const smoothPath = (source) => {\n const count = source.length / 2\n if (count < 3) return source\n const closed = (source[0] - source[source.length - 2]) ** 2\n + (source[1] - source[source.length - 1]) ** 2 < 4\n /* Flat scratch throughout: the previous shape built one [x, y] array per\n point PER PASS — ~14 small arrays per source vertex, ~65k per sheet at\n 1440x900 — which is pure GC inside the animation frame on both painters. */\n let points = closed ? source.slice(0, source.length - 2) : source.slice()\n const passes = count <= CONTOUR_SMOOTH_FULL ? 3 : (count <= CONTOUR_SMOOTH_LIMIT ? 2 : 1)\n for (let pass = 0; pass < passes; pass++) {\n const n = points.length / 2\n const limit = closed ? n : n - 1\n const next = new Array(n * 4)\n let w2 = 0\n if (!closed) { next[w2++] = points[0]; next[w2++] = points[1] }\n for (let k = 0; k < limit; k++) {\n const a = k * 2\n const b = ((k + 1) % n) * 2\n const ax = points[a], ay = points[a + 1]\n const bx = points[b], by = points[b + 1]\n next[w2++] = ax * 0.75 + bx * 0.25\n next[w2++] = ay * 0.75 + by * 0.25\n next[w2++] = ax * 0.25 + bx * 0.75\n next[w2++] = ay * 0.25 + by * 0.75\n }\n if (!closed) { next[w2++] = points[points.length - 2]; next[w2++] = points[points.length - 1] }\n points = w2 === next.length ? next : next.slice(0, w2)\n }\n if (!closed) return points\n // Re-anchor the repeated start vertex so the ring stays exactly closed.\n const out = points.slice()\n out.push(out[0], out[1])\n return out\n }\n /* Chaikin removes local grid noise. A constrained Catmull-Rom cubic then\n gives each join one shared tangent. The handle cap prevents overshoot at\n narrow saddles while the larger tangent factor removes long rounded-polygon\n bends that remain visible with midpoint quadratics. */\n const drawSmoothPath = (source) => {\n const count = source.length / 2\n if (count < 3) {\n ctx.moveTo(source[0], source[1])\n for (let k = 2; k < source.length; k += 2) ctx.lineTo(source[k], source[k + 1])\n return\n }\n const closed = (source[0] - source[source.length - 2]) ** 2\n + (source[1] - source[source.length - 1]) ** 2 < 4\n const limit = closed ? count - 1 : count\n const at = (index) => {\n const k = closed\n ? (index + limit) % limit\n : Math.max(0, Math.min(limit - 1, index))\n return k * 2\n }\n /* Tangents from scalars, with squared lengths wherever the comparison allows\n it. The previous shape allocated eight [x, y] arrays and ran up to six\n Math.hypot per SEGMENT — tens of thousands of segments per frame, on both\n the WebGL painter and the canvas fallback. Math.sqrt replaces hypot because\n every argument here is a pixel delta, far from hypot's overflow range. */\n const tangentAt = (index, out) => {\n const i = at(index)\n const p = at(index - 1)\n const n = at(index + 1)\n const cx = source[i], cy = source[i + 1]\n const inX = cx - source[p], inY = cy - source[p + 1]\n const outX = source[n] - cx, outY = source[n + 1] - cy\n let tx, ty, cap\n if (!closed && index === 0) {\n tx = outX * 0.4\n ty = outY * 0.4\n cap = Math.sqrt(outX * outX + outY * outY) * 0.55\n } else if (!closed && index === limit - 1) {\n tx = inX * 0.4\n ty = inY * 0.4\n cap = Math.sqrt(inX * inX + inY * inY) * 0.55\n } else {\n tx = (source[n] - source[p]) * 0.32\n ty = (source[n + 1] - source[p + 1]) * 0.32\n cap = Math.sqrt(Math.min(inX * inX + inY * inY, outX * outX + outY * outY)) * 0.62\n }\n const len2 = tx * tx + ty * ty\n if (len2 > cap * cap && len2 > 0) {\n const s = cap / Math.sqrt(len2)\n tx *= s\n ty *= s\n }\n out[0] = tx\n out[1] = ty\n }\n ctx.moveTo(source[0], source[1])\n const segments = closed ? limit : limit - 1\n const t0 = [0, 0]\n const t1 = [0, 0]\n for (let k = 0; k < segments; k++) {\n const s = at(k)\n const e = at(k + 1)\n tangentAt(k, t0)\n tangentAt(k + 1, t1)\n ctx.bezierCurveTo(\n source[s] + t0[0], source[s + 1] + t0[1],\n source[e] - t1[0], source[e + 1] - t1[1],\n source[e], source[e + 1],\n )\n }\n /* Mark a ring as a RING. The cyclic tangents above already make the seam C1\n and the final span already lands exactly on the start point, so this adds\n no geometry — but without it the canvas treats the path as open and butts\n two caps together at the seam instead of joining them, which is defect (2)\n of issue #3. contour-cusps.test.js guards this. */\n if (closed) ctx.closePath()\n }\n const isRing = (p) => {\n const n = p.length - 2\n return n >= 2 && (p[0] - p[n]) ** 2 + (p[1] - p[n + 1]) ** 2 < 4\n }\n /* ONE STROKE, RINGS FIRST — and the ORDER matters for the canvas fallback, not\n for looks. `closePath()` on a real 2D context finalises the current subpath by\n splicing it into the path accumulated so far, so its cost grows with\n everything already in that path (measured on the canvas fallback: 18 calls per\n sheet averaging 0.278 ms, max 1.2 ms, ~16% of the frame). Drawing the rings\n while the path still holds only other rings bounds every splice to ring\n geometry. Output is unchanged either way: canvas stroke coverage is a union,\n and the WebGL painter blends with MAX (commutative), so subpath order cannot\n affect the result. Stroking each path separately would also fix the cost but\n double-composites overlapping strokes and visibly darkens every crossing —\n the very \"piled-up lines\" look this avoids. */\n ctx.beginPath()\n for (let i = 0; i < contourPaths.length; i++) {\n const p = contourPaths[i]\n if (isRing(p)) drawSmoothPath(smoothPath(p))\n }\n for (let i = 0; i < contourPaths.length; i++) {\n const p = contourPaths[i]\n if (!isRing(p)) drawSmoothPath(smoothPath(p))\n }\n ctx.stroke()\n }\n/* MIT; contributed by higekibaka. GPU stroke painter from Endfield Glass. */\nconst CURVE_TOLERANCE_PX = 0.04;\nconst STRIDE = 6;\nclass StrokeSegments {\n data = new Float32Array(4096 * STRIDE);\n used = 0;\n tolerance = CURVE_TOLERANCE_PX;\n x = 0;\n y = 0;\n startX = 0;\n startY = 0;\n first = 0;\n reset() {\n this.used = 0;\n this.first = 0;\n }\n moveTo(x, y) {\n this.x = this.startX = x;\n this.y = this.startY = y;\n this.first = this.used;\n }\n lineTo(x, y) {\n if (x === this.x && y === this.y) return;\n if (this.used + STRIDE > this.data.length) {\n const next = new Float32Array(this.data.length * 2);\n next.set(this.data);\n this.data = next;\n }\n const i = this.used;\n this.data[i] = this.x;\n this.data[i + 1] = this.y;\n this.data[i + 2] = x;\n this.data[i + 3] = y;\n this.data[i + 4] = i === this.first ? 1 : 0;\n this.data[i + 5] = 1;\n if (i > this.first) this.data[i - 1] = 0;\n this.used += STRIDE;\n this.x = x;\n this.y = y;\n }\n closePath() {\n this.lineTo(this.startX, this.startY);\n if (this.used > this.first) {\n this.data[this.first + 4] = 0;\n this.data[this.used - 1] = 0;\n }\n }\n bezierCurveTo(ax, ay, bx, by, x, y) {\n this.cubic(this.x, this.y, ax, ay, bx, by, x, y, 0);\n }\n cubic(x0, y0, x1, y1, x2, y2, x3, y3, depth) {\n const dx = x3 - x0, dy = y3 - y0, length2 = dx * dx + dy * dy;\n const tolerance2 = this.tolerance * this.tolerance;\n const t1 = length2 > 0 ? Math.max(0, Math.min(1, ((x1 - x0) * dx + (y1 - y0) * dy) / length2)) : 0;\n const t2 = length2 > 0 ? Math.max(0, Math.min(1, ((x2 - x0) * dx + (y2 - y0) * dy) / length2)) : 0;\n const e1x = x1 - x0 - t1 * dx, e1y = y1 - y0 - t1 * dy;\n const e2x = x2 - x0 - t2 * dx, e2y = y2 - y0 - t2 * dy;\n if (e1x * e1x + e1y * e1y <= tolerance2 && e2x * e2x + e2y * e2y <= tolerance2 || depth >= 16) {\n this.lineTo(x3, y3);\n return;\n }\n const a = (x0 + x1) / 2, b = (y0 + y1) / 2, c = (x1 + x2) / 2, d = (y1 + y2) / 2;\n const e = (x2 + x3) / 2, f = (y2 + y3) / 2, g = (a + c) / 2, h = (b + d) / 2;\n const i = (c + e) / 2, j = (d + f) / 2, k = (g + i) / 2, l = (h + j) / 2;\n this.cubic(x0, y0, a, b, g, h, k, l, depth + 1);\n this.cubic(k, l, i, j, e, f, x3, y3, depth + 1);\n }\n}\nfunction parseStrokeColor(value) {\n const m = value.match(/^rgba?\\(\\s*([\\d.]+)\\s*,\\s*([\\d.]+)\\s*,\\s*([\\d.]+)(?:\\s*,\\s*([\\d.]+))?\\s*\\)$/);\n if (!m) return null;\n const values = [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255, m[4] === void 0 ? 1 : Number(m[4])];\n return values.every((v) => Number.isFinite(v) && v >= 0 && v <= 1) ? values : null;\n}\nconst VERTEX = `#version 300 es\nprecision highp float;\nlayout(location=0) in vec4 segment;\nlayout(location=1) in vec2 caps;\nuniform vec2 viewportSize;\nuniform vec2 scale;\nuniform float width;\nout vec2 local;\nflat out float segmentLength;\nflat out float radius;\nflat out vec2 endCaps;\nvoid main() {\n vec2 a = segment.xy * scale, b = segment.zw * scale;\n vec2 delta = b-a;\n segmentLength = max(length(delta), 0.000001);\n vec2 tangent = delta / segmentLength;\n vec2 normal = vec2(-tangent.y, tangent.x);\n radius = 0.5 * width * scale.x * scale.y * length(segment.zw-segment.xy) / segmentLength;\n float margin = radius + 1.0;\n // Two triangles per segment, generated without a second vertex buffer.\n vec2 corners[6] = vec2[6](vec2(0,-1),vec2(1,-1),vec2(0,1),vec2(0,1),vec2(1,-1),vec2(1,1));\n vec2 corner = corners[gl_VertexID];\n local = vec2(mix(-margin, segmentLength+margin, corner.x), corner.y * margin);\n vec2 pixel = a + tangent * local.x + normal * local.y;\n gl_Position = vec4(pixel.x/viewportSize.x*2.0-1.0, 1.0-pixel.y/viewportSize.y*2.0, 0, 1);\n endCaps = caps;\n}`;\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nin vec2 local;\nflat in float segmentLength;\nflat in float radius;\nflat in vec2 endCaps;\nuniform vec4 color;\nout vec4 outputColor;\nvoid main() {\n vec2 nearest = vec2(clamp(local.x, 0.0, segmentLength), 0);\n float distance = length(local-nearest)-radius;\n if (endCaps.x > 0.5) distance = max(distance, -local.x);\n if (endCaps.y > 0.5) distance = max(distance, local.x-segmentLength);\n float coverage = clamp(0.5-distance, 0.0, 1.0);\n float alpha = color.a * coverage;\n outputColor = vec4(color.rgb * alpha, alpha);\n}`;\nfunction createWebGLContourContext(canvas, onContextLost) {\n const gl = canvas.getContext(\"webgl2\", {\n alpha: true,\n premultipliedAlpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n preserveDrawingBuffer: false\n });\n if (!gl) return null;\n let program = null;\n let buffer = null;\n let vao = null;\n const shaders = [];\n const events = canvas;\n const lost = () => onContextLost?.();\n const releaseContext = gl.getExtension(\"WEBGL_lose_context\");\n const release = () => {\n events.removeEventListener?.(\"webglcontextlost\", lost);\n if (buffer) gl.deleteBuffer(buffer);\n if (vao) gl.deleteVertexArray(vao);\n if (program) gl.deleteProgram(program);\n for (const shader of shaders) gl.deleteShader(shader);\n buffer = null;\n vao = null;\n program = null;\n shaders.length = 0;\n releaseContext?.loseContext();\n };\n try {\n const compile = (type, source) => {\n const shader = gl.createShader(type);\n if (!shader) throw new Error(\"WebGL shader allocation failed\");\n shaders.push(shader);\n gl.shaderSource(shader, source);\n gl.compileShader(shader);\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(\"Contour shader: \" + gl.getShaderInfoLog(shader));\n return shader;\n };\n const vs = compile(gl.VERTEX_SHADER, VERTEX), fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);\n program = gl.createProgram();\n buffer = gl.createBuffer();\n vao = gl.createVertexArray();\n if (!program || !buffer || !vao) throw new Error(\"WebGL allocation failed\");\n gl.attachShader(program, vs);\n gl.attachShader(program, fs);\n gl.linkProgram(program);\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(\"Contour shader link: \" + gl.getProgramInfoLog(program));\n gl.useProgram(program);\n gl.bindVertexArray(vao);\n gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n gl.enableVertexAttribArray(0);\n gl.vertexAttribPointer(0, 4, gl.FLOAT, false, STRIDE * 4, 0);\n gl.vertexAttribDivisor(0, 1);\n gl.enableVertexAttribArray(1);\n gl.vertexAttribPointer(1, 2, gl.FLOAT, false, STRIDE * 4, 16);\n gl.vertexAttribDivisor(1, 1);\n gl.disable(gl.DEPTH_TEST);\n gl.disable(gl.CULL_FACE);\n gl.disable(gl.DITHER);\n gl.enable(gl.BLEND);\n gl.blendEquation(gl.MAX);\n gl.blendFunc(gl.ONE, gl.ONE);\n const viewport = gl.getUniformLocation(program, \"viewportSize\");\n const scaling = gl.getUniformLocation(program, \"scale\");\n const color = gl.getUniformLocation(program, \"color\");\n const width = gl.getUniformLocation(program, \"width\");\n const segments = new StrokeSegments();\n let sx = 1, sy = 1, capacity = 0, disposed = false;\n const context = {\n strokeStyle: \"rgba(16,17,16,0.16)\",\n lineWidth: 1,\n lineJoin: \"round\",\n setTransform(a, b, c, d, e, f) {\n if (b !== 0 || c !== 0 || e !== 0 || f !== 0 || a <= 0 || d <= 0) throw new Error(\"Unsupported contour transform\");\n sx = a;\n sy = d;\n segments.tolerance = CURVE_TOLERANCE_PX / Math.max(sx, sy);\n },\n clearRect() {\n if (disposed || gl.isContextLost()) throw new Error(\"Contour WebGL context lost\");\n gl.viewport(0, 0, canvas.width, canvas.height);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n },\n beginPath: () => segments.reset(),\n moveTo: (x, y) => segments.moveTo(x, y),\n lineTo: (x, y) => segments.lineTo(x, y),\n bezierCurveTo: (a, b, c, d, e, f) => segments.bezierCurveTo(a, b, c, d, e, f),\n closePath: () => segments.closePath(),\n stroke() {\n const rgba = parseStrokeColor(context.strokeStyle);\n if (!rgba) throw new Error(\"Unsupported contour stroke color\");\n if (segments.used === 0) {\n gl.flush();\n return;\n }\n gl.uniform2f(viewport, canvas.width, canvas.height);\n gl.uniform2f(scaling, sx, sy);\n gl.uniform4fv(color, rgba);\n gl.uniform1f(width, context.lineWidth);\n if (capacity < segments.data.byteLength) {\n capacity = segments.data.byteLength;\n gl.bufferData(gl.ARRAY_BUFFER, capacity, gl.STREAM_DRAW);\n }\n gl.bufferSubData(gl.ARRAY_BUFFER, 0, segments.data, 0, segments.used);\n gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, segments.used / STRIDE);\n gl.flush();\n },\n dispose() {\n if (disposed) return;\n disposed = true;\n segments.data = new Float32Array(0);\n segments.used = 0;\n release();\n }\n };\n events.addEventListener?.(\"webglcontextlost\", lost);\n return context;\n } catch (error) {\n release();\n throw error;\n }\n}\n\nself.onmessage = ({data}) => {\n try {\n if (data.type === 'init') {\n if (canvas !== null) throw new Error('duplicate worker init')\n canvas = data.canvas; contourSeed = data.seed\n painter = createWebGLContourContext(canvas, () => self.postMessage({type:'error',message:'WebGL context lost'}))\n rasterizer = painter ? 'worker-webgl2' : 'worker-canvas2d'\n if (!painter) painter = canvas.getContext('2d', {willReadFrequently:true})\n if (!painter) throw new Error('worker canvas unavailable')\n contourLineCv = {getContext:()=>painter, get width(){return canvas.width},get height(){return canvas.height}}\n self.postMessage({type:'initialized',rasterizer})\n } else if (data.type === 'frame') {\n if (!canvas || !painter) throw new Error('worker not initialized')\n const {w,h,dpr,phase,geometry,color,seq}=data\n if (![w,h,dpr,phase,seq].every(Number.isFinite) || w<1 || h<1 || dpr<=0 || dpr>2\n || Math.round(w*dpr)*Math.round(h*dpr)>8000000 || !/^#[0-9a-f]{8}$/i.test(color)) throw new Error('invalid frame')\n const resized = !contourGeom || contourGeom.w!==w || contourGeom.h!==h\n if (resized) contourBuild(w,h)\n const width=Math.round(w*dpr),height=Math.round(h*dpr)\n if(canvas.width!==width)canvas.width=width\n if(canvas.height!==height)canvas.height=height\n const c=color.slice(1)\n stroke=`rgba(${parseInt(c.slice(0,2),16)},${parseInt(c.slice(2,4),16)},${parseInt(c.slice(4,6),16)},${parseInt(c.slice(6,8),16)/255})`\n if(geometry || resized)contourExtract(phase)\n painter.setTransform(1,0,0,1,0,0)\n contourDrawLines()\n self.postMessage({type:'painted',seq,rasterizer})\n } else if (data.type === 'dispose') {\n if(painter && painter.dispose)painter.dispose()\n contourField=null;contourPaths=[];painter=null;canvas=null\n self.close()\n }\n } catch(error) { self.postMessage({type:'error',message:String(error.message || error)}) }\n}\nself.postMessage({type:'ready'})\n"
3291
3389
  /* END GENERATED CONTOUR WORKER */
3292
3390
  const onContourVisibility = () => {
3293
3391
  if (document.hidden && contourWorker) contourWorker.pending = null
@@ -3698,7 +3796,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3698
3796
  scrollAnimRun()
3699
3797
  }
3700
3798
 
3701
- /* A reader gesture wins immediately — the app's own READING_INTENTS list. */
3799
+ /* A reader gesture wins immediately: wheel, touch, pointer-down and ANY
3800
+ keydown with focus inside the scrollport all count — the tail snaps back to
3801
+ the app's own geometry instead of decaying. The list is deliberately broader
3802
+ than navigation keys: a lingering lag under a text selection (Ctrl+C) reads
3803
+ as broken rendering, not as smoothness. */
3702
3804
  const scrollAnimOnIntent = (entry) => { if (entry.offset !== 0) scrollAnimClear(entry) }
3703
3805
 
3704
3806
  const scrollAnimDetach = (entry) => {
@@ -3766,6 +3868,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3766
3868
  cheaper than the work the rest of this hook already does per mutation. */
3767
3869
  const syncScrollAnim = () => {
3768
3870
  if (typeof document === 'undefined') return
3871
+ ensureScrollAnimEnvWatch()
3769
3872
  const on = scrollAnimActive()
3770
3873
  if (!on && scrollAnimPorts.size === 0) { scrollAnimStop(); return }
3771
3874
  const live = document.querySelectorAll('[data-conversation-scroll]')
@@ -3808,18 +3911,43 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3808
3911
  scrollAnimDetach(entry)
3809
3912
  scrollAnimPorts.delete(port)
3810
3913
  }
3914
+ /* The environment watchers go with the run too. destroyScrollAnim runs on
3915
+ every master-switch OFF (unmount) and on run dispose, while the fragment
3916
+ itself is evaluated once per apply() — a listener registered at fragment
3917
+ scope would survive destroy, and after a disable -> enable cycle without a
3918
+ page load the old closure's syncScrollAnim would attach a SECOND set of
3919
+ port listeners and a second rAF loop alongside the new run's, both writing
3920
+ the same columns' transforms. */
3921
+ if (scrollAnimEnvWatched) {
3922
+ scrollAnimEnvWatched = false
3923
+ if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
3924
+ document.removeEventListener('visibilitychange', onScrollAnimEnvironmentChange)
3925
+ }
3926
+ if (scrollAnimMotionQuery !== null && typeof scrollAnimMotionQuery.removeEventListener === 'function') {
3927
+ scrollAnimMotionQuery.removeEventListener('change', onScrollAnimEnvironmentChange)
3928
+ }
3929
+ scrollAnimMotionQuery = null
3930
+ }
3811
3931
  }
3812
3932
  const onScrollAnimEnvironmentChange = () => {
3813
3933
  if (!scrollAnimActive()) scrollAnimSnap()
3814
3934
  else syncScrollAnim()
3815
3935
  }
3816
- if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
3817
- document.addEventListener('visibilitychange', onScrollAnimEnvironmentChange)
3818
- }
3819
- if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
3820
- scrollAnimMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
3821
- if (typeof scrollAnimMotionQuery.addEventListener === 'function') {
3822
- scrollAnimMotionQuery.addEventListener('change', onScrollAnimEnvironmentChange)
3936
+ /* Attached on the first sync (which every mount/reconcile path calls) and
3937
+ removed in destroyScrollAnim — NOT registered unconditionally at fragment
3938
+ scope, for the ghost-animator reason destroyScrollAnim documents. */
3939
+ let scrollAnimEnvWatched = false
3940
+ const ensureScrollAnimEnvWatch = () => {
3941
+ if (scrollAnimEnvWatched) return
3942
+ scrollAnimEnvWatched = true
3943
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
3944
+ document.addEventListener('visibilitychange', onScrollAnimEnvironmentChange)
3945
+ }
3946
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
3947
+ scrollAnimMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
3948
+ if (typeof scrollAnimMotionQuery.addEventListener === 'function') {
3949
+ scrollAnimMotionQuery.addEventListener('change', onScrollAnimEnvironmentChange)
3950
+ }
3823
3951
  }
3824
3952
  }
3825
3953
  // Let the page observer declared above re-attach as the app renders.
@@ -3855,6 +3983,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3855
3983
  // to outlive it so a stalled flourish can still never leave the app covered.
3856
3984
  let loaderKill = null
3857
3985
  let loaderDone = false
3986
+ /* The DOMContentLoaded deferral runLoader can install while <body> is still
3987
+ missing; the run's dispose effect removes it (removeEventListener is a no-op
3988
+ once a { once } listener has fired). */
3989
+ let loaderDomReadyPending = null
3858
3990
  const clearLoaderTimers = () => {
3859
3991
  if (loaderRaf !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(loaderRaf)
3860
3992
  loaderRaf = null
@@ -3923,7 +4055,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3923
4055
  // animation (loaderDone stays false, so the retry is the real first run).
3924
4056
  if (!document.body) {
3925
4057
  if (typeof document.addEventListener === 'function') {
3926
- document.addEventListener('DOMContentLoaded', () => { runLoader() }, { once: true })
4058
+ /* Kept in a variable the run's dispose effect can remove: a bundle that
4059
+ evaluated pre-body and a run disposed before DOMContentLoaded must not
4060
+ wire the dead closure back into the page. */
4061
+ loaderDomReadyPending = () => { runLoader() }
4062
+ document.addEventListener('DOMContentLoaded', loaderDomReadyPending, { once: true })
3927
4063
  }
3928
4064
  return
3929
4065
  }
@@ -4294,10 +4430,17 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4294
4430
  let thunderRebindTimer = null
4295
4431
  /* Retry budget for thunderRebind, bounded like every other retry in this
4296
4432
  file (rebindPrefs 40, settle watch 20): a permanently absent sessions
4297
- service must not leave a 120 ms poll running for the life of the page.
4298
- A settings change re-runs syncThunder -> thunderRebind, which restarts
4299
- the budget. */
4433
+ service must not leave a 120 ms poll running for the life of the page. The
4434
+ budget is two-phase — 100 fast tries (~12s) for the ordinary cold boot, then
4435
+ a slow phase (one service probe every 5s, bounded) for the runtime that
4436
+ arrives later than that, e.g. on a slow mirror. Once a service IS seen the
4437
+ counter resets, so a later service loss gets a fresh budget. */
4300
4438
  let thunderRebindAttempts = 0
4439
+ /* Two-phase shape of the budget above: ~12s of fast tries, then up to twenty
4440
+ more minutes of one cheap ctx.get probe every 5s. */
4441
+ const THUNDER_REBIND_FAST_LIMIT = 100
4442
+ const THUNDER_REBIND_SLOW_MS = 5000
4443
+ const THUNDER_REBIND_SLOW_END = 340
4301
4444
  let thunderWatchedId = null
4302
4445
  // null = nothing readable observed yet, so the next value is a baseline.
4303
4446
  let thunderLastRunning = null
@@ -4370,17 +4513,21 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4370
4513
  if (thunderRebindTimer !== null && typeof clearTimeout === 'function') clearTimeout(thunderRebindTimer)
4371
4514
  thunderRebindTimer = null
4372
4515
  /* Service not there yet: keep retrying rather than giving up for good, since
4373
- the only reason to be here is that the feature is switched on — but only
4374
- within the budget above. */
4516
+ the only reason to be here is that the feature is switched on — fast tries
4517
+ first, then a slow probe cadence, both bounded (see the budget note). */
4375
4518
  if (sessions === undefined) {
4376
- if (thunderRebindAttempts >= 100) {
4519
+ if (thunderRebindAttempts >= THUNDER_REBIND_SLOW_END) {
4377
4520
  dbg('thunder rebind gave up: no sessions service after', thunderRebindAttempts, 'tries')
4378
4521
  return
4379
4522
  }
4380
4523
  thunderRebindAttempts += 1
4381
- if (typeof setTimeout === 'function') thunderRebindTimer = setTimeout(thunderRebind, 120)
4524
+ if (typeof setTimeout === 'function') {
4525
+ thunderRebindTimer = setTimeout(thunderRebind,
4526
+ thunderRebindAttempts > THUNDER_REBIND_FAST_LIMIT ? THUNDER_REBIND_SLOW_MS : 120)
4527
+ }
4382
4528
  return
4383
4529
  }
4530
+ thunderRebindAttempts = 0
4384
4531
  // The list subscription may have been skipped earlier (no service then), so
4385
4532
  // attach it as soon as one exists.
4386
4533
  thunderSubscribeList(sessions)
@@ -4517,8 +4664,23 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4517
4664
  let audioAttentionObserver = null
4518
4665
  let audioAttentionFrame = null
4519
4666
  let audioAttentionKind = null
4667
+ /* Attach the observer exactly once per watch, retrying until <body> exists:
4668
+ a first sync that ran pre-body used to start the interval with no observer
4669
+ and every later sync short-circuited on the running timer, leaving that page
4670
+ load on the 1s poll forever. The tick is the retry point. */
4671
+ const audioAttentionEnsureObserver = () => {
4672
+ if (audioAttentionObserver !== null) return
4673
+ if (typeof MutationObserver !== 'function' || typeof document === 'undefined' || !document.body) return
4674
+ audioAttentionObserver = new MutationObserver(audioAttentionSchedule)
4675
+ try {
4676
+ audioAttentionObserver.observe(document.body, { childList: true, subtree: true })
4677
+ } catch (e) {
4678
+ audioAttentionObserver = null
4679
+ }
4680
+ }
4520
4681
  const audioAttentionTick = () => {
4521
4682
  try {
4683
+ audioAttentionEnsureObserver()
4522
4684
  const kind = detectPendingInteraction()
4523
4685
  if (kind === null) {
4524
4686
  audioAttentionKind = null
@@ -4547,14 +4709,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4547
4709
  if (wanted && audioAttentionTimer === null && typeof setInterval === 'function') {
4548
4710
  audioAttentionKind = null
4549
4711
  audioAttentionTimer = setInterval(audioAttentionTick, AUDIO_ATTENTION_POLL_MS)
4550
- if (typeof MutationObserver === 'function' && typeof document !== 'undefined' && document.body) {
4551
- audioAttentionObserver = new MutationObserver(audioAttentionSchedule)
4552
- try {
4553
- audioAttentionObserver.observe(document.body, { childList: true, subtree: true })
4554
- } catch (e) {
4555
- audioAttentionObserver = null
4556
- }
4557
- }
4712
+ audioAttentionEnsureObserver()
4558
4713
  audioAttentionTick()
4559
4714
  } else if (!wanted && audioAttentionTimer !== null) {
4560
4715
  stopAudioAttentionWatch()
@@ -4614,10 +4769,9 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4614
4769
  const BALANCE_KEY = 'balanceCapsule'
4615
4770
  const isBalanceCapsuleOn = () => prefsGet(BALANCE_KEY) === '1'
4616
4771
  /* What the credits mode's RIGHT-hand percentage slot reads: 已用xx% (the
4617
- plugin's own badge reading) or 剩余xx%. Anything other than 'remaining'
4618
- reads as 'used' — the preference is a two-literal choice, stored as
4619
- exactly one of them, and the tolerant default mirrors how
4620
- readContourRenderer treats its own select. */
4772
+ plugin's own badge reading) or 剩余xx%. Anything other than 'used' reads as
4773
+ 'remaining' — the preference is a two-literal choice, stored as exactly one
4774
+ of them, and 'remaining' is the default the field ships with. */
4621
4775
  const CREDIT_DISPLAY_KEY = 'creditDisplay'
4622
4776
  const readCreditDisplay = () => (prefsGet(CREDIT_DISPLAY_KEY) === 'used' ? 'used' : 'remaining')
4623
4777
  let balanceEl = null
@@ -4824,6 +4978,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4824
4978
  creditProviderResolved = false
4825
4979
  creditsRebindAttempts = 0
4826
4980
  creditsWatchedStore = null
4981
+ /* The per-provider fetch stamps go too. They are the throttle's memory, and
4982
+ a destroy (preview remount, channel teardown) is a fresh load as far as
4983
+ the capsule is concerned: keeping the stamps would throttle the first
4984
+ post-remount fetch on the pre-destroy timestamp and leave the channel
4985
+ read stuck on '--' for up to the whole 5-minute floor — which is exactly
4986
+ the recovery the preview's collapse is supposed to perform, and which the
4987
+ unthrottled wallet path already gets. */
4988
+ for (const stampKey of Object.keys(creditLastFetch)) delete creditLastFetch[stampKey]
4827
4989
  if (balanceSidebarRo !== null) {
4828
4990
  try { balanceSidebarRo.disconnect() } catch (e) { /* already gone */ }
4829
4991
  balanceSidebarRo = null
@@ -5148,31 +5310,34 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
5148
5310
  const next = (typeof provider === 'string' && JET_HUB_PROVIDER_LABELS[provider] !== undefined)
5149
5311
  ? provider
5150
5312
  : null
5151
- if (next !== creditProvider || (next !== null && !creditProviderResolved)) {
5313
+ const previous = creditProvider
5314
+ if (next !== previous || (next !== null && !creditProviderResolved)) {
5152
5315
  creditProvider = next
5153
5316
  creditProviderResolved = next !== null
5154
5317
  creditPaintIdle()
5155
5318
  }
5156
- // A switch (force, from the store subscription) bypasses the rate
5157
- // floor so the read follows the channel immediately; the 60s poll
5158
- // passes force=false and is throttled by creditsApply.
5159
- if (next !== null) await creditsApply(next, force === true)
5319
+ /* A switch (the resolved provider differs from the painted one) forces the
5320
+ fetch so the read follows the channel immediately; an ordinary publish
5321
+ resolving the SAME provider keeps the per-provider rate floor, exactly as
5322
+ the creditsRebind note promises. Both the 60s poll and the debounced
5323
+ store-subscription path pass force=false and rely on this detector. */
5324
+ if (next !== null) await creditsApply(next, force === true || next !== previous)
5160
5325
  }
5161
5326
 
5162
5327
  /* Store events land here; a channel flip re-reads the directory and
5163
5328
  repaints. Debounced so a burst of publishes collapses into one read.
5164
- The debounce itself is also the switch detector: if the provider the
5165
- delayed read resolves differs from the one painted, the fetch is
5166
- forced — ordinary publishes (balance churn, model-list updates) keep
5167
- the throttle. */
5329
+ The read passes force=false: creditsRefresh itself is the switch
5330
+ detector — if the provider the delayed read resolves differs from the
5331
+ one painted, the fetch is forced; ordinary publishes (balance churn,
5332
+ model-list updates) keep the throttle. */
5168
5333
  const creditsRebind = () => {
5169
5334
  if (balanceEl === null) return
5170
5335
  if (creditsRebindTimer !== null && typeof clearTimeout === 'function') clearTimeout(creditsRebindTimer)
5171
5336
  creditsRebindTimer = null
5172
- if (typeof setTimeout !== 'function') { creditsRefresh(true); return }
5337
+ if (typeof setTimeout !== 'function') { creditsRefresh(false); return }
5173
5338
  creditsRebindTimer = setTimeout(() => {
5174
5339
  creditsRebindTimer = null
5175
- creditsRefresh(true)
5340
+ creditsRefresh(false)
5176
5341
  }, 150)
5177
5342
  }
5178
5343
 
@@ -6577,6 +6742,37 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
6577
6742
  [data-cordis-decline]:hover:not(:disabled) {
6578
6743
  background: #d6281d !important;
6579
6744
  }
6745
+ /* ---------- 审批白条的前提:祖先不能是 containing block(issue #41) ----------
6746
+ The band below fills the VIEWPORT only while no ancestor between the
6747
+ panel and the viewport is a containing block for fixed descendants.
6748
+ DSH 0.2.2-rc.1 declared will-change: transform on the composer seat,
6749
+ which builds that containing block exactly like a real transform does
6750
+ (CSS Will Change: a property that would make the element generate a
6751
+ containing block MUST do so when named in will-change). The approval
6752
+ panel replaces the composer and therefore lives INSIDE that seat, so
6753
+ position: fixed; inset: 0 started resolving against the seat's
6754
+ padding box instead of the viewport. Fixed also takes the panel out of
6755
+ flow, which left the seat with no in-flow child: its height collapsed
6756
+ to 0, the layer resolved to the seat's width by ZERO height, and the
6757
+ card centred on that line straddled the window's bottom edge (fixture:
6758
+ layer 720x0, card running 95px past the viewport). Only the top strip
6759
+ reading 「● 等待审批」 survived — the reported screenshot, and the
6760
+ 152-second approval that followed.
6761
+ The fix is one declaration on the ancestor, not on the band: CSS
6762
+ cannot escape a containing block, so a transformed/will-changed
6763
+ ancestor is not something the band can style its way out of. Dropping
6764
+ the hint only while a panel is mounted keeps the app's compositing
6765
+ hint for every other frame. Verified live in Chrome: computed
6766
+ will-change flips to auto and the layer measures the full viewport;
6767
+ deleting this declaration at runtime collapses the same layer back to
6768
+ zero height, and re-inserting it restores the viewport rect
6769
+ (test/approval-band-transform.test.js covers both directions).
6770
+ Both anchors are listed because the [class$=] hook dies the moment
6771
+ upstream appends a second class to the seat, and [data-composer-seat]
6772
+ is the e2e hook the app itself declares. */
6773
+ body:has([data-approval-key]) :is([data-composer-seat], [class$='_composerSeat']) {
6774
+ will-change: auto !important;
6775
+ }
6580
6776
  /* ---------- 审批弹窗:全屏白条 + 上下遮罩(Endfield 寻访确认框) ----------
6581
6777
  The shipped panel is a rounded warn-bordered card parked inside the
6582
6778
  composer column (max-width: --dsh-chat-content-width). The reference
@@ -6594,9 +6790,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
6594
6790
  CSS-module SUFFIXES, each rule scoped under the root so a generic
6595
6791
  '_card' / '_body' elsewhere in the app is untouched.
6596
6792
  position:fixed escapes the composer column's width cap and the
6597
- frame's overflow clip; no ancestor between the seat and the viewport
6598
- carries a transform/filter, and if a future one did, the band simply
6599
- stays in flow — degraded position, never a broken dialog. */
6793
+ frame's overflow clip, and it lands on the viewport only while the
6794
+ seat does not build a containing block — the rule above the block is
6795
+ what keeps that true (issue #41), not the absence of ancestors with a
6796
+ transform. An earlier revision of this note claimed a transformed
6797
+ ancestor would merely leave the band in flow, "degraded position,
6798
+ never a broken dialog"; that was wrong, and #41 is the proof: fixed
6799
+ does not fall back to static, it keeps laying out against a seat that
6800
+ has just been emptied, so the band collapses instead of degrading. */
6600
6801
  [data-approval-key] {
6601
6802
  position: fixed !important;
6602
6803
  inset: 0 !important;
@@ -7274,7 +7475,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
7274
7475
 
7275
7476
  Note the + is the ONLY composer element whose class contains '_add'; the
7276
7477
  scope cannot reach the settings wrappers, the diff lines or the address bar.
7277
- Verified by test/hover-check.js against real hovered pixels in both
7478
+ Verified by test/tools/hover-check.js against real hovered pixels in both
7278
7479
  palettes and both schemes. */
7279
7480
  body[data-ds-dark-theme] :is([data-composer-seat], [class$='_composerSeat'], [class$='_composerHero']) [class*='_add'] {
7280
7481
  color: var(--edge-accent) !important;
@@ -7832,6 +8033,27 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
7832
8033
  body[data-ds-dark-theme] .endfield-settings-group-title {
7833
8034
  color: var(--edge-status-dark);
7834
8035
  }
8036
+ /* ---------- Settings page: 二级折叠子节 ----------
8037
+ 折叠头是全宽 button:文字颜色继承面板自身(主题关闭时仍是 app 的
8038
+ ink,可读性由 app 令牌保证),只有悬停/聚焦态借用组头同款的
8039
+ palette 配色对 —— 静止的目录保持中性,交互时的反馈才着色。
8040
+ 类名避开主题 hover 反转规则的语义子串(item/row/card/tab…),
8041
+ 那条 !important 规则碰不到这个控件。
8042
+ body 以 [hidden] 收起而非条件渲染:行不离开 DOM,display:none
8043
+ 是显式兜底,防止宿主或 UA 样式表把 hidden 覆盖回去。 */
8044
+ .endfield-settings-sub-toggle:hover {
8045
+ color: var(--edge-status-light);
8046
+ }
8047
+ body[data-ds-dark-theme] .endfield-settings-sub-toggle:hover {
8048
+ color: var(--edge-status-dark);
8049
+ }
8050
+ .endfield-settings-sub-toggle:focus-visible {
8051
+ outline: 1px solid currentColor;
8052
+ outline-offset: 2px;
8053
+ }
8054
+ .endfield-settings-sub-body[hidden] {
8055
+ display: none;
8056
+ }
7835
8057
  /* ================= 雷霆大字 (娱乐模式) =================
7836
8058
  The task-boundary announcement. Fixed, centred, above the shell overlay
7837
8059
  layer and the app's own dialogs but BELOW the boot plate (2147483000), so
@@ -7850,7 +8072,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
7850
8072
  2.11:1 on bg-layer-1, i.e. the same "on cream, #fff500 is not a colour
7851
8073
  choice, it is an erasure" failure this theme already documents for the
7852
8074
  watermark and the turn-status label — except here it was pure white. Caught
7853
- by rendering the plate and measuring the pixels (test/thunder-shot.js), not
8075
+ by rendering the plate and measuring the pixels (test/tools/thunder-shot.js), not
7854
8076
  by reading the CSS.
7855
8077
 
7856
8078
  Measured white-vs-scrimmed-surface, ink #101110 over each light surface:
@@ -8419,6 +8641,39 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8419
8641
  [data-endfield-balance][data-endfield-balance-shade][data-endfield-balance-position] {
8420
8642
  transform: translateY(-6px);
8421
8643
  }
8644
+
8645
+ /* ---------- Ink fills need an inverted foreground in light mode ----------
8646
+ --dsw-alias-state-business-primary is ink in the light palette (see the
8647
+ neutralize block above), and consumers paint it as a FILL while their
8648
+ glyph stays on --dsw-alias-label-primary — which is ink too. Fill and
8649
+ glyph then resolve to #101110 and the control reads as a solid black
8650
+ rectangle: the host's active settings tab lost its own label, and
8651
+ third-party switches lost their knob.
8652
+
8653
+ Fixing it here rather than by repainting the token keeps the light
8654
+ palette's ink/paper language: the fill stays ink, only the glyph moves
8655
+ to the host's pure white. Not --dsw-alias-label-primary-inverted: this
8656
+ sheet already repaints that role to ink in both palettes (see the warm
8657
+ label block), so the knob would stay invisible.
8658
+
8659
+ Both selectors are structural rather than hash-named: the tab hook is
8660
+ the ARIA contract, and the switch hook matches the semantic suffix
8661
+ every switch carries — dsh-better-display's pinned class and the
8662
+ host's own CSS-module switches alike end in _switch, and the
8663
+ [data-on] guard keeps the rule to the checked state, so an unchecked
8664
+ knob keeps its ink fill. The hash form this replaces (the shape PR #37
8665
+ first shipped) is exactly what selector-guard's part 1 fails on: the
8666
+ suffix survives any rehash, the pinned class does not.
8667
+
8668
+ The tab hook is scoped to dialogs on purpose: the app's top-level nav
8669
+ is role=tab too, but it sits on paper with an ink underline, so its
8670
+ selected label must stay ink. */
8671
+ body:not([data-ds-dark-theme]) [role='dialog'] [role='tab'][aria-selected='true'] {
8672
+ color: var(--dsw-static-neutral-bluish-00) !important;
8673
+ }
8674
+ body:not([data-ds-dark-theme]) [class$='_switch'][data-on]::after {
8675
+ background: var(--dsw-static-neutral-bluish-00) !important;
8676
+ }
8422
8677
  `)
8423
8678
  syncRadiusMode()
8424
8679
  syncGlass()
@@ -8580,9 +8835,18 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8580
8835
  groupBg: '背景',
8581
8836
  groupAnim: '动画',
8582
8837
  groupFun: '娱乐',
8583
- /* 折叠分组:组头是「编号 名称」加一个随展开态翻转的箭头;
8584
- 展开后的「收起」提示放在箭头的 title 上而非行内,收起的
8585
- 五个组头并排时行内文案会重复五次。 */
8838
+ /* 二级折叠子节的标题(组 > 子节 > 行)。子节头是「名称 + 拉丁大写 +
8839
+ 状态摘要」加一个随展开态翻转的箭头;与组头一样,拉丁行在英文界面下
8840
+ 与中文名重复时省略。状态摘要复用各行已有的 on/off 文案,不新增词汇。 */
8841
+ subContour: '等高线',
8842
+ subWatermark: '水印',
8843
+ subScrollAnim: '输出滚动动画',
8844
+ subLoader: '启动加载动画',
8845
+ subThunder: '雷霆大字',
8846
+ subBalance: '余额胶囊',
8847
+ subAudioSlots: '通知槽位',
8848
+ subAudioSound: '音量与音源',
8849
+ subAudioMeta: '预留与诊断',
8586
8850
  themeRow: '终末地主题',
8587
8851
  themeOn: '开启主题',
8588
8852
  themeOff: '关闭主题',
@@ -8607,6 +8871,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8607
8871
  contourTrailOn: '开启轨迹',
8608
8872
  contourTrailOff: '关闭轨迹',
8609
8873
  contourTrailHint: '鼠标移动时局部扰动等高线并逐渐恢复;仅动态模式生效,静态或减少动态效果时暂停',
8874
+ contourTrailHintWorker: 'Worker/WebGL 渲染暂不支持轨迹,切回 Canvas 渲染后生效',
8610
8875
  contourAnimRow: '动态等高线',
8611
8876
  contourAnimOn: '开启动态',
8612
8877
  contourAnimOff: '切为静态',
@@ -8770,6 +9035,15 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8770
9035
  groupBg: 'BACKGROUND',
8771
9036
  groupAnim: 'ANIMATION',
8772
9037
  groupFun: 'ENTERTAINMENT',
9038
+ subContour: 'CONTOUR',
9039
+ subWatermark: 'WORDMARK',
9040
+ subScrollAnim: 'SCROLL',
9041
+ subLoader: 'BOOT',
9042
+ subThunder: 'THUNDER',
9043
+ subBalance: 'BALANCE',
9044
+ subAudioSlots: 'SLOTS',
9045
+ subAudioSound: 'SOUND',
9046
+ subAudioMeta: 'RESERVED',
8773
9047
  themeRow: 'Endfield theme',
8774
9048
  themeOn: 'Turn on',
8775
9049
  themeOff: 'Turn off',
@@ -8794,6 +9068,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8794
9068
  contourTrailOn: 'Enable trail',
8795
9069
  contourTrailOff: 'Disable trail',
8796
9070
  contourTrailHint: 'Locally deforms contours near the mouse, then fades; pauses in static or reduced-motion mode',
9071
+ contourTrailHintWorker: 'Not drawn by the Worker/WebGL renderer; switch back to Canvas to use it',
8797
9072
  contourAnimRow: 'Animated contours',
8798
9073
  contourAnimOn: 'Animate',
8799
9074
  contourAnimOff: 'Make static',
@@ -9025,6 +9300,15 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9025
9300
  const [audioDiag, setAudioDiag] = R.useState(isAudioDiagOn())
9026
9301
  const [hostState, setHostState] = R.useState(null)
9027
9302
  const [previewNote, setPreviewNote] = R.useState('')
9303
+ /* 二级折叠子节的展开表。一个对象存全部子节(id -> bool),而不是每个
9304
+ 子节一个 useState:这些子节由同一个 render 函数内联构造,没有独立
9305
+ 的组件类型,逐节建 hook 既无法命名也无法稳定计数。缺省(未登记)
9306
+ 即收起,所以每次打开设置页都从最紧凑的目录开始;这是纯 UI 状态,
9307
+ 不进持久化偏好(durable section 只收功能开关)。测试桩的 setter
9308
+ 是 no-op,同样依赖「body 始终渲染、仅以 hidden 收起」来在收起态
9309
+ 下保持整棵行树可遍历。 */
9310
+ const [openSubs, setOpenSubs] = R.useState({})
9311
+ const toggleSub = (id) => setOpenSubs((prev) => ({ ...prev, [id]: !prev[id] }))
9028
9312
  const refreshHostState = () => {
9029
9313
  if (typeof fetch !== 'function') return
9030
9314
  fetch(AUDIO_STATE_URL, { headers: { accept: 'application/json' } })
@@ -9191,13 +9475,23 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9191
9475
  const next = !isEnabled()
9192
9476
  prefsSet(ENABLED_KEY, next ? '1' : '0')
9193
9477
  setEnabled(next)
9194
- if (next) { mount(); syncWatermarkVisibility(); syncContour() }
9195
- else { unmount(); syncWatermarkVisibility() }
9196
- /* The announcement watcher is gated on the master switch too, so it has
9197
- to be reconciled here. unmount() already stops it, but turning the
9198
- theme back ON must restart it — otherwise the feature would stay dead
9199
- until the next page load. */
9200
- syncThunder()
9478
+ if (next) {
9479
+ mount()
9480
+ syncWatermarkVisibility()
9481
+ syncContour()
9482
+ /* The same list the initial mount block and reconcileFromPrefs run:
9483
+ on a scope that is NOT durably served no authoritative echo ever
9484
+ comes back to run reconcileFromPrefs, so every layer gated on its
9485
+ own switch has to be reconciled here or it stays dead until the
9486
+ next page load — the trap the thunder sync used to document. */
9487
+ syncScrollAnim()
9488
+ syncThunder()
9489
+ syncAudioAttentionWatch()
9490
+ syncBalanceCapsule()
9491
+ } else {
9492
+ unmount()
9493
+ syncWatermarkVisibility()
9494
+ }
9201
9495
  }
9202
9496
  const toggleContour = () => {
9203
9497
  const next = !isContourOn()
@@ -9420,7 +9714,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9420
9714
  title: audioOn ? '' : t('audioNeedOn'),
9421
9715
  }, t('audioTest') + ' · ' + t(labelKey))
9422
9716
  const toggleAudio = () => {
9423
- const next = !audioOn
9717
+ const next = !isAudioOn()
9424
9718
  prefsSet(AUDIO_ENABLED_KEY, next ? '1' : '0')
9425
9719
  setAudioOn(next)
9426
9720
  // The attention watcher is gated on this switch, so it has to be
@@ -9429,13 +9723,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9429
9723
  if (next) playPreview('turn-done')
9430
9724
  }
9431
9725
  const toggleAudioStart = () => {
9432
- const next = !audioStart
9726
+ const next = !isAudioStartOn()
9433
9727
  prefsSet(AUDIO_TURN_START_KEY, next ? '1' : '0')
9434
9728
  setAudioStart(next)
9435
9729
  if (next) playPreview('turn-start')
9436
9730
  }
9437
9731
  const toggleAudioBoot = () => {
9438
- const next = !audioBoot
9732
+ const next = !isAudioBootOn()
9439
9733
  prefsSet(AUDIO_BOOT_KEY, next ? '1' : '0')
9440
9734
  setAudioBoot(next)
9441
9735
  // Preview the boot slot itself: the real one fires from the loader,
@@ -9443,7 +9737,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9443
9737
  if (next) playPreview('boot')
9444
9738
  }
9445
9739
  const toggleAudioDone = () => {
9446
- const next = !audioDone
9740
+ const next = !isAudioDoneOn()
9447
9741
  prefsSet(AUDIO_TURN_DONE_KEY, next ? '1' : '0')
9448
9742
  setAudioDone(next)
9449
9743
  if (next) playPreview('turn-done')
@@ -9454,12 +9748,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9454
9748
  setAudioVolume(clamped)
9455
9749
  }
9456
9750
  const toggleAudioHumanOnly = () => {
9457
- const next = !audioHumanOnly
9751
+ const next = !isAudioHumanOnly()
9458
9752
  prefsSet(AUDIO_HUMAN_ONLY_KEY, next ? '1' : '0')
9459
9753
  setAudioHumanOnly(next)
9460
9754
  }
9461
9755
  const toggleAudioDiag = () => {
9462
- const next = !audioDiag
9756
+ const next = !isAudioDiagOn()
9463
9757
  prefsSet(AUDIO_DIAG_KEY, next ? '1' : '0')
9464
9758
  setAudioDiag(next)
9465
9759
  }
@@ -9507,10 +9801,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9507
9801
  return rows.join(' · ')
9508
9802
  }
9509
9803
  const pageStyle = { maxWidth: '640px', padding: '4px 0 16px' }
9510
- /* The ten switches are grouped into four concerns so the page can be
9804
+ /* The switches are grouped into five concerns so the page can be
9511
9805
  scanned instead of read as a flat list: 主题 (master switch +
9512
- appearance), 背景 (contour sheet + watermark), 动画 (boot loader),
9513
- 娱乐 (雷霆大字 announcements + their entry animation).
9806
+ appearance), 背景 (contour sheet + watermark), 动画 (output scroll
9807
+ animation + boot loader), 娱乐 (雷霆大字 announcements), 通知 (audio
9808
+ notifications).
9514
9809
  Each group is an editorial numbered header; rows keep their stable
9515
9810
  React keys. The last row of each group drops its divider so the next
9516
9811
  group header's own rule is the only line between groups.
@@ -9541,6 +9836,48 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9541
9836
  }
9542
9837
  /** "<row label>: <on|off>" — one spelling for every status row. */
9543
9838
  const stateOf = (on) => t(on ? 'on' : 'off')
9839
+ /* 二级折叠子节(组 > 子节 > 行)。头部是全宽按钮:左侧小刻线 + 中文名
9840
+ + 拉丁大写(与组头同一套 editorial 语言,但整体降一级),右侧可选
9841
+ 状态摘要 + 随展开态翻转的 +/− 刻字箭头;展开态同时由 aria-expanded
9842
+ 表达,提示不写进行内(收起的九个子节头会把同一段文案重复九次)。
9843
+ body 始终渲染、仅以 hidden 属性收起:行不离开元素树,store 订阅、
9844
+ disabled 推导与测试的树遍历都不因折叠状态而改变;status 摘要让用户
9845
+ 在收起时仍能看到该节主开关的读数。类名避开主题 hover 反转规则的
9846
+ 语义子串(item/row/card/tab…),折叠头的文字颜色全程自持。 */
9847
+ const subSection = (id, key, status, rows) => {
9848
+ const open = openSubs[id] === true
9849
+ const bodyId = 'endfield-sub-' + id
9850
+ const head = [
9851
+ R.createElement('span', { key: 'mark', 'aria-hidden': 'true', style: { width: '3px', height: '11px', flex: '0 0 auto', background: 'currentColor', opacity: 0.55 } }),
9852
+ R.createElement('span', { key: 'cn', style: { fontSize: '12px', fontWeight: 600, letterSpacing: '0.12em', lineHeight: '1.5' } }, t(key)),
9853
+ ]
9854
+ const latin = LOCALE_EN[key]
9855
+ if (latin !== undefined && latin !== t(key)) {
9856
+ head.push(R.createElement('span', { key: 'en', style: { fontSize: '10px', fontWeight: 500, letterSpacing: '0.18em', opacity: 0.6, lineHeight: '1.5' } }, latin))
9857
+ }
9858
+ head.push(R.createElement('span', { key: 'gap', 'aria-hidden': 'true', style: { flex: '1 1 auto' } }))
9859
+ if (status !== undefined) {
9860
+ head.push(R.createElement('span', { key: 'status', style: { fontSize: '11px', opacity: 0.66, lineHeight: '1.5' } }, status))
9861
+ }
9862
+ head.push(R.createElement('span', { key: 'arrow', 'aria-hidden': 'true', style: { flex: '0 0 auto', fontSize: '13px', lineHeight: '1' } }, open ? '−' : '+'))
9863
+ return R.createElement('div', { key: 'sub-' + id, className: 'endfield-settings-sub' }, [
9864
+ R.createElement('button', {
9865
+ key: 'head',
9866
+ type: 'button',
9867
+ className: 'endfield-settings-sub-toggle',
9868
+ onClick: () => toggleSub(id),
9869
+ 'aria-expanded': open,
9870
+ 'aria-controls': bodyId,
9871
+ style: {
9872
+ display: 'flex', alignItems: 'center', gap: '8px', width: '100%',
9873
+ padding: '10px 0', background: 'transparent', border: 'none',
9874
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
9875
+ color: 'inherit', textAlign: 'left', cursor: 'pointer',
9876
+ },
9877
+ }, head),
9878
+ R.createElement('div', { key: 'sub-body-' + id, id: bodyId, hidden: !open, className: 'endfield-settings-sub-body' }, rows),
9879
+ ])
9880
+ }
9544
9881
  const row = (key, last, children) => R.createElement('div', { key, style: last ? { ...rowStyle, borderBottom: 'none' } : rowStyle }, children)
9545
9882
  return R.createElement('div', { className: 'endfield-settings', style: pageStyle }, [
9546
9883
  /* --- 01 主题:总开关在最前,随后是配色与圆角 --- */
@@ -9599,6 +9936,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9599
9936
  /* --- 02 背景:等高线 + 水印,各自的主开关在前、附属开关在后 --- */
9600
9937
  R.createElement('div', { key: 'group-bg' }, [
9601
9938
  groupTitle('02', 'groupBg', false),
9939
+ subSection('contour', 'subContour', stateOf(contourOn), [
9602
9940
  row('contour', false, [
9603
9941
  R.createElement('span', { style: labelStyle },
9604
9942
  t('contourRow') + t('sep') + stateOf(contourOn),
@@ -9632,7 +9970,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9632
9970
  row('contour-trail', false, [
9633
9971
  R.createElement('span', { style: labelStyle },
9634
9972
  t('contourTrailRow') + t('sep') + stateOf(contourTrailOn),
9635
- R.createElement('span', { style: hintStyle }, t('contourTrailHint'))
9973
+ R.createElement('span', { style: hintStyle },
9974
+ // Say so when the selected renderer cannot draw the trail,
9975
+ // rather than letting the switch look broken.
9976
+ (contourTrailOn && contourRenderer === 'worker-webgl')
9977
+ ? t('contourTrailHintWorker')
9978
+ : t('contourTrailHint')
9979
+ )
9636
9980
  ),
9637
9981
  R.createElement('button', {
9638
9982
  type: 'button', onClick: toggleContourTrail,
@@ -9697,6 +10041,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9697
10041
  title: contourOn ? '' : t('contourAnimNeedLayer'),
9698
10042
  }, t(contourScrollPause ? 'contourScrollPauseOff' : 'contourScrollPauseOn'))
9699
10043
  ]),
10044
+ ]),
10045
+ subSection('watermark', 'subWatermark', stateOf(wmOn), [
9700
10046
  row('watermark', false, [
9701
10047
  R.createElement('span', { style: labelStyle }, t('watermarkRow') + t('sep') + stateOf(wmOn)),
9702
10048
  R.createElement('button', { type: 'button', onClick: toggleWm, style: btnStyleFor(wmOn) }, t(wmOn ? 'watermarkOff' : 'watermarkOn'))
@@ -9717,10 +10063,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9717
10063
  title: wmOn ? '' : t('wmPersistNeedWm'),
9718
10064
  }, t(wmPersist ? 'wmPersistOff' : 'wmPersistOn'))
9719
10065
  ]),
10066
+ ]),
9720
10067
  ]),
9721
10068
  /* --- 03 动画:输出滚动动画 + 启动加载动画 --- */
9722
10069
  R.createElement('div', { key: 'group-anim' }, [
9723
10070
  groupTitle('03', 'groupAnim', false),
10071
+ subSection('scroll', 'subScrollAnim', stateOf(scrollAnim), [
9724
10072
  row('scroll-anim', false, [
9725
10073
  R.createElement('span', { style: labelStyle },
9726
10074
  t('scrollAnimRow') + t('sep') + stateOf(scrollAnim),
@@ -9758,6 +10106,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9758
10106
  }, t(SCROLL_ANIM_LEVEL_COPY[level])))
9759
10107
  )
9760
10108
  ]),
10109
+ ]),
10110
+ subSection('loader', 'subLoader', stateOf(loaderOn), [
9761
10111
  row('loader', true, [
9762
10112
  R.createElement('span', { style: labelStyle },
9763
10113
  t('loaderRow') + t('sep') + stateOf(loaderOn),
@@ -9781,10 +10131,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9781
10131
  R.createElement('button', { type: 'button', onClick: toggleLoader, style: btnStyleFor(loaderOn) }, t(loaderOn ? 'loaderOff' : 'loaderOn'))
9782
10132
  )
9783
10133
  ]),
10134
+ ]),
9784
10135
  ]),
9785
10136
  /* --- 04 娱乐:雷霆大字(主开关 + 入场动画子开关) --- */
9786
10137
  R.createElement('div', { key: 'group-fun' }, [
9787
10138
  groupTitle('04', 'groupFun', false),
10139
+ subSection('thunder', 'subThunder', stateOf(thunderOn), [
9788
10140
  row('thunder', false, [
9789
10141
  R.createElement('span', { style: labelStyle },
9790
10142
  t('thunderRow') + t('sep') + stateOf(thunderOn),
@@ -9825,6 +10177,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9825
10177
  title: thunderOn ? '' : t('thunderNeed'),
9826
10178
  }, t(thunderAnim ? 'thunderAnimOff' : 'thunderAnimOn'))
9827
10179
  ]),
10180
+ ]),
10181
+ subSection('balance', 'subBalance', stateOf(balanceOn), [
9828
10182
  /* --- 顶部余额胶囊:同样属于「娱乐」组的悬浮层 --- */
9829
10183
  row('balance-capsule', false, [
9830
10184
  R.createElement('span', { style: labelStyle },
@@ -9875,11 +10229,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9875
10229
  t('balancePosition' + value.charAt(0).toUpperCase() + value.slice(1)))))
9876
10230
  ]),
9877
10231
  /* --- 防遮挡两个子开关:侧栏展开自动隐藏 / 指针靠近自动隐藏 ---
9878
- Both ship ON because the遮挡 they answer is what the capsule
9879
- rows exist for; each toggle can silence its rule individually.
9880
- The switches stay enabled with the capsule off (a
9881
- pre-configuration the next mount honours), mirroring the
9882
- position select above; the hint states what fades and when. */
10232
+ Sidebar-hide ships OFF (opening the sidebar is the user's own
10233
+ deliberate action); hover-hide ships ON (the capsule moving out
10234
+ of the pointer's way is the un-asked-for behaviour). Each toggle
10235
+ can silence its rule individually. The switches stay enabled
10236
+ with the capsule off (a pre-configuration the next mount
10237
+ honours), mirroring the position select above; the hint states
10238
+ what fades and when. */
9883
10239
  row('balance-capsule-sidebar-hide', false, [
9884
10240
  R.createElement('span', { style: labelStyle },
9885
10241
  t('balanceSidebarHideRow') + t('sep') + stateOf(balanceSidebarHide),
@@ -9891,7 +10247,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9891
10247
  type: 'button',
9892
10248
  onClick: toggleBalanceSidebarHide,
9893
10249
  style: btnStyleFor(balanceSidebarHide),
9894
- }, t(balanceSidebarHide ? 'balanceOff' : 'balanceOn'))
10250
+ }, t(balanceSidebarHide ? 'off' : 'on'))
9895
10251
  ]),
9896
10252
  row('balance-capsule-hover-hide', false, [
9897
10253
  R.createElement('span', { style: labelStyle },
@@ -9904,7 +10260,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9904
10260
  type: 'button',
9905
10261
  onClick: toggleBalanceHoverHide,
9906
10262
  style: btnStyleFor(balanceHoverHide),
9907
- }, t(balanceHoverHide ? 'balanceOff' : 'balanceOn'))
10263
+ }, t(balanceHoverHide ? 'off' : 'on'))
9908
10264
  ]),
9909
10265
  /* --- 渠道额度读数:右侧百分比显示已用 or 剩余 ---
9910
10266
  The row lives directly under the capsule switch because it only
@@ -9927,6 +10283,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9927
10283
  }, CREDIT_DISPLAY_OPTIONS.map((value) => R.createElement('option', { key: value, value },
9928
10284
  t(value === 'used' ? 'creditDisplayUsed' : 'creditDisplayRemaining'))))
9929
10285
  ]),
10286
+ ]),
9930
10287
  ]),
9931
10288
  /* --- 05 音频:两个生效槽位 + 两个预留槽位 ---
9932
10289
  Every row states WHEN it fires, because that is the whole contract
@@ -9934,6 +10291,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9934
10291
  not fire yet, so a switch that does nothing cannot read as broken. */
9935
10292
  R.createElement('div', { key: 'group-audio' }, [
9936
10293
  groupTitle('05', 'groupAudio', false),
10294
+ subSection('slots', 'subAudioSlots', stateOf(audioOn), [
9937
10295
  row('audio', false, [
9938
10296
  R.createElement('span', { style: labelStyle },
9939
10297
  t('audioRow') + t('sep') + stateOf(audioOn),
@@ -9993,6 +10351,21 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9993
10351
  }, t(audioDone ? 'audioDoneOff' : 'audioDoneOn'))
9994
10352
  )
9995
10353
  ]),
10354
+ row('audio-human', true, [
10355
+ R.createElement('span', { style: labelStyle },
10356
+ t('audioHumanOnlyRow') + t('sep') + t(audioHumanOnly ? 'audioHumanOnlyOn' : 'audioHumanOnlyOff'),
10357
+ R.createElement('span', { style: hintStyle },
10358
+ t(audioHumanOnly ? 'audioHumanOnlyHintOn' : 'audioHumanOnlyHintOff')
10359
+ )
10360
+ ),
10361
+ R.createElement('button', {
10362
+ type: 'button', onClick: toggleAudioHumanOnly,
10363
+ style: btnStyleFor(audioHumanOnly, !audioOn), disabled: !audioOn,
10364
+ title: audioOn ? '' : t('audioNeedOn'),
10365
+ }, t(audioHumanOnly ? 'audioHumanOnlyOff' : 'audioHumanOnlyOn'))
10366
+ ]),
10367
+ ]),
10368
+ subSection('sound', 'subAudioSound', String(audioVolume) + '%', [
9996
10369
  row('audio-volume', false, [
9997
10370
  R.createElement('span', { style: labelStyle },
9998
10371
  t('audioVolumeRow') + t('sep') + String(audioVolume) + '%',
@@ -10011,24 +10384,6 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10011
10384
  R.createElement('span', { style: { ...labelStyle, minWidth: '38px', textAlign: 'right' } }, String(audioVolume) + '%')
10012
10385
  )
10013
10386
  ]),
10014
- row('audio-attention', false, [
10015
- R.createElement('span', { style: labelStyle },
10016
- t('audioAttentionRow') + t('sep') + stateOf(audioTestReady()),
10017
- R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
10018
- ),
10019
- R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
10020
- audioTestButton('attention', 'audioSlotAttention')
10021
- )
10022
- ]),
10023
- row('audio-fail', false, [
10024
- R.createElement('span', { style: labelStyle },
10025
- t('audioTurnFailRow') + t('sep') + stateOf(audioTestReady()),
10026
- R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
10027
- ),
10028
- R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
10029
- audioTestButton('turn-fail', 'audioSlotFail')
10030
- )
10031
- ]),
10032
10387
  row('audio-source', false, [
10033
10388
  R.createElement('span', { style: labelStyle },
10034
10389
  t('audioFileRow') + t('sep') + sourceSummary(),
@@ -10039,7 +10394,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10039
10394
  style: btnStyleFor(false),
10040
10395
  }, t('audioRefresh'))
10041
10396
  ]),
10042
- row('audio-dir', false, [
10397
+ row('audio-dir', true, [
10043
10398
  R.createElement('span', { style: labelStyle },
10044
10399
  t('audioSoundDirRow') + t('sep') + (readAudioSoundDir() || t('audioSoundDirDefault')),
10045
10400
  R.createElement('span', { style: hintStyle }, t('audioSoundDirHint'))
@@ -10056,18 +10411,27 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10056
10411
  })
10057
10412
  )
10058
10413
  ]),
10059
- row('audio-human', false, [
10414
+ ]),
10415
+ /* 预留槽位(需要你回应 / 出错)与诊断日志同节:三行都没有
10416
+ 主开关,行内的试听按钮与诊断开关就是这一节的全部。 */
10417
+ subSection('reserved', 'subAudioMeta', undefined, [
10418
+ row('audio-attention', false, [
10060
10419
  R.createElement('span', { style: labelStyle },
10061
- t('audioHumanOnlyRow') + t('sep') + t(audioHumanOnly ? 'audioHumanOnlyOn' : 'audioHumanOnlyOff'),
10062
- R.createElement('span', { style: hintStyle },
10063
- t(audioHumanOnly ? 'audioHumanOnlyHintOn' : 'audioHumanOnlyHintOff')
10064
- )
10420
+ t('audioAttentionRow') + t('sep') + stateOf(audioTestReady()),
10421
+ R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
10065
10422
  ),
10066
- R.createElement('button', {
10067
- type: 'button', onClick: toggleAudioHumanOnly,
10068
- style: btnStyleFor(audioHumanOnly, !audioOn), disabled: !audioOn,
10069
- title: audioOn ? '' : t('audioNeedOn'),
10070
- }, t(audioHumanOnly ? 'audioHumanOnlyOff' : 'audioHumanOnlyOn'))
10423
+ R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
10424
+ audioTestButton('attention', 'audioSlotAttention')
10425
+ )
10426
+ ]),
10427
+ row('audio-fail', false, [
10428
+ R.createElement('span', { style: labelStyle },
10429
+ t('audioTurnFailRow') + t('sep') + stateOf(audioTestReady()),
10430
+ R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
10431
+ ),
10432
+ R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
10433
+ audioTestButton('turn-fail', 'audioSlotFail')
10434
+ )
10071
10435
  ]),
10072
10436
  row('audio-diag', true, [
10073
10437
  R.createElement('span', { style: labelStyle },
@@ -10078,6 +10442,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10078
10442
  ),
10079
10443
  R.createElement('button', { type: 'button', onClick: toggleAudioDiag, style: btnStyleFor(audioDiag) }, t(audioDiag ? 'audioDiagOff' : 'audioDiagOn'))
10080
10444
  ]),
10445
+ ]),
10081
10446
  ]),
10082
10447
  ])
10083
10448
  }
@@ -10105,6 +10470,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10105
10470
  if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
10106
10471
  document.removeEventListener('DOMContentLoaded', watermarkObserverLate)
10107
10472
  document.removeEventListener('DOMContentLoaded', contourSchemeObserverLate)
10473
+ // The boot loader's pre-body deferral too: a dead run must not be wired
10474
+ // back in to mount an unstyled plate.
10475
+ if (loaderDomReadyPending !== null) {
10476
+ document.removeEventListener('DOMContentLoaded', loaderDomReadyPending)
10477
+ loaderDomReadyPending = null
10478
+ }
10108
10479
  }
10109
10480
  if (watermarkRaf !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(watermarkRaf)
10110
10481
  if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') window.removeEventListener('resize', onWatermarkResize)