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/README.md +9 -6
- package/client.js +530 -115
- package/docs/audio-notifications.md +1 -1
- package/docs/design-language.md +1 -1
- package/docs/engineering-notes.md +59 -7
- package/docs/features.md +4 -1
- package/docs/preview.gif +0 -0
- package/docs/testing.md +38 -5
- package/index.js +7 -0
- package/lib/audio.js +30 -5
- package/lib/slots.js +3 -2
- package/package.json +8 -8
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
|
-
不存在默认就错的答案,所以默认
|
|
194
|
-
|
|
193
|
+
不存在默认就错的答案,所以默认 remaining(与左侧主数字的剩余额度语义一致,
|
|
194
|
+
也与 features.md 的字段表一致)。仅影响 credits 模式的右侧百分比槽位。 */
|
|
195
195
|
creditDisplay: 'remaining',
|
|
196
196
|
/* 余额胶囊的停靠位 —— center(顶部居中)| left(靠左)| right(靠右)。
|
|
197
197
|
仅当胶囊开启时有意义:选择靠边停靠可让开侧边栏与顶部工具栏的交叠区。 */
|
|
198
198
|
balanceCapsulePosition: 'center',
|
|
199
199
|
/* 防遮挡的两个子开关 —— 侧边栏展开自动隐藏 / 指针靠近自动隐藏。
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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.
|
|
1865
|
-
|
|
1866
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
if (
|
|
3856
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4333
|
-
the
|
|
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 —
|
|
4408
|
-
|
|
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 >=
|
|
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')
|
|
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
|
-
|
|
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 '
|
|
4652
|
-
|
|
4653
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
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
|
|
5199
|
-
delayed read resolves differs from the
|
|
5200
|
-
forced
|
|
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(
|
|
5337
|
+
if (typeof setTimeout !== 'function') { creditsRefresh(false); return }
|
|
5207
5338
|
creditsRebindTimer = setTimeout(() => {
|
|
5208
5339
|
creditsRebindTimer = null
|
|
5209
|
-
creditsRefresh(
|
|
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
|
|
6632
|
-
|
|
6633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
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 = !
|
|
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 = !
|
|
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 = !
|
|
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 = !
|
|
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 = !
|
|
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 = !
|
|
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
|
|
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), 动画 (
|
|
9547
|
-
娱乐 (雷霆大字 announcements
|
|
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 },
|
|
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
|
-
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
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 ? '
|
|
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 ? '
|
|
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',
|
|
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
|
-
|
|
10492
|
+
]),
|
|
10493
|
+
/* 预留槽位(需要你回应 / 出错)与诊断日志同节:三行都没有
|
|
10494
|
+
主开关,行内的试听按钮与诊断开关就是这一节的全部。 */
|
|
10495
|
+
subSection('reserved', 'subAudioMeta', undefined, [
|
|
10496
|
+
row('audio-attention', false, [
|
|
10094
10497
|
R.createElement('span', { style: labelStyle },
|
|
10095
|
-
t('
|
|
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('
|
|
10101
|
-
|
|
10102
|
-
|
|
10103
|
-
|
|
10104
|
-
|
|
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)
|