dsh-theme-endfield 1.2.3 → 1.2.5

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
@@ -190,15 +190,17 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
190
190
  floating element the user did not ask for. */
191
191
  balanceCapsule: '0',
192
192
  /* 渠道额度胶囊的右侧百分比读数选「已用」还是「剩余」——两个值都有真实语义,
193
- 不存在默认就错的答案,所以默认 used(与插件自身徽章一致的读法)。
194
- 仅影响 credits 模式的右侧百分比槽位;左侧主数字始终为剩余额度。 */
193
+ 不存在默认就错的答案,所以默认 remaining(与左侧主数字的剩余额度语义一致,
194
+ 也与 features.md 的字段表一致)。仅影响 credits 模式的右侧百分比槽位。 */
195
195
  creditDisplay: 'remaining',
196
196
  /* 余额胶囊的停靠位 —— center(顶部居中)| left(靠左)| right(靠右)。
197
197
  仅当胶囊开启时有意义:选择靠边停靠可让开侧边栏与顶部工具栏的交叠区。 */
198
198
  balanceCapsulePosition: 'center',
199
199
  /* 防遮挡的两个子开关 —— 侧边栏展开自动隐藏 / 指针靠近自动隐藏。
200
- default-ON:用户抱怨的就是遮挡,两个行为同时默认生效;单独关掉
201
- 其中一个的出口保留在这里(开关语义与其余 default-ON 一致:!== '0')。 */
200
+ 侧栏展开是主要遮挡源但属于「用户主动打开」的动作,默认关;指针靠近
201
+ 则在用户不知情时收起内容,默认开。用户抱怨的遮挡由 hover-hide 兜底,
202
+ 单独关掉其中一个的出口保留在这里(开关语义不一致是有意的:
203
+ sidebarHide 读 === '1',hoverHide 读 !== '0',见 11-prefs-fields.js)。 */
202
204
  balanceCapsuleSidebarHide: '0',
203
205
  balanceCapsuleHoverHide: '1',
204
206
  /* 音频通知 (host half: lib/audio.js). Two live slots — the prompt that
@@ -308,13 +310,16 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
308
310
  buggy build already wrote: 'contourAnim' -> 'contour-anim'. Derived from
309
311
  the table (not from the schema, which cannot know it) so the two can never
310
312
  drift, and only for fields that really do have a distinct legacy double:
311
- 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. */
312
317
  const PREFS_FIELD_TO_LEGACY_KEY = (() => {
313
318
  const m = {}
314
319
  for (const field of Object.keys(PREFS_KEY_TO_FIELD)) {
315
320
  const f = PREFS_KEY_TO_FIELD[field]
316
321
  const legacy = field.slice(PREFS_NS.length + 1)
317
- if (legacy !== f) m[f] = legacy
322
+ if (legacy !== '' && legacy !== f) m[f] = legacy
318
323
  }
319
324
  return m
320
325
  })()
@@ -373,7 +378,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
373
378
  const v = prefsGet(BALANCE_POSITION_KEY)
374
379
  return BALANCE_POSITION_OPTIONS.indexOf(v) >= 0 ? v : 'center'
375
380
  }
376
- /* 防遮挡子开关:default-ON(!== '0'),与主开关的读取惯例一致。 */
381
+ /* 防遮挡子开关。两者默认值不同,读取语义也随之不同:sidebarHide 默认关
382
+ (读 === '1'),hoverHide 默认开(读 !== '0')——见 10-prefs-store.js 的注释。 */
377
383
  const BALANCE_SIDEBAR_HIDE_KEY = 'balanceCapsuleSidebarHide'
378
384
  const BALANCE_HOVER_HIDE_KEY = 'balanceCapsuleHoverHide'
379
385
  const isBalanceSidebarHideOn = () => prefsGet(BALANCE_SIDEBAR_HIDE_KEY) === '1'
@@ -387,7 +393,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
387
393
  so session credentials and the host-side badge cache (TTL 120s, failure
388
394
  TTL 15s) come for free; the page only ever names the provider.
389
395
  The id table IS the plugin's contract: usage.badge answers for these
390
- twelve, and anything else — notably the built-in DeepSeek models — keeps
396
+ thirteen, and anything else — notably the built-in DeepSeek models — keeps
391
397
  the wallet display, so an absent plugin can never blank the capsule. */
392
398
  const JET_HUB_RPC_SCOPE = '/api'
393
399
  const JET_HUB_RPC_CHANNEL = 'jet-hub'
@@ -803,6 +809,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
803
809
  promise resolves, so a synchronous dirty mark would make every toggle
804
810
  emit a redundant second write. A late repair beats a duplicate write.
805
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
806
816
  const prefsWriteField = (field, value) => {
807
817
  const scope = prefsScope
808
818
  if (!scope || typeof scope.set !== 'function') return false
@@ -814,6 +824,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
814
824
  }
815
825
  if (result && typeof result.then === 'function') {
816
826
  result.then((ok) => {
827
+ if (prefsDisposed) return
817
828
  if (ok === false) {
818
829
  dbg('set REFUSED by the host', field, value)
819
830
  prefsDirty.add(field)
@@ -822,6 +833,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
822
833
  prefsDirty.delete(field)
823
834
  }
824
835
  }, (e) => {
836
+ if (prefsDisposed) return
825
837
  dbg('set REJECTED', field, value, String(e && e.message || e))
826
838
  prefsDirty.add(field)
827
839
  })
@@ -889,6 +901,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
889
901
  prefsRetryCount = 0
890
902
  }
891
903
  const prefsScheduleRetry = () => {
904
+ if (prefsDisposed) return
892
905
  // Nothing held any more: no reason to keep ticking.
893
906
  if (prefsDirty.size === 0) { prefsStopRetry(); return }
894
907
  // A durably-served scope needs no timer — its subscription replays fast.
@@ -1269,6 +1282,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
1269
1282
  // provider-owned, so leaving our listener behind would leak it into the
1270
1283
  // next run of this plugin in the same page.
1271
1284
  ctx.effect(() => () => {
1285
+ prefsDisposed = true
1272
1286
  prefsListeners.length = 0
1273
1287
  prefsReleaseSubscription()
1274
1288
  prefsScope = null
@@ -1846,6 +1860,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
1846
1860
  let contourTrailListening = false
1847
1861
  let contourTrailPainted = false
1848
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
1849
1868
  /* Last applied animation state. Declared HERE, above every function that touches
1850
1869
  it, because contourTeardown() assigns it and is itself reachable from
1851
1870
  unmount() — a `let` declared further down would still be in its temporal dead
@@ -1861,9 +1880,9 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
1861
1880
  of one sheet roughly constant in frame AREA. The cap is set so every ordinary
1862
1881
  window keeps the shipped 6px grid byte-for-byte (1440x900 is 36k cells,
1863
1882
  1920x1080 is 58k); only genuinely large canvases coarsen, and the spline still
1864
- rounds the coarser polyline at a 1px stroke. It also keeps a large HiDPI
1865
- canvas inside the worker's own backing-store limit, so the sheet stays on the
1866
- 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.) */
1867
1886
  const CONTOUR_MAX_CELLS = 60000
1868
1887
  /* Bump radius floor, in grid samples. The radius was `(0.05..0.14) * min(w, h)`,
1869
1888
  which ties the terrain's feature size to the frame's SHORTER side: in a short
@@ -1901,6 +1920,25 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
1901
1920
  while (step < 40 && cells(step) > CONTOUR_MAX_CELLS) step += 1
1902
1921
  return step
1903
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
+ }
1904
1942
  /* VIEWPORT-ADAPTIVE DENSITY. The shipped look was tuned on a desktop frame:
1905
1943
  20 levels over the +/-1.45 span and 22 gaussian bumps. Those are per-AREA
1906
1944
  constants, but the sheet is judged per-VIEWPORT -- a phone-width frame
@@ -2967,7 +3005,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
2967
3005
  const contourRefresh = (geometry) => {
2968
3006
  if (contourWorker !== null) {
2969
3007
  if (!contourGeom || document.hidden) return
2970
- 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)))
2971
3010
  const pending = contourWorker.pending
2972
3011
  contourWorker.pending = {w:contourGeom.w,h:contourGeom.h,dpr:ratio,phase:contourPhase,
2973
3012
  color:contourStroke(),geometry:geometry || !!(pending && pending.geometry)}
@@ -3059,6 +3098,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3059
3098
  contourSwitchSig = ''
3060
3099
  contourApplySwitches()
3061
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
+ }
3062
3109
  const contourSizeTo = (host) => {
3063
3110
  const r = host.getBoundingClientRect()
3064
3111
  const w = Math.max(1, Math.round(r.width))
@@ -3164,6 +3211,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3164
3211
  contourMotionQuery.removeEventListener('change', onContourEnvironmentChange)
3165
3212
  }
3166
3213
  contourMotionQuery = null
3214
+ if (contourResolutionQuery !== null && typeof contourResolutionQuery.removeEventListener === 'function') {
3215
+ contourResolutionQuery.removeEventListener('change', onContourResolutionChange)
3216
+ }
3217
+ contourResolutionQuery = null
3167
3218
  contourDisposeWorker()
3168
3219
  if (contourRo !== null) {
3169
3220
  contourRo.disconnect()
@@ -3183,6 +3234,15 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3183
3234
  landscape would visibly snap backwards before resuming. A fresh mount at
3184
3235
  phase 0 has no such transient — which is exactly what this restores. */
3185
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
3186
3246
  // Nothing is drawn any more, so the next mount must re-apply the switch rather
3187
3247
  // than trust a signature describing a canvas that no longer exists.
3188
3248
  contourSwitchSig = ''
@@ -3262,6 +3322,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3262
3322
  if (typeof contourMotionQuery.addEventListener === 'function') {
3263
3323
  contourMotionQuery.addEventListener('change', onContourEnvironmentChange)
3264
3324
  }
3325
+ contourResolutionQuery = window.matchMedia('(resolution)')
3326
+ if (typeof contourResolutionQuery.addEventListener === 'function') {
3327
+ contourResolutionQuery.addEventListener('change', onContourResolutionChange)
3328
+ }
3265
3329
  }
3266
3330
  contourStartWorker(line)
3267
3331
  wrap.setAttribute('data-endfield-renderer', contourWorker ? 'starting' : 'main-canvas2d')
@@ -3321,7 +3385,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3321
3385
  // Let the page observer declared above re-attach the layer as the app renders.
3322
3386
  contourSyncHook = syncContour
3323
3387
  /* BEGIN GENERATED CONTOUR WORKER */
3324
- 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"
3325
3389
  /* END GENERATED CONTOUR WORKER */
3326
3390
  const onContourVisibility = () => {
3327
3391
  if (document.hidden && contourWorker) contourWorker.pending = null
@@ -3732,7 +3796,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3732
3796
  scrollAnimRun()
3733
3797
  }
3734
3798
 
3735
- /* 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. */
3736
3804
  const scrollAnimOnIntent = (entry) => { if (entry.offset !== 0) scrollAnimClear(entry) }
3737
3805
 
3738
3806
  const scrollAnimDetach = (entry) => {
@@ -3800,6 +3868,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3800
3868
  cheaper than the work the rest of this hook already does per mutation. */
3801
3869
  const syncScrollAnim = () => {
3802
3870
  if (typeof document === 'undefined') return
3871
+ ensureScrollAnimEnvWatch()
3803
3872
  const on = scrollAnimActive()
3804
3873
  if (!on && scrollAnimPorts.size === 0) { scrollAnimStop(); return }
3805
3874
  const live = document.querySelectorAll('[data-conversation-scroll]')
@@ -3842,18 +3911,43 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3842
3911
  scrollAnimDetach(entry)
3843
3912
  scrollAnimPorts.delete(port)
3844
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
+ }
3845
3931
  }
3846
3932
  const onScrollAnimEnvironmentChange = () => {
3847
3933
  if (!scrollAnimActive()) scrollAnimSnap()
3848
3934
  else syncScrollAnim()
3849
3935
  }
3850
- if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
3851
- document.addEventListener('visibilitychange', onScrollAnimEnvironmentChange)
3852
- }
3853
- if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
3854
- scrollAnimMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
3855
- if (typeof scrollAnimMotionQuery.addEventListener === 'function') {
3856
- 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
+ }
3857
3951
  }
3858
3952
  }
3859
3953
  // Let the page observer declared above re-attach as the app renders.
@@ -3889,6 +3983,10 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3889
3983
  // to outlive it so a stalled flourish can still never leave the app covered.
3890
3984
  let loaderKill = null
3891
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
3892
3990
  const clearLoaderTimers = () => {
3893
3991
  if (loaderRaf !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(loaderRaf)
3894
3992
  loaderRaf = null
@@ -3957,7 +4055,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
3957
4055
  // animation (loaderDone stays false, so the retry is the real first run).
3958
4056
  if (!document.body) {
3959
4057
  if (typeof document.addEventListener === 'function') {
3960
- 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 })
3961
4063
  }
3962
4064
  return
3963
4065
  }
@@ -4328,10 +4430,17 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4328
4430
  let thunderRebindTimer = null
4329
4431
  /* Retry budget for thunderRebind, bounded like every other retry in this
4330
4432
  file (rebindPrefs 40, settle watch 20): a permanently absent sessions
4331
- service must not leave a 120 ms poll running for the life of the page.
4332
- A settings change re-runs syncThunder -> thunderRebind, which restarts
4333
- 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. */
4334
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
4335
4444
  let thunderWatchedId = null
4336
4445
  // null = nothing readable observed yet, so the next value is a baseline.
4337
4446
  let thunderLastRunning = null
@@ -4404,17 +4513,21 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4404
4513
  if (thunderRebindTimer !== null && typeof clearTimeout === 'function') clearTimeout(thunderRebindTimer)
4405
4514
  thunderRebindTimer = null
4406
4515
  /* Service not there yet: keep retrying rather than giving up for good, since
4407
- the only reason to be here is that the feature is switched on — but only
4408
- 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). */
4409
4518
  if (sessions === undefined) {
4410
- if (thunderRebindAttempts >= 100) {
4519
+ if (thunderRebindAttempts >= THUNDER_REBIND_SLOW_END) {
4411
4520
  dbg('thunder rebind gave up: no sessions service after', thunderRebindAttempts, 'tries')
4412
4521
  return
4413
4522
  }
4414
4523
  thunderRebindAttempts += 1
4415
- 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
+ }
4416
4528
  return
4417
4529
  }
4530
+ thunderRebindAttempts = 0
4418
4531
  // The list subscription may have been skipped earlier (no service then), so
4419
4532
  // attach it as soon as one exists.
4420
4533
  thunderSubscribeList(sessions)
@@ -4551,8 +4664,23 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4551
4664
  let audioAttentionObserver = null
4552
4665
  let audioAttentionFrame = null
4553
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
+ }
4554
4681
  const audioAttentionTick = () => {
4555
4682
  try {
4683
+ audioAttentionEnsureObserver()
4556
4684
  const kind = detectPendingInteraction()
4557
4685
  if (kind === null) {
4558
4686
  audioAttentionKind = null
@@ -4581,14 +4709,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4581
4709
  if (wanted && audioAttentionTimer === null && typeof setInterval === 'function') {
4582
4710
  audioAttentionKind = null
4583
4711
  audioAttentionTimer = setInterval(audioAttentionTick, AUDIO_ATTENTION_POLL_MS)
4584
- if (typeof MutationObserver === 'function' && typeof document !== 'undefined' && document.body) {
4585
- audioAttentionObserver = new MutationObserver(audioAttentionSchedule)
4586
- try {
4587
- audioAttentionObserver.observe(document.body, { childList: true, subtree: true })
4588
- } catch (e) {
4589
- audioAttentionObserver = null
4590
- }
4591
- }
4712
+ audioAttentionEnsureObserver()
4592
4713
  audioAttentionTick()
4593
4714
  } else if (!wanted && audioAttentionTimer !== null) {
4594
4715
  stopAudioAttentionWatch()
@@ -4648,10 +4769,9 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4648
4769
  const BALANCE_KEY = 'balanceCapsule'
4649
4770
  const isBalanceCapsuleOn = () => prefsGet(BALANCE_KEY) === '1'
4650
4771
  /* What the credits mode's RIGHT-hand percentage slot reads: 已用xx% (the
4651
- plugin's own badge reading) or 剩余xx%. Anything other than 'remaining'
4652
- reads as 'used' — the preference is a two-literal choice, stored as
4653
- exactly one of them, and the tolerant default mirrors how
4654
- 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. */
4655
4775
  const CREDIT_DISPLAY_KEY = 'creditDisplay'
4656
4776
  const readCreditDisplay = () => (prefsGet(CREDIT_DISPLAY_KEY) === 'used' ? 'used' : 'remaining')
4657
4777
  let balanceEl = null
@@ -4858,6 +4978,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
4858
4978
  creditProviderResolved = false
4859
4979
  creditsRebindAttempts = 0
4860
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]
4861
4989
  if (balanceSidebarRo !== null) {
4862
4990
  try { balanceSidebarRo.disconnect() } catch (e) { /* already gone */ }
4863
4991
  balanceSidebarRo = null
@@ -5182,31 +5310,34 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
5182
5310
  const next = (typeof provider === 'string' && JET_HUB_PROVIDER_LABELS[provider] !== undefined)
5183
5311
  ? provider
5184
5312
  : null
5185
- if (next !== creditProvider || (next !== null && !creditProviderResolved)) {
5313
+ const previous = creditProvider
5314
+ if (next !== previous || (next !== null && !creditProviderResolved)) {
5186
5315
  creditProvider = next
5187
5316
  creditProviderResolved = next !== null
5188
5317
  creditPaintIdle()
5189
5318
  }
5190
- // A switch (force, from the store subscription) bypasses the rate
5191
- // floor so the read follows the channel immediately; the 60s poll
5192
- // passes force=false and is throttled by creditsApply.
5193
- 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)
5194
5325
  }
5195
5326
 
5196
5327
  /* Store events land here; a channel flip re-reads the directory and
5197
5328
  repaints. Debounced so a burst of publishes collapses into one read.
5198
- The debounce itself is also the switch detector: if the provider the
5199
- delayed read resolves differs from the one painted, the fetch is
5200
- forced — ordinary publishes (balance churn, model-list updates) keep
5201
- 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. */
5202
5333
  const creditsRebind = () => {
5203
5334
  if (balanceEl === null) return
5204
5335
  if (creditsRebindTimer !== null && typeof clearTimeout === 'function') clearTimeout(creditsRebindTimer)
5205
5336
  creditsRebindTimer = null
5206
- if (typeof setTimeout !== 'function') { creditsRefresh(true); return }
5337
+ if (typeof setTimeout !== 'function') { creditsRefresh(false); return }
5207
5338
  creditsRebindTimer = setTimeout(() => {
5208
5339
  creditsRebindTimer = null
5209
- creditsRefresh(true)
5340
+ creditsRefresh(false)
5210
5341
  }, 150)
5211
5342
  }
5212
5343
 
@@ -6514,6 +6645,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
6514
6645
  [class$='_sidebarCol'] [class*='_projectRow']:hover,
6515
6646
  [class$='_sidebarCol'] [class*='_sessionRow']:hover,
6516
6647
  [class$='_sidebarCol'] [class*='_sessionRow'][class*='_selected'],
6648
+ [class$='_sidebarCol'] [class*='_sessionRow'][class*='_menuOpen'],
6517
6649
  [class$='_sidebarCol'] [class*='_searchResultRow']:hover,
6518
6650
  [class$='_sidebarCol'] [class*='_searchResultRow'][class*='_selected'] {
6519
6651
  background: rgba(var(--edge-accent-rgb), 0.22) !important;
@@ -6538,10 +6670,50 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
6538
6670
  body[data-ds-dark-theme] [class$='_sidebarCol'] [class*='_projectRow']:hover,
6539
6671
  body[data-ds-dark-theme] [class$='_sidebarCol'] [class*='_sessionRow']:hover,
6540
6672
  body[data-ds-dark-theme] [class$='_sidebarCol'] [class*='_sessionRow'][class*='_selected'],
6673
+ body[data-ds-dark-theme] [class$='_sidebarCol'] [class*='_sessionRow'][class*='_menuOpen'],
6541
6674
  body[data-ds-dark-theme] [class$='_sidebarCol'] [class*='_searchResultRow']:hover,
6542
6675
  body[data-ds-dark-theme] [class$='_sidebarCol'] [class*='_searchResultRow'][class*='_selected'] {
6543
6676
  background: var(--edge-accent) !important;
6544
6677
  }
6678
+ /* ---------- 会话行高亮:沿用 app 原生描边结构,只换颜色 ----------
6679
+ Reversed from the shipped bundle (dsh-client-ui-workspace
6680
+ Rows.module.css, read back from the installed 0.2.2-rc.1 app.asar):
6681
+ the app draws the hover/selected wash with NO geometry of its own —
6682
+ every row carries
6683
+ border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
6684
+ background-clip: padding-box;
6685
+ (3px) and the selected state is literally one declaration,
6686
+ .wnFoaW_sessionRow.wnFoaW_selected { background-color: ... }.
6687
+ The 3px transparent frame is the app's own design: with its faint
6688
+ native wash the inset reads as intentional, but the theme's strong
6689
+ accent lets the unpainted frame show the column background through on
6690
+ both sides (「两边颜色不是填满的」). So the fix stays INSIDE the app's
6691
+ structure and only changes colors: the frame is painted with the same
6692
+ wash as the background (border-inline-color), which fills the row's
6693
+ border box edge to edge. Nothing moves — rail padding, row padding,
6694
+ the scrollbar gutter, the title's 36px line all stay exactly where the
6695
+ app put them — and because the whole wash now lives inside the row's
6696
+ border box, it can never cross the scroll container's clip region:
6697
+ 圆角模式 keeps the row's native var(--dsw-radius-md) rounding on both
6698
+ ends, and the square-mode blanket pass squares it, both without ever
6699
+ being cut (the previous full-bleed attempt that re-architected the
6700
+ list's margins/clip was reverted for exactly that fragility).
6701
+ Same three row suffixes the theme already paints; the app's menuOpen
6702
+ state is included so its longhand wash fills the frame too. */
6703
+ [class$='_sidebarCol'] :is([class*='_sessionRow'], [class*='_projectRow'], [class*='_searchResultRow']):is(
6704
+ :hover,
6705
+ [class*='_selected'],
6706
+ [class*='_menuOpen']
6707
+ ) {
6708
+ border-inline-color: rgba(var(--edge-accent-rgb), 0.22) !important;
6709
+ }
6710
+ body[data-ds-dark-theme] [class$='_sidebarCol'] :is([class*='_sessionRow'], [class*='_projectRow'], [class*='_searchResultRow']):is(
6711
+ :hover,
6712
+ [class*='_selected'],
6713
+ [class*='_menuOpen']
6714
+ ) {
6715
+ border-inline-color: var(--edge-accent) !important;
6716
+ }
6545
6717
  /* WHY '_badge' not bare 'badge': app CSS-module badges are '<hash>_badge'
6546
6718
  (zf92ZW_badge footer, dlU_AG_badge …), but third-party plugins name
6547
6719
  their own widgets without the underscore word boundary —
@@ -6611,6 +6783,68 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
6611
6783
  [data-cordis-decline]:hover:not(:disabled) {
6612
6784
  background: #d6281d !important;
6613
6785
  }
6786
+ /* ---------- 审批白条的前提:祖先不能是 containing block(issue #41) ----------
6787
+ The band below fills the VIEWPORT only while no ancestor between the
6788
+ panel and the viewport is a containing block for fixed descendants.
6789
+ DSH 0.2.2-rc.1 declared will-change: transform on the composer seat,
6790
+ which builds that containing block exactly like a real transform does
6791
+ (CSS Will Change: a property that would make the element generate a
6792
+ containing block MUST do so when named in will-change). The approval
6793
+ panel replaces the composer and therefore lives INSIDE that seat, so
6794
+ position: fixed; inset: 0 started resolving against the seat's
6795
+ padding box instead of the viewport. Fixed also takes the panel out of
6796
+ flow, which left the seat with no in-flow child: its height collapsed
6797
+ to 0, the layer resolved to the seat's width by ZERO height, and the
6798
+ card centred on that line straddled the window's bottom edge (fixture:
6799
+ layer 720x0, card running 95px past the viewport). Only the top strip
6800
+ reading 「● 等待审批」 survived — the reported screenshot, and the
6801
+ 152-second approval that followed.
6802
+ The fix is one declaration on the ancestor, not on the band: CSS
6803
+ cannot escape a containing block, so a transformed/will-changed
6804
+ ancestor is not something the band can style its way out of. Dropping
6805
+ the hint only while a panel is mounted keeps the app's compositing
6806
+ hint for every other frame. Verified live in Chrome: computed
6807
+ will-change flips to auto and the layer measures the full viewport;
6808
+ deleting this declaration at runtime collapses the same layer back to
6809
+ zero height, and re-inserting it restores the viewport rect
6810
+ (test/approval-band-transform.test.js covers both directions).
6811
+ Both anchors are listed because the [class$=] hook dies the moment
6812
+ upstream appends a second class to the seat, and [data-composer-seat]
6813
+ is the e2e hook the app itself declares.
6814
+
6815
+ DEFENCE IN DEPTH (the 自动授权审查 field report). will-change on the
6816
+ seat is the only containing-block producer measured on the seat's
6817
+ ancestor chain in 0.2.2-rc.1 — but the band is position:fixed, so ANY
6818
+ producer on ANY ancestor between it and the viewport re-creates the
6819
+ whole collapse, and the apps that hit it in the field were one release
6820
+ behind (the report shipped before this rule existed). While a panel is
6821
+ mounted the app is modal-blocked anyway, so the theme releases every
6822
+ containing-block-producing property on the seat AND the structural
6823
+ layers the seat actually nests under, all verified in the extracted
6824
+ bundles and all semantic anchors: the conversation scrollport
6825
+ ([data-conversation-scroll]), the conversation root
6826
+ ([data-conversation-content]), the frame and center column (the same
6827
+ suffix hooks the contour sheet already relies on). transform:none is
6828
+ the only declaration that could plausibly change a visible pixel
6829
+ elsewhere, and everything it could un-transform is behind the band's
6830
+ own full-viewport mask for exactly as long as the panel exists. */
6831
+ body:has([data-approval-key]) :is(
6832
+ [data-composer-seat],
6833
+ [class$='_composerSeat'],
6834
+ [data-conversation-scroll],
6835
+ [data-conversation-content],
6836
+ [class$='_centerCol'],
6837
+ [class*='_frame']
6838
+ ) {
6839
+ will-change: auto !important;
6840
+ transform: none !important;
6841
+ filter: none !important;
6842
+ backdrop-filter: none !important;
6843
+ perspective: none !important;
6844
+ container-type: normal !important;
6845
+ content-visibility: visible !important;
6846
+ contain: none !important;
6847
+ }
6614
6848
  /* ---------- 审批弹窗:全屏白条 + 上下遮罩(Endfield 寻访确认框) ----------
6615
6849
  The shipped panel is a rounded warn-bordered card parked inside the
6616
6850
  composer column (max-width: --dsh-chat-content-width). The reference
@@ -6628,9 +6862,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
6628
6862
  CSS-module SUFFIXES, each rule scoped under the root so a generic
6629
6863
  '_card' / '_body' elsewhere in the app is untouched.
6630
6864
  position:fixed escapes the composer column's width cap and the
6631
- frame's overflow clip; no ancestor between the seat and the viewport
6632
- carries a transform/filter, and if a future one did, the band simply
6633
- stays in flow — degraded position, never a broken dialog. */
6865
+ frame's overflow clip, and it lands on the viewport only while the
6866
+ seat does not build a containing block — the rule above the block is
6867
+ what keeps that true (issue #41), not the absence of ancestors with a
6868
+ transform. An earlier revision of this note claimed a transformed
6869
+ ancestor would merely leave the band in flow, "degraded position,
6870
+ never a broken dialog"; that was wrong, and #41 is the proof: fixed
6871
+ does not fall back to static, it keeps laying out against a seat that
6872
+ has just been emptied, so the band collapses instead of degrading. */
6634
6873
  [data-approval-key] {
6635
6874
  position: fixed !important;
6636
6875
  inset: 0 !important;
@@ -6644,8 +6883,14 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
6644
6883
  }
6645
6884
  /* The band: square, full-bleed, paper. The zero-radius pass in round
6646
6885
  mode would restore the app's --dsw-radius-xl here, and a rounded band
6647
- contradicts the reference — pin it flat with priority. */
6648
- [data-approval-key] > [class$='_card'] {
6886
+ contradicts the reference — pin it flat with priority. '_card' as a
6887
+ SUBSTRING, not a suffix: subject-bearing approvals (an agent-team
6888
+ escalation renders its icon + name) ship the card as
6889
+ '<hash>_card <hash>_withSubject', and a $= match dies the moment that
6890
+ second class is appended — the band would keep its mask but lose the
6891
+ paper. The root's ONLY child is the card, so the looser hook cannot
6892
+ reach anything else. */
6893
+ [data-approval-key] > [class*='_card'] {
6649
6894
  width: 100%;
6650
6895
  max-width: none !important;
6651
6896
  box-sizing: border-box;
@@ -7308,7 +7553,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
7308
7553
 
7309
7554
  Note the + is the ONLY composer element whose class contains '_add'; the
7310
7555
  scope cannot reach the settings wrappers, the diff lines or the address bar.
7311
- Verified by test/hover-check.js against real hovered pixels in both
7556
+ Verified by test/tools/hover-check.js against real hovered pixels in both
7312
7557
  palettes and both schemes. */
7313
7558
  body[data-ds-dark-theme] :is([data-composer-seat], [class$='_composerSeat'], [class$='_composerHero']) [class*='_add'] {
7314
7559
  color: var(--edge-accent) !important;
@@ -7866,6 +8111,27 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
7866
8111
  body[data-ds-dark-theme] .endfield-settings-group-title {
7867
8112
  color: var(--edge-status-dark);
7868
8113
  }
8114
+ /* ---------- Settings page: 二级折叠子节 ----------
8115
+ 折叠头是全宽 button:文字颜色继承面板自身(主题关闭时仍是 app 的
8116
+ ink,可读性由 app 令牌保证),只有悬停/聚焦态借用组头同款的
8117
+ palette 配色对 —— 静止的目录保持中性,交互时的反馈才着色。
8118
+ 类名避开主题 hover 反转规则的语义子串(item/row/card/tab…),
8119
+ 那条 !important 规则碰不到这个控件。
8120
+ body 以 [hidden] 收起而非条件渲染:行不离开 DOM,display:none
8121
+ 是显式兜底,防止宿主或 UA 样式表把 hidden 覆盖回去。 */
8122
+ .endfield-settings-sub-toggle:hover {
8123
+ color: var(--edge-status-light);
8124
+ }
8125
+ body[data-ds-dark-theme] .endfield-settings-sub-toggle:hover {
8126
+ color: var(--edge-status-dark);
8127
+ }
8128
+ .endfield-settings-sub-toggle:focus-visible {
8129
+ outline: 1px solid currentColor;
8130
+ outline-offset: 2px;
8131
+ }
8132
+ .endfield-settings-sub-body[hidden] {
8133
+ display: none;
8134
+ }
7869
8135
  /* ================= 雷霆大字 (娱乐模式) =================
7870
8136
  The task-boundary announcement. Fixed, centred, above the shell overlay
7871
8137
  layer and the app's own dialogs but BELOW the boot plate (2147483000), so
@@ -7884,7 +8150,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
7884
8150
  2.11:1 on bg-layer-1, i.e. the same "on cream, #fff500 is not a colour
7885
8151
  choice, it is an erasure" failure this theme already documents for the
7886
8152
  watermark and the turn-status label — except here it was pure white. Caught
7887
- by rendering the plate and measuring the pixels (test/thunder-shot.js), not
8153
+ by rendering the plate and measuring the pixels (test/tools/thunder-shot.js), not
7888
8154
  by reading the CSS.
7889
8155
 
7890
8156
  Measured white-vs-scrimmed-surface, ink #101110 over each light surface:
@@ -8453,6 +8719,39 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8453
8719
  [data-endfield-balance][data-endfield-balance-shade][data-endfield-balance-position] {
8454
8720
  transform: translateY(-6px);
8455
8721
  }
8722
+
8723
+ /* ---------- Ink fills need an inverted foreground in light mode ----------
8724
+ --dsw-alias-state-business-primary is ink in the light palette (see the
8725
+ neutralize block above), and consumers paint it as a FILL while their
8726
+ glyph stays on --dsw-alias-label-primary — which is ink too. Fill and
8727
+ glyph then resolve to #101110 and the control reads as a solid black
8728
+ rectangle: the host's active settings tab lost its own label, and
8729
+ third-party switches lost their knob.
8730
+
8731
+ Fixing it here rather than by repainting the token keeps the light
8732
+ palette's ink/paper language: the fill stays ink, only the glyph moves
8733
+ to the host's pure white. Not --dsw-alias-label-primary-inverted: this
8734
+ sheet already repaints that role to ink in both palettes (see the warm
8735
+ label block), so the knob would stay invisible.
8736
+
8737
+ Both selectors are structural rather than hash-named: the tab hook is
8738
+ the ARIA contract, and the switch hook matches the semantic suffix
8739
+ every switch carries — dsh-better-display's pinned class and the
8740
+ host's own CSS-module switches alike end in _switch, and the
8741
+ [data-on] guard keeps the rule to the checked state, so an unchecked
8742
+ knob keeps its ink fill. The hash form this replaces (the shape PR #37
8743
+ first shipped) is exactly what selector-guard's part 1 fails on: the
8744
+ suffix survives any rehash, the pinned class does not.
8745
+
8746
+ The tab hook is scoped to dialogs on purpose: the app's top-level nav
8747
+ is role=tab too, but it sits on paper with an ink underline, so its
8748
+ selected label must stay ink. */
8749
+ body:not([data-ds-dark-theme]) [role='dialog'] [role='tab'][aria-selected='true'] {
8750
+ color: var(--dsw-static-neutral-bluish-00) !important;
8751
+ }
8752
+ body:not([data-ds-dark-theme]) [class$='_switch'][data-on]::after {
8753
+ background: var(--dsw-static-neutral-bluish-00) !important;
8754
+ }
8456
8755
  `)
8457
8756
  syncRadiusMode()
8458
8757
  syncGlass()
@@ -8614,9 +8913,18 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8614
8913
  groupBg: '背景',
8615
8914
  groupAnim: '动画',
8616
8915
  groupFun: '娱乐',
8617
- /* 折叠分组:组头是「编号 名称」加一个随展开态翻转的箭头;
8618
- 展开后的「收起」提示放在箭头的 title 上而非行内,收起的
8619
- 五个组头并排时行内文案会重复五次。 */
8916
+ /* 二级折叠子节的标题(组 > 子节 > 行)。子节头是「名称 + 拉丁大写 +
8917
+ 状态摘要」加一个随展开态翻转的箭头;与组头一样,拉丁行在英文界面下
8918
+ 与中文名重复时省略。状态摘要复用各行已有的 on/off 文案,不新增词汇。 */
8919
+ subContour: '等高线',
8920
+ subWatermark: '水印',
8921
+ subScrollAnim: '输出滚动动画',
8922
+ subLoader: '启动加载动画',
8923
+ subThunder: '雷霆大字',
8924
+ subBalance: '余额胶囊',
8925
+ subAudioSlots: '通知槽位',
8926
+ subAudioSound: '音量与音源',
8927
+ subAudioMeta: '预留与诊断',
8620
8928
  themeRow: '终末地主题',
8621
8929
  themeOn: '开启主题',
8622
8930
  themeOff: '关闭主题',
@@ -8641,6 +8949,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8641
8949
  contourTrailOn: '开启轨迹',
8642
8950
  contourTrailOff: '关闭轨迹',
8643
8951
  contourTrailHint: '鼠标移动时局部扰动等高线并逐渐恢复;仅动态模式生效,静态或减少动态效果时暂停',
8952
+ contourTrailHintWorker: 'Worker/WebGL 渲染暂不支持轨迹,切回 Canvas 渲染后生效',
8644
8953
  contourAnimRow: '动态等高线',
8645
8954
  contourAnimOn: '开启动态',
8646
8955
  contourAnimOff: '切为静态',
@@ -8804,6 +9113,15 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8804
9113
  groupBg: 'BACKGROUND',
8805
9114
  groupAnim: 'ANIMATION',
8806
9115
  groupFun: 'ENTERTAINMENT',
9116
+ subContour: 'CONTOUR',
9117
+ subWatermark: 'WORDMARK',
9118
+ subScrollAnim: 'SCROLL',
9119
+ subLoader: 'BOOT',
9120
+ subThunder: 'THUNDER',
9121
+ subBalance: 'BALANCE',
9122
+ subAudioSlots: 'SLOTS',
9123
+ subAudioSound: 'SOUND',
9124
+ subAudioMeta: 'RESERVED',
8807
9125
  themeRow: 'Endfield theme',
8808
9126
  themeOn: 'Turn on',
8809
9127
  themeOff: 'Turn off',
@@ -8828,6 +9146,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
8828
9146
  contourTrailOn: 'Enable trail',
8829
9147
  contourTrailOff: 'Disable trail',
8830
9148
  contourTrailHint: 'Locally deforms contours near the mouse, then fades; pauses in static or reduced-motion mode',
9149
+ contourTrailHintWorker: 'Not drawn by the Worker/WebGL renderer; switch back to Canvas to use it',
8831
9150
  contourAnimRow: 'Animated contours',
8832
9151
  contourAnimOn: 'Animate',
8833
9152
  contourAnimOff: 'Make static',
@@ -9059,6 +9378,15 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9059
9378
  const [audioDiag, setAudioDiag] = R.useState(isAudioDiagOn())
9060
9379
  const [hostState, setHostState] = R.useState(null)
9061
9380
  const [previewNote, setPreviewNote] = R.useState('')
9381
+ /* 二级折叠子节的展开表。一个对象存全部子节(id -> bool),而不是每个
9382
+ 子节一个 useState:这些子节由同一个 render 函数内联构造,没有独立
9383
+ 的组件类型,逐节建 hook 既无法命名也无法稳定计数。缺省(未登记)
9384
+ 即收起,所以每次打开设置页都从最紧凑的目录开始;这是纯 UI 状态,
9385
+ 不进持久化偏好(durable section 只收功能开关)。测试桩的 setter
9386
+ 是 no-op,同样依赖「body 始终渲染、仅以 hidden 收起」来在收起态
9387
+ 下保持整棵行树可遍历。 */
9388
+ const [openSubs, setOpenSubs] = R.useState({})
9389
+ const toggleSub = (id) => setOpenSubs((prev) => ({ ...prev, [id]: !prev[id] }))
9062
9390
  const refreshHostState = () => {
9063
9391
  if (typeof fetch !== 'function') return
9064
9392
  fetch(AUDIO_STATE_URL, { headers: { accept: 'application/json' } })
@@ -9225,13 +9553,23 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9225
9553
  const next = !isEnabled()
9226
9554
  prefsSet(ENABLED_KEY, next ? '1' : '0')
9227
9555
  setEnabled(next)
9228
- if (next) { mount(); syncWatermarkVisibility(); syncContour() }
9229
- else { unmount(); syncWatermarkVisibility() }
9230
- /* The announcement watcher is gated on the master switch too, so it has
9231
- to be reconciled here. unmount() already stops it, but turning the
9232
- theme back ON must restart it — otherwise the feature would stay dead
9233
- until the next page load. */
9234
- syncThunder()
9556
+ if (next) {
9557
+ mount()
9558
+ syncWatermarkVisibility()
9559
+ syncContour()
9560
+ /* The same list the initial mount block and reconcileFromPrefs run:
9561
+ on a scope that is NOT durably served no authoritative echo ever
9562
+ comes back to run reconcileFromPrefs, so every layer gated on its
9563
+ own switch has to be reconciled here or it stays dead until the
9564
+ next page load — the trap the thunder sync used to document. */
9565
+ syncScrollAnim()
9566
+ syncThunder()
9567
+ syncAudioAttentionWatch()
9568
+ syncBalanceCapsule()
9569
+ } else {
9570
+ unmount()
9571
+ syncWatermarkVisibility()
9572
+ }
9235
9573
  }
9236
9574
  const toggleContour = () => {
9237
9575
  const next = !isContourOn()
@@ -9454,7 +9792,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9454
9792
  title: audioOn ? '' : t('audioNeedOn'),
9455
9793
  }, t('audioTest') + ' · ' + t(labelKey))
9456
9794
  const toggleAudio = () => {
9457
- const next = !audioOn
9795
+ const next = !isAudioOn()
9458
9796
  prefsSet(AUDIO_ENABLED_KEY, next ? '1' : '0')
9459
9797
  setAudioOn(next)
9460
9798
  // The attention watcher is gated on this switch, so it has to be
@@ -9463,13 +9801,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9463
9801
  if (next) playPreview('turn-done')
9464
9802
  }
9465
9803
  const toggleAudioStart = () => {
9466
- const next = !audioStart
9804
+ const next = !isAudioStartOn()
9467
9805
  prefsSet(AUDIO_TURN_START_KEY, next ? '1' : '0')
9468
9806
  setAudioStart(next)
9469
9807
  if (next) playPreview('turn-start')
9470
9808
  }
9471
9809
  const toggleAudioBoot = () => {
9472
- const next = !audioBoot
9810
+ const next = !isAudioBootOn()
9473
9811
  prefsSet(AUDIO_BOOT_KEY, next ? '1' : '0')
9474
9812
  setAudioBoot(next)
9475
9813
  // Preview the boot slot itself: the real one fires from the loader,
@@ -9477,7 +9815,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9477
9815
  if (next) playPreview('boot')
9478
9816
  }
9479
9817
  const toggleAudioDone = () => {
9480
- const next = !audioDone
9818
+ const next = !isAudioDoneOn()
9481
9819
  prefsSet(AUDIO_TURN_DONE_KEY, next ? '1' : '0')
9482
9820
  setAudioDone(next)
9483
9821
  if (next) playPreview('turn-done')
@@ -9488,12 +9826,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9488
9826
  setAudioVolume(clamped)
9489
9827
  }
9490
9828
  const toggleAudioHumanOnly = () => {
9491
- const next = !audioHumanOnly
9829
+ const next = !isAudioHumanOnly()
9492
9830
  prefsSet(AUDIO_HUMAN_ONLY_KEY, next ? '1' : '0')
9493
9831
  setAudioHumanOnly(next)
9494
9832
  }
9495
9833
  const toggleAudioDiag = () => {
9496
- const next = !audioDiag
9834
+ const next = !isAudioDiagOn()
9497
9835
  prefsSet(AUDIO_DIAG_KEY, next ? '1' : '0')
9498
9836
  setAudioDiag(next)
9499
9837
  }
@@ -9541,10 +9879,11 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9541
9879
  return rows.join(' · ')
9542
9880
  }
9543
9881
  const pageStyle = { maxWidth: '640px', padding: '4px 0 16px' }
9544
- /* The ten switches are grouped into four concerns so the page can be
9882
+ /* The switches are grouped into five concerns so the page can be
9545
9883
  scanned instead of read as a flat list: 主题 (master switch +
9546
- appearance), 背景 (contour sheet + watermark), 动画 (boot loader),
9547
- 娱乐 (雷霆大字 announcements + their entry animation).
9884
+ appearance), 背景 (contour sheet + watermark), 动画 (output scroll
9885
+ animation + boot loader), 娱乐 (雷霆大字 announcements), 通知 (audio
9886
+ notifications).
9548
9887
  Each group is an editorial numbered header; rows keep their stable
9549
9888
  React keys. The last row of each group drops its divider so the next
9550
9889
  group header's own rule is the only line between groups.
@@ -9575,6 +9914,48 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9575
9914
  }
9576
9915
  /** "<row label>: <on|off>" — one spelling for every status row. */
9577
9916
  const stateOf = (on) => t(on ? 'on' : 'off')
9917
+ /* 二级折叠子节(组 > 子节 > 行)。头部是全宽按钮:左侧小刻线 + 中文名
9918
+ + 拉丁大写(与组头同一套 editorial 语言,但整体降一级),右侧可选
9919
+ 状态摘要 + 随展开态翻转的 +/− 刻字箭头;展开态同时由 aria-expanded
9920
+ 表达,提示不写进行内(收起的九个子节头会把同一段文案重复九次)。
9921
+ body 始终渲染、仅以 hidden 属性收起:行不离开元素树,store 订阅、
9922
+ disabled 推导与测试的树遍历都不因折叠状态而改变;status 摘要让用户
9923
+ 在收起时仍能看到该节主开关的读数。类名避开主题 hover 反转规则的
9924
+ 语义子串(item/row/card/tab…),折叠头的文字颜色全程自持。 */
9925
+ const subSection = (id, key, status, rows) => {
9926
+ const open = openSubs[id] === true
9927
+ const bodyId = 'endfield-sub-' + id
9928
+ const head = [
9929
+ R.createElement('span', { key: 'mark', 'aria-hidden': 'true', style: { width: '3px', height: '11px', flex: '0 0 auto', background: 'currentColor', opacity: 0.55 } }),
9930
+ R.createElement('span', { key: 'cn', style: { fontSize: '12px', fontWeight: 600, letterSpacing: '0.12em', lineHeight: '1.5' } }, t(key)),
9931
+ ]
9932
+ const latin = LOCALE_EN[key]
9933
+ if (latin !== undefined && latin !== t(key)) {
9934
+ head.push(R.createElement('span', { key: 'en', style: { fontSize: '10px', fontWeight: 500, letterSpacing: '0.18em', opacity: 0.6, lineHeight: '1.5' } }, latin))
9935
+ }
9936
+ head.push(R.createElement('span', { key: 'gap', 'aria-hidden': 'true', style: { flex: '1 1 auto' } }))
9937
+ if (status !== undefined) {
9938
+ head.push(R.createElement('span', { key: 'status', style: { fontSize: '11px', opacity: 0.66, lineHeight: '1.5' } }, status))
9939
+ }
9940
+ head.push(R.createElement('span', { key: 'arrow', 'aria-hidden': 'true', style: { flex: '0 0 auto', fontSize: '13px', lineHeight: '1' } }, open ? '−' : '+'))
9941
+ return R.createElement('div', { key: 'sub-' + id, className: 'endfield-settings-sub' }, [
9942
+ R.createElement('button', {
9943
+ key: 'head',
9944
+ type: 'button',
9945
+ className: 'endfield-settings-sub-toggle',
9946
+ onClick: () => toggleSub(id),
9947
+ 'aria-expanded': open,
9948
+ 'aria-controls': bodyId,
9949
+ style: {
9950
+ display: 'flex', alignItems: 'center', gap: '8px', width: '100%',
9951
+ padding: '10px 0', background: 'transparent', border: 'none',
9952
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
9953
+ color: 'inherit', textAlign: 'left', cursor: 'pointer',
9954
+ },
9955
+ }, head),
9956
+ R.createElement('div', { key: 'sub-body-' + id, id: bodyId, hidden: !open, className: 'endfield-settings-sub-body' }, rows),
9957
+ ])
9958
+ }
9578
9959
  const row = (key, last, children) => R.createElement('div', { key, style: last ? { ...rowStyle, borderBottom: 'none' } : rowStyle }, children)
9579
9960
  return R.createElement('div', { className: 'endfield-settings', style: pageStyle }, [
9580
9961
  /* --- 01 主题:总开关在最前,随后是配色与圆角 --- */
@@ -9633,6 +10014,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9633
10014
  /* --- 02 背景:等高线 + 水印,各自的主开关在前、附属开关在后 --- */
9634
10015
  R.createElement('div', { key: 'group-bg' }, [
9635
10016
  groupTitle('02', 'groupBg', false),
10017
+ subSection('contour', 'subContour', stateOf(contourOn), [
9636
10018
  row('contour', false, [
9637
10019
  R.createElement('span', { style: labelStyle },
9638
10020
  t('contourRow') + t('sep') + stateOf(contourOn),
@@ -9666,7 +10048,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9666
10048
  row('contour-trail', false, [
9667
10049
  R.createElement('span', { style: labelStyle },
9668
10050
  t('contourTrailRow') + t('sep') + stateOf(contourTrailOn),
9669
- R.createElement('span', { style: hintStyle }, t('contourTrailHint'))
10051
+ R.createElement('span', { style: hintStyle },
10052
+ // Say so when the selected renderer cannot draw the trail,
10053
+ // rather than letting the switch look broken.
10054
+ (contourTrailOn && contourRenderer === 'worker-webgl')
10055
+ ? t('contourTrailHintWorker')
10056
+ : t('contourTrailHint')
10057
+ )
9670
10058
  ),
9671
10059
  R.createElement('button', {
9672
10060
  type: 'button', onClick: toggleContourTrail,
@@ -9731,6 +10119,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9731
10119
  title: contourOn ? '' : t('contourAnimNeedLayer'),
9732
10120
  }, t(contourScrollPause ? 'contourScrollPauseOff' : 'contourScrollPauseOn'))
9733
10121
  ]),
10122
+ ]),
10123
+ subSection('watermark', 'subWatermark', stateOf(wmOn), [
9734
10124
  row('watermark', false, [
9735
10125
  R.createElement('span', { style: labelStyle }, t('watermarkRow') + t('sep') + stateOf(wmOn)),
9736
10126
  R.createElement('button', { type: 'button', onClick: toggleWm, style: btnStyleFor(wmOn) }, t(wmOn ? 'watermarkOff' : 'watermarkOn'))
@@ -9751,10 +10141,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9751
10141
  title: wmOn ? '' : t('wmPersistNeedWm'),
9752
10142
  }, t(wmPersist ? 'wmPersistOff' : 'wmPersistOn'))
9753
10143
  ]),
10144
+ ]),
9754
10145
  ]),
9755
10146
  /* --- 03 动画:输出滚动动画 + 启动加载动画 --- */
9756
10147
  R.createElement('div', { key: 'group-anim' }, [
9757
10148
  groupTitle('03', 'groupAnim', false),
10149
+ subSection('scroll', 'subScrollAnim', stateOf(scrollAnim), [
9758
10150
  row('scroll-anim', false, [
9759
10151
  R.createElement('span', { style: labelStyle },
9760
10152
  t('scrollAnimRow') + t('sep') + stateOf(scrollAnim),
@@ -9792,6 +10184,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9792
10184
  }, t(SCROLL_ANIM_LEVEL_COPY[level])))
9793
10185
  )
9794
10186
  ]),
10187
+ ]),
10188
+ subSection('loader', 'subLoader', stateOf(loaderOn), [
9795
10189
  row('loader', true, [
9796
10190
  R.createElement('span', { style: labelStyle },
9797
10191
  t('loaderRow') + t('sep') + stateOf(loaderOn),
@@ -9815,10 +10209,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9815
10209
  R.createElement('button', { type: 'button', onClick: toggleLoader, style: btnStyleFor(loaderOn) }, t(loaderOn ? 'loaderOff' : 'loaderOn'))
9816
10210
  )
9817
10211
  ]),
10212
+ ]),
9818
10213
  ]),
9819
10214
  /* --- 04 娱乐:雷霆大字(主开关 + 入场动画子开关) --- */
9820
10215
  R.createElement('div', { key: 'group-fun' }, [
9821
10216
  groupTitle('04', 'groupFun', false),
10217
+ subSection('thunder', 'subThunder', stateOf(thunderOn), [
9822
10218
  row('thunder', false, [
9823
10219
  R.createElement('span', { style: labelStyle },
9824
10220
  t('thunderRow') + t('sep') + stateOf(thunderOn),
@@ -9859,6 +10255,8 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9859
10255
  title: thunderOn ? '' : t('thunderNeed'),
9860
10256
  }, t(thunderAnim ? 'thunderAnimOff' : 'thunderAnimOn'))
9861
10257
  ]),
10258
+ ]),
10259
+ subSection('balance', 'subBalance', stateOf(balanceOn), [
9862
10260
  /* --- 顶部余额胶囊:同样属于「娱乐」组的悬浮层 --- */
9863
10261
  row('balance-capsule', false, [
9864
10262
  R.createElement('span', { style: labelStyle },
@@ -9909,11 +10307,13 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9909
10307
  t('balancePosition' + value.charAt(0).toUpperCase() + value.slice(1)))))
9910
10308
  ]),
9911
10309
  /* --- 防遮挡两个子开关:侧栏展开自动隐藏 / 指针靠近自动隐藏 ---
9912
- Both ship ON because the遮挡 they answer is what the capsule
9913
- rows exist for; each toggle can silence its rule individually.
9914
- The switches stay enabled with the capsule off (a
9915
- pre-configuration the next mount honours), mirroring the
9916
- position select above; the hint states what fades and when. */
10310
+ Sidebar-hide ships OFF (opening the sidebar is the user's own
10311
+ deliberate action); hover-hide ships ON (the capsule moving out
10312
+ of the pointer's way is the un-asked-for behaviour). Each toggle
10313
+ can silence its rule individually. The switches stay enabled
10314
+ with the capsule off (a pre-configuration the next mount
10315
+ honours), mirroring the position select above; the hint states
10316
+ what fades and when. */
9917
10317
  row('balance-capsule-sidebar-hide', false, [
9918
10318
  R.createElement('span', { style: labelStyle },
9919
10319
  t('balanceSidebarHideRow') + t('sep') + stateOf(balanceSidebarHide),
@@ -9925,7 +10325,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9925
10325
  type: 'button',
9926
10326
  onClick: toggleBalanceSidebarHide,
9927
10327
  style: btnStyleFor(balanceSidebarHide),
9928
- }, t(balanceSidebarHide ? 'balanceOff' : 'balanceOn'))
10328
+ }, t(balanceSidebarHide ? 'off' : 'on'))
9929
10329
  ]),
9930
10330
  row('balance-capsule-hover-hide', false, [
9931
10331
  R.createElement('span', { style: labelStyle },
@@ -9938,7 +10338,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9938
10338
  type: 'button',
9939
10339
  onClick: toggleBalanceHoverHide,
9940
10340
  style: btnStyleFor(balanceHoverHide),
9941
- }, t(balanceHoverHide ? 'balanceOff' : 'balanceOn'))
10341
+ }, t(balanceHoverHide ? 'off' : 'on'))
9942
10342
  ]),
9943
10343
  /* --- 渠道额度读数:右侧百分比显示已用 or 剩余 ---
9944
10344
  The row lives directly under the capsule switch because it only
@@ -9961,6 +10361,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9961
10361
  }, CREDIT_DISPLAY_OPTIONS.map((value) => R.createElement('option', { key: value, value },
9962
10362
  t(value === 'used' ? 'creditDisplayUsed' : 'creditDisplayRemaining'))))
9963
10363
  ]),
10364
+ ]),
9964
10365
  ]),
9965
10366
  /* --- 05 音频:两个生效槽位 + 两个预留槽位 ---
9966
10367
  Every row states WHEN it fires, because that is the whole contract
@@ -9968,6 +10369,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
9968
10369
  not fire yet, so a switch that does nothing cannot read as broken. */
9969
10370
  R.createElement('div', { key: 'group-audio' }, [
9970
10371
  groupTitle('05', 'groupAudio', false),
10372
+ subSection('slots', 'subAudioSlots', stateOf(audioOn), [
9971
10373
  row('audio', false, [
9972
10374
  R.createElement('span', { style: labelStyle },
9973
10375
  t('audioRow') + t('sep') + stateOf(audioOn),
@@ -10027,6 +10429,21 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10027
10429
  }, t(audioDone ? 'audioDoneOff' : 'audioDoneOn'))
10028
10430
  )
10029
10431
  ]),
10432
+ row('audio-human', true, [
10433
+ R.createElement('span', { style: labelStyle },
10434
+ t('audioHumanOnlyRow') + t('sep') + t(audioHumanOnly ? 'audioHumanOnlyOn' : 'audioHumanOnlyOff'),
10435
+ R.createElement('span', { style: hintStyle },
10436
+ t(audioHumanOnly ? 'audioHumanOnlyHintOn' : 'audioHumanOnlyHintOff')
10437
+ )
10438
+ ),
10439
+ R.createElement('button', {
10440
+ type: 'button', onClick: toggleAudioHumanOnly,
10441
+ style: btnStyleFor(audioHumanOnly, !audioOn), disabled: !audioOn,
10442
+ title: audioOn ? '' : t('audioNeedOn'),
10443
+ }, t(audioHumanOnly ? 'audioHumanOnlyOff' : 'audioHumanOnlyOn'))
10444
+ ]),
10445
+ ]),
10446
+ subSection('sound', 'subAudioSound', String(audioVolume) + '%', [
10030
10447
  row('audio-volume', false, [
10031
10448
  R.createElement('span', { style: labelStyle },
10032
10449
  t('audioVolumeRow') + t('sep') + String(audioVolume) + '%',
@@ -10045,24 +10462,6 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10045
10462
  R.createElement('span', { style: { ...labelStyle, minWidth: '38px', textAlign: 'right' } }, String(audioVolume) + '%')
10046
10463
  )
10047
10464
  ]),
10048
- row('audio-attention', false, [
10049
- R.createElement('span', { style: labelStyle },
10050
- t('audioAttentionRow') + t('sep') + stateOf(audioTestReady()),
10051
- R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
10052
- ),
10053
- R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
10054
- audioTestButton('attention', 'audioSlotAttention')
10055
- )
10056
- ]),
10057
- row('audio-fail', false, [
10058
- R.createElement('span', { style: labelStyle },
10059
- t('audioTurnFailRow') + t('sep') + stateOf(audioTestReady()),
10060
- R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
10061
- ),
10062
- R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
10063
- audioTestButton('turn-fail', 'audioSlotFail')
10064
- )
10065
- ]),
10066
10465
  row('audio-source', false, [
10067
10466
  R.createElement('span', { style: labelStyle },
10068
10467
  t('audioFileRow') + t('sep') + sourceSummary(),
@@ -10073,7 +10472,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10073
10472
  style: btnStyleFor(false),
10074
10473
  }, t('audioRefresh'))
10075
10474
  ]),
10076
- row('audio-dir', false, [
10475
+ row('audio-dir', true, [
10077
10476
  R.createElement('span', { style: labelStyle },
10078
10477
  t('audioSoundDirRow') + t('sep') + (readAudioSoundDir() || t('audioSoundDirDefault')),
10079
10478
  R.createElement('span', { style: hintStyle }, t('audioSoundDirHint'))
@@ -10090,18 +10489,27 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10090
10489
  })
10091
10490
  )
10092
10491
  ]),
10093
- row('audio-human', false, [
10492
+ ]),
10493
+ /* 预留槽位(需要你回应 / 出错)与诊断日志同节:三行都没有
10494
+ 主开关,行内的试听按钮与诊断开关就是这一节的全部。 */
10495
+ subSection('reserved', 'subAudioMeta', undefined, [
10496
+ row('audio-attention', false, [
10094
10497
  R.createElement('span', { style: labelStyle },
10095
- t('audioHumanOnlyRow') + t('sep') + t(audioHumanOnly ? 'audioHumanOnlyOn' : 'audioHumanOnlyOff'),
10096
- R.createElement('span', { style: hintStyle },
10097
- t(audioHumanOnly ? 'audioHumanOnlyHintOn' : 'audioHumanOnlyHintOff')
10098
- )
10498
+ t('audioAttentionRow') + t('sep') + stateOf(audioTestReady()),
10499
+ R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
10099
10500
  ),
10100
- R.createElement('button', {
10101
- type: 'button', onClick: toggleAudioHumanOnly,
10102
- style: btnStyleFor(audioHumanOnly, !audioOn), disabled: !audioOn,
10103
- title: audioOn ? '' : t('audioNeedOn'),
10104
- }, t(audioHumanOnly ? 'audioHumanOnlyOff' : 'audioHumanOnlyOn'))
10501
+ R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
10502
+ audioTestButton('attention', 'audioSlotAttention')
10503
+ )
10504
+ ]),
10505
+ row('audio-fail', false, [
10506
+ R.createElement('span', { style: labelStyle },
10507
+ t('audioTurnFailRow') + t('sep') + stateOf(audioTestReady()),
10508
+ R.createElement('span', { style: hintStyle }, t('audioReservedHint'))
10509
+ ),
10510
+ R.createElement('span', { style: { display: 'flex', gap: '8px', flex: '0 0 auto' } },
10511
+ audioTestButton('turn-fail', 'audioSlotFail')
10512
+ )
10105
10513
  ]),
10106
10514
  row('audio-diag', true, [
10107
10515
  R.createElement('span', { style: labelStyle },
@@ -10112,6 +10520,7 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10112
10520
  ),
10113
10521
  R.createElement('button', { type: 'button', onClick: toggleAudioDiag, style: btnStyleFor(audioDiag) }, t(audioDiag ? 'audioDiagOff' : 'audioDiagOn'))
10114
10522
  ]),
10523
+ ]),
10115
10524
  ]),
10116
10525
  ])
10117
10526
  }
@@ -10139,6 +10548,12 @@ function apply(ctx) { // Idempotency: the installed bundle can be applied mor
10139
10548
  if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
10140
10549
  document.removeEventListener('DOMContentLoaded', watermarkObserverLate)
10141
10550
  document.removeEventListener('DOMContentLoaded', contourSchemeObserverLate)
10551
+ // The boot loader's pre-body deferral too: a dead run must not be wired
10552
+ // back in to mount an unstyled plate.
10553
+ if (loaderDomReadyPending !== null) {
10554
+ document.removeEventListener('DOMContentLoaded', loaderDomReadyPending)
10555
+ loaderDomReadyPending = null
10556
+ }
10142
10557
  }
10143
10558
  if (watermarkRaf !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(watermarkRaf)
10144
10559
  if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') window.removeEventListener('resize', onWatermarkResize)