@nano133/nanoterra 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +202 -9
  3. package/ai/SPEC.md +51 -2
  4. package/dist/admin/adapters/d1.d.ts +1 -0
  5. package/dist/admin/adapters/d1.js +6 -4
  6. package/dist/admin/adapters/firestore.d.ts +1 -0
  7. package/dist/admin/adapters/firestore.js +7 -5
  8. package/dist/admin/conformance.d.ts +1 -1
  9. package/dist/admin/conformance.js +12 -4
  10. package/dist/admin/handler.d.ts +1 -1
  11. package/dist/admin/handler.js +2 -2
  12. package/dist/admin/index.d.ts +2 -2
  13. package/dist/admin/index.js +1 -1
  14. package/dist/admin/memory.js +3 -1
  15. package/dist/admin/panel/gameCard.js +13 -10
  16. package/dist/admin/panel/icons.d.ts +17 -0
  17. package/dist/admin/panel/icons.js +3 -0
  18. package/dist/admin/panel/overview.js +1 -1
  19. package/dist/admin/store.d.ts +2 -1
  20. package/dist/admin/switches.d.ts +13 -3
  21. package/dist/admin/switches.js +25 -6
  22. package/dist/character/create.js +6 -2
  23. package/dist/character/layer.d.ts +14 -0
  24. package/dist/character/layer.js +10 -0
  25. package/dist/emotes/clip.d.ts +79 -0
  26. package/dist/emotes/clip.js +165 -0
  27. package/dist/emotes/clips.d.ts +3 -0
  28. package/dist/emotes/clips.js +2 -0
  29. package/dist/emotes/events.d.ts +22 -0
  30. package/dist/emotes/events.js +19 -0
  31. package/dist/emotes/icons.d.ts +1 -0
  32. package/dist/emotes/icons.js +6 -0
  33. package/dist/emotes/index.d.ts +8 -0
  34. package/dist/emotes/index.js +11 -0
  35. package/dist/emotes/list.d.ts +123 -0
  36. package/dist/emotes/list.js +23 -0
  37. package/dist/emotes/options.d.ts +16 -0
  38. package/dist/emotes/options.js +34 -0
  39. package/dist/emotes/panel/index.d.ts +3 -0
  40. package/dist/emotes/panel/index.js +9 -0
  41. package/dist/emotes/panel/mount.d.ts +56 -0
  42. package/dist/emotes/panel/mount.js +360 -0
  43. package/dist/emotes/player.d.ts +37 -0
  44. package/dist/emotes/player.js +101 -0
  45. package/dist/emotes/wheel.d.ts +42 -0
  46. package/dist/emotes/wheel.js +110 -0
  47. package/dist/options/common.d.ts +88 -0
  48. package/dist/options/common.js +59 -0
  49. package/dist/options/core.d.ts +130 -0
  50. package/dist/options/core.js +420 -0
  51. package/dist/options/index.d.ts +7 -0
  52. package/dist/options/index.js +24 -0
  53. package/dist/options/panel/index.d.ts +2 -0
  54. package/dist/options/panel/index.js +10 -0
  55. package/dist/options/panel/mount.d.ts +30 -0
  56. package/dist/options/panel/mount.js +378 -0
  57. package/dist/options/reflect.d.ts +5 -0
  58. package/dist/options/reflect.js +27 -0
  59. package/package.json +12 -4
  60. package/src/admin/adapters/d1.ts +5 -4
  61. package/src/admin/adapters/firestore.ts +6 -5
  62. package/src/admin/conformance.ts +14 -5
  63. package/src/admin/handler.ts +2 -2
  64. package/src/admin/index.ts +2 -2
  65. package/src/admin/memory.ts +2 -1
  66. package/src/admin/panel/gameCard.ts +13 -9
  67. package/src/admin/panel/icons.ts +3 -0
  68. package/src/admin/panel/overview.ts +1 -1
  69. package/src/admin/store.ts +2 -2
  70. package/src/admin/switches.ts +29 -12
  71. package/src/character/create.ts +6 -2
  72. package/src/character/layer.ts +21 -0
  73. package/src/emotes/clip.ts +204 -0
  74. package/src/emotes/clips.ts +7 -0
  75. package/src/emotes/events.ts +28 -0
  76. package/src/emotes/icons.ts +7 -0
  77. package/src/emotes/icons.webp +0 -0
  78. package/src/emotes/index.ts +16 -0
  79. package/src/emotes/list.ts +33 -0
  80. package/src/emotes/options.ts +43 -0
  81. package/src/emotes/panel/index.ts +11 -0
  82. package/src/emotes/panel/mount.ts +390 -0
  83. package/src/emotes/panel/style.css +415 -0
  84. package/src/emotes/player.ts +131 -0
  85. package/src/emotes/wheel.ts +131 -0
  86. package/src/options/common.ts +65 -0
  87. package/src/options/core.ts +465 -0
  88. package/src/options/index.ts +41 -0
  89. package/src/options/panel/index.ts +12 -0
  90. package/src/options/panel/mount.ts +418 -0
  91. package/src/options/panel/style.css +528 -0
  92. package/src/options/reflect.ts +28 -0
@@ -0,0 +1,528 @@
1
+ /* The Options key and panel (mountOptions in ./mount.ts). Every rule is under .n133-options, so nothing leaks into the game's page.
2
+ Phone first: every key, switch, choice and slider is 44 px or more, and a row's label is 16 px. On a phone held upright the panel is a sheet from the
3
+ bottom; on a wide or sideways screen it is a sheet on the right. A scrim covers the game while it is open; a press on it closes the panel.
4
+ Light and dark: the panel follows data-n133-theme="light" | "dark" on the page's <html> when the page sets it (the admin panel does), else the device's
5
+ prefers-color-scheme.
6
+ To theme it, set the --n133-* custom properties on the element that holds the key (or a parent): --n133-ink, --n133-ink-soft, --n133-paper, --n133-ground
7
+ (the tab bar and a choice's well), --n133-line, --n133-gem, --n133-gem-dark, --n133-on-gem, --n133-radius, --n133-font and --n133-options-z (the panel's
8
+ z-index, default 1100: above the friends panel). Those set the LIGHT theme; the DARK theme reads the twins with "dark-" in the name: --n133-dark-ink,
9
+ --n133-dark-paper and so on. */
10
+
11
+ .n133-options {
12
+ --font: var(--n133-font, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
13
+ --radius: var(--n133-radius, 18px);
14
+ --ink: var(--n133-ink, #16202e);
15
+ --ink-soft: var(--n133-ink-soft, #4a5568);
16
+ --paper: var(--n133-paper, #ffffff);
17
+ --ground: var(--n133-ground, #eef2f7);
18
+ --line: var(--n133-line, #d3dce7);
19
+ --gem: var(--n133-gem, #20c9a6);
20
+ --gem-dark: var(--n133-gem-dark, #0e8a72);
21
+ --on-gem: var(--n133-on-gem, #ffffff);
22
+ /* The colour of the shadows and the scrim (r g b). */
23
+ --shade: 16 24 40;
24
+ --z: var(--n133-options-z, 1100);
25
+ display: inline-block;
26
+ color: var(--ink);
27
+ color-scheme: light;
28
+ font: 16px/1.4 var(--font);
29
+ -webkit-text-size-adjust: 100%;
30
+ -webkit-tap-highlight-color: transparent;
31
+ }
32
+
33
+ /* Dark: the page says so (data-n133-theme), or the device does and the page does not say light. The two blocks are the same: CSS cannot share them. */
34
+ :root[data-n133-theme="dark"] .n133-options {
35
+ --ink: var(--n133-dark-ink, #e7edf5);
36
+ --ink-soft: var(--n133-dark-ink-soft, #a9b5c5);
37
+ --paper: var(--n133-dark-paper, #171f2b);
38
+ --ground: var(--n133-dark-ground, #0f151e);
39
+ --line: var(--n133-dark-line, #34415a);
40
+ --gem: var(--n133-dark-gem, #20c9a6);
41
+ --gem-dark: var(--n133-dark-gem-dark, #3fd8b6);
42
+ --on-gem: var(--n133-dark-on-gem, #062b23);
43
+ --shade: 0 0 0;
44
+ color-scheme: dark;
45
+ }
46
+
47
+ @media (prefers-color-scheme: dark) {
48
+ :root:not([data-n133-theme="light"]) .n133-options {
49
+ --ink: var(--n133-dark-ink, #e7edf5);
50
+ --ink-soft: var(--n133-dark-ink-soft, #a9b5c5);
51
+ --paper: var(--n133-dark-paper, #171f2b);
52
+ --ground: var(--n133-dark-ground, #0f151e);
53
+ --line: var(--n133-dark-line, #34415a);
54
+ --gem: var(--n133-dark-gem, #20c9a6);
55
+ --gem-dark: var(--n133-dark-gem-dark, #3fd8b6);
56
+ --on-gem: var(--n133-dark-on-gem, #062b23);
57
+ --shade: 0 0 0;
58
+ color-scheme: dark;
59
+ }
60
+ }
61
+
62
+ .n133-options *,
63
+ .n133-options *::before,
64
+ .n133-options *::after {
65
+ box-sizing: border-box;
66
+ }
67
+
68
+ .n133-options [hidden] {
69
+ display: none !important;
70
+ }
71
+
72
+ .n133-options :is(h2, h3, p) {
73
+ margin: 0;
74
+ }
75
+
76
+ /* ---- the key ---- */
77
+
78
+ .n133-options .n133o-key {
79
+ display: inline-flex;
80
+ align-items: center;
81
+ gap: 8px;
82
+ min-width: 44px;
83
+ min-height: 44px;
84
+ padding: 0 14px;
85
+ border: 1px solid var(--line);
86
+ border-radius: 999px;
87
+ background: var(--paper);
88
+ color: var(--ink);
89
+ font: 650 15px/1 var(--font);
90
+ cursor: pointer;
91
+ touch-action: manipulation;
92
+ }
93
+
94
+ /* ---- the scrim and the sheet ---- */
95
+
96
+ .n133-options .n133o-scrim {
97
+ position: fixed;
98
+ z-index: var(--z);
99
+ inset: 0;
100
+ background: rgb(var(--shade) / 0.4);
101
+ }
102
+
103
+ .n133-options .n133o-panel {
104
+ position: fixed;
105
+ z-index: var(--z);
106
+ right: 0;
107
+ bottom: 0;
108
+ left: 0;
109
+ display: flex;
110
+ flex-direction: column;
111
+ height: min(85vh, 680px);
112
+ height: min(85dvh, 680px);
113
+ border: 1px solid var(--line);
114
+ border-bottom: 0;
115
+ border-radius: var(--radius) var(--radius) 0 0;
116
+ background: var(--paper);
117
+ color: var(--ink);
118
+ box-shadow: 0 -8px 30px rgb(var(--shade) / 0.18);
119
+ }
120
+
121
+ /* Closed: not shown, off the screen and out of the tab order. */
122
+ .n133-options .n133o-panel[data-open="false"] {
123
+ visibility: hidden;
124
+ pointer-events: none;
125
+ transform: translateY(110%);
126
+ }
127
+
128
+ .n133-options .n133o-scrim[data-open="false"] {
129
+ visibility: hidden;
130
+ pointer-events: none;
131
+ opacity: 0;
132
+ }
133
+
134
+ @media (min-width: 640px), (orientation: landscape) and (max-height: 520px) {
135
+ .n133-options .n133o-panel {
136
+ top: 0;
137
+ left: auto;
138
+ width: min(400px, 100vw);
139
+ height: auto;
140
+ border-bottom: 1px solid var(--line);
141
+ border-right: 0;
142
+ border-radius: var(--radius) 0 0 var(--radius);
143
+ box-shadow: -8px 0 30px rgb(var(--shade) / 0.18);
144
+ padding-right: env(safe-area-inset-right);
145
+ }
146
+
147
+ .n133-options .n133o-panel[data-open="false"] {
148
+ transform: translateX(110%);
149
+ }
150
+ }
151
+
152
+ @media (prefers-reduced-motion: no-preference) {
153
+ .n133-options .n133o-panel {
154
+ transition: transform 180ms ease-out, visibility 0s linear 180ms;
155
+ }
156
+
157
+ .n133-options .n133o-panel[data-open="true"] {
158
+ transition: transform 180ms ease-out, visibility 0s;
159
+ }
160
+
161
+ .n133-options .n133o-scrim {
162
+ transition: opacity 180ms ease-out, visibility 0s linear 180ms;
163
+ }
164
+
165
+ .n133-options .n133o-scrim[data-open="true"] {
166
+ transition: opacity 180ms ease-out, visibility 0s;
167
+ }
168
+ }
169
+
170
+ .n133-options .n133o-head {
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: space-between;
174
+ gap: 8px;
175
+ padding: 8px 8px 4px 18px;
176
+ }
177
+
178
+ .n133-options .n133o-title {
179
+ font: 750 19px/1.2 var(--font);
180
+ }
181
+
182
+ .n133-options .n133o-close {
183
+ display: grid;
184
+ place-items: center;
185
+ width: 44px;
186
+ height: 44px;
187
+ border: 0;
188
+ border-radius: 12px;
189
+ background: transparent;
190
+ color: var(--ink);
191
+ cursor: pointer;
192
+ }
193
+
194
+ /* ---- the tabs ---- */
195
+
196
+ .n133-options .n133o-tabs {
197
+ display: grid;
198
+ grid-auto-columns: 1fr;
199
+ grid-auto-flow: column;
200
+ gap: 4px;
201
+ margin: 4px 16px 4px;
202
+ padding: 4px;
203
+ border-radius: 14px;
204
+ background: var(--ground);
205
+ }
206
+
207
+ .n133-options .n133o-tab {
208
+ min-height: 44px;
209
+ border: 0;
210
+ border-radius: 10px;
211
+ background: transparent;
212
+ color: var(--ink-soft);
213
+ font: 650 16px/1 var(--font);
214
+ cursor: pointer;
215
+ touch-action: manipulation;
216
+ }
217
+
218
+ .n133-options .n133o-tab[aria-selected="true"] {
219
+ background: var(--paper);
220
+ color: var(--ink);
221
+ box-shadow: 0 1px 3px rgb(var(--shade) / 0.18), inset 0 0 0 1px var(--line);
222
+ }
223
+
224
+ /* ---- the rows ---- */
225
+
226
+ .n133-options .n133o-body {
227
+ flex: 1 1 auto;
228
+ min-height: 0;
229
+ overflow: auto;
230
+ overscroll-behavior: contain;
231
+ padding: 4px 16px 16px;
232
+ }
233
+
234
+ .n133-options .n133o-group {
235
+ margin-top: 16px;
236
+ }
237
+
238
+ .n133-options .n133o-pane > :first-child {
239
+ margin-top: 8px;
240
+ }
241
+
242
+ .n133-options .n133o-group-title {
243
+ margin: 0 0 8px;
244
+ color: var(--ink-soft);
245
+ font: 700 13px/1.2 var(--font);
246
+ letter-spacing: 0.04em;
247
+ text-transform: uppercase;
248
+ }
249
+
250
+ .n133-options .n133o-rows {
251
+ display: grid;
252
+ gap: 8px;
253
+ }
254
+
255
+ .n133-options .n133o-row {
256
+ display: grid;
257
+ gap: 8px;
258
+ width: 100%;
259
+ min-height: 56px;
260
+ padding: 10px 14px;
261
+ border: 1px solid var(--line);
262
+ border-radius: 14px;
263
+ background: var(--paper);
264
+ color: var(--ink);
265
+ font: inherit;
266
+ text-align: left;
267
+ }
268
+
269
+ .n133-options .n133o-text {
270
+ display: grid;
271
+ gap: 2px;
272
+ min-width: 0;
273
+ }
274
+
275
+ .n133-options .n133o-label {
276
+ font: 600 16px/1.3 var(--font);
277
+ overflow-wrap: anywhere;
278
+ }
279
+
280
+ .n133-options .n133o-help {
281
+ color: var(--ink-soft);
282
+ font: 14px/1.35 var(--font);
283
+ overflow-wrap: anywhere;
284
+ }
285
+
286
+ /* A toggle: the whole row is the switch. */
287
+ .n133-options .n133o-toggle {
288
+ grid-template-columns: minmax(0, 1fr) auto;
289
+ align-items: center;
290
+ cursor: pointer;
291
+ touch-action: manipulation;
292
+ }
293
+
294
+ .n133-options .n133o-switch {
295
+ position: relative;
296
+ width: 52px;
297
+ height: 32px;
298
+ border-radius: 16px;
299
+ background: var(--line);
300
+ }
301
+
302
+ .n133-options .n133o-knob {
303
+ position: absolute;
304
+ top: 3px;
305
+ left: 3px;
306
+ width: 26px;
307
+ height: 26px;
308
+ border-radius: 50%;
309
+ background: #ffffff;
310
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.3);
311
+ }
312
+
313
+ .n133-options .n133o-toggle[aria-checked="true"] .n133o-switch {
314
+ background: var(--gem-dark);
315
+ }
316
+
317
+ .n133-options .n133o-toggle[aria-checked="true"] .n133o-knob {
318
+ transform: translateX(20px);
319
+ }
320
+
321
+ @media (prefers-reduced-motion: no-preference) {
322
+ .n133-options .n133o-knob {
323
+ transition: transform 120ms ease-out;
324
+ }
325
+ }
326
+
327
+ /* A choice: a row of keys in a well, or a drop-down. */
328
+ .n133-options .n133o-seg {
329
+ display: grid;
330
+ grid-auto-columns: 1fr;
331
+ grid-auto-flow: column;
332
+ gap: 3px;
333
+ padding: 3px;
334
+ border-radius: 12px;
335
+ background: var(--ground);
336
+ }
337
+
338
+ .n133-options .n133o-seg-key {
339
+ min-width: 0;
340
+ min-height: 44px;
341
+ padding: 0 6px;
342
+ border: 0;
343
+ border-radius: 9px;
344
+ background: transparent;
345
+ color: var(--ink-soft);
346
+ font: 600 15px/1.1 var(--font);
347
+ cursor: pointer;
348
+ touch-action: manipulation;
349
+ overflow-wrap: anywhere;
350
+ }
351
+
352
+ .n133-options .n133o-seg-key[aria-checked="true"] {
353
+ background: var(--gem-dark);
354
+ color: var(--on-gem);
355
+ }
356
+
357
+ .n133-options .n133o-select {
358
+ width: 100%;
359
+ min-height: 44px;
360
+ padding: 0 12px;
361
+ border: 1px solid var(--line);
362
+ border-radius: 12px;
363
+ background: var(--paper);
364
+ color: var(--ink);
365
+ font: 16px/1 var(--font);
366
+ }
367
+
368
+ /* A range: the label and the value on one line, the help under them, the slider under that. The slider's box is 44 px tall: all of it takes a touch. */
369
+ .n133-options .n133o-range-text {
370
+ grid-template-columns: minmax(0, 1fr) auto;
371
+ align-items: baseline;
372
+ column-gap: 12px;
373
+ }
374
+
375
+ .n133-options .n133o-range-text .n133o-help {
376
+ grid-column: 1 / -1;
377
+ }
378
+
379
+ .n133-options .n133o-value {
380
+ color: var(--ink);
381
+ font: 700 16px/1.3 var(--font);
382
+ font-variant-numeric: tabular-nums;
383
+ }
384
+
385
+ .n133-options .n133o-range-input {
386
+ width: 100%;
387
+ height: 44px;
388
+ margin: 0;
389
+ background: transparent;
390
+ cursor: pointer;
391
+ -webkit-appearance: none;
392
+ appearance: none;
393
+ accent-color: var(--gem-dark);
394
+ }
395
+
396
+ .n133-options .n133o-range-input::-webkit-slider-runnable-track {
397
+ height: 8px;
398
+ border-radius: 4px;
399
+ background: linear-gradient(to right, var(--gem-dark) var(--n133o-fill, 0%), var(--line) var(--n133o-fill, 0%));
400
+ }
401
+
402
+ .n133-options .n133o-range-input::-webkit-slider-thumb {
403
+ width: 28px;
404
+ height: 28px;
405
+ margin-top: -10px;
406
+ border: 2px solid var(--gem-dark);
407
+ border-radius: 50%;
408
+ background: #ffffff;
409
+ box-shadow: 0 1px 4px rgb(0 0 0 / 0.3);
410
+ -webkit-appearance: none;
411
+ appearance: none;
412
+ }
413
+
414
+ .n133-options .n133o-range-input::-moz-range-track {
415
+ height: 8px;
416
+ border-radius: 4px;
417
+ background: var(--line);
418
+ }
419
+
420
+ .n133-options .n133o-range-input::-moz-range-progress {
421
+ height: 8px;
422
+ border-radius: 4px;
423
+ background: var(--gem-dark);
424
+ }
425
+
426
+ .n133-options .n133o-range-input::-moz-range-thumb {
427
+ width: 24px;
428
+ height: 24px;
429
+ border: 2px solid var(--gem-dark);
430
+ border-radius: 50%;
431
+ background: #ffffff;
432
+ box-shadow: 0 1px 4px rgb(0 0 0 / 0.3);
433
+ }
434
+
435
+ /* A key binding: the key's name on a key cap; it waits for the next key in the gem colour. */
436
+ .n133-options .n133o-key-row {
437
+ grid-template-columns: minmax(0, 1fr) auto;
438
+ align-items: center;
439
+ }
440
+
441
+ .n133-options .n133o-keycap {
442
+ min-width: 72px;
443
+ min-height: 44px;
444
+ padding: 0 14px;
445
+ border: 1px solid var(--line);
446
+ border-bottom-width: 3px;
447
+ border-radius: 10px;
448
+ background: var(--ground);
449
+ color: var(--ink);
450
+ font: 700 16px/1 var(--font);
451
+ cursor: pointer;
452
+ touch-action: manipulation;
453
+ }
454
+
455
+ .n133-options .n133o-keycap[aria-pressed="true"] {
456
+ border-color: var(--gem-dark);
457
+ background: var(--gem-dark);
458
+ color: var(--on-gem);
459
+ }
460
+
461
+ .n133-options .n133o-empty {
462
+ margin: 16px 0;
463
+ color: var(--ink-soft);
464
+ }
465
+
466
+ /* ---- the foot: Reset to defaults, and the panel's own question ---- */
467
+
468
+ .n133-options .n133o-foot {
469
+ flex: 0 0 auto;
470
+ padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
471
+ border-top: 1px solid var(--line);
472
+ }
473
+
474
+ .n133-options .n133o-btn {
475
+ display: inline-flex;
476
+ align-items: center;
477
+ justify-content: center;
478
+ gap: 8px;
479
+ min-height: 44px;
480
+ padding: 0 16px;
481
+ border: 1px solid var(--line);
482
+ border-radius: 12px;
483
+ background: var(--paper);
484
+ color: var(--ink);
485
+ font: 650 15px/1 var(--font);
486
+ white-space: nowrap;
487
+ cursor: pointer;
488
+ touch-action: manipulation;
489
+ }
490
+
491
+ .n133-options .n133o-btn:disabled {
492
+ color: var(--ink-soft);
493
+ cursor: default;
494
+ opacity: 0.6;
495
+ }
496
+
497
+ .n133-options .n133o-reset {
498
+ width: 100%;
499
+ }
500
+
501
+ .n133-options .n133o-primary {
502
+ border-color: var(--gem-dark);
503
+ background: var(--gem-dark);
504
+ color: var(--on-gem);
505
+ }
506
+
507
+ .n133-options .n133o-ask {
508
+ padding: 12px;
509
+ border: 1px solid var(--gem-dark);
510
+ border-radius: 14px;
511
+ }
512
+
513
+ .n133-options .n133o-ask-q {
514
+ margin: 0 0 10px;
515
+ font-weight: 650;
516
+ overflow-wrap: anywhere;
517
+ }
518
+
519
+ .n133-options .n133o-ask-keys {
520
+ display: grid;
521
+ grid-template-columns: 1fr 1fr;
522
+ gap: 8px;
523
+ }
524
+
525
+ .n133-options :is(.n133o-key, .n133o-close, .n133o-tab, .n133o-row, .n133o-seg-key, .n133o-keycap, .n133o-select, .n133o-range-input, .n133o-btn):focus-visible {
526
+ outline: 3px solid var(--gem);
527
+ outline-offset: 2px;
528
+ }
@@ -0,0 +1,28 @@
1
+ // Settings the page's CSS needs too: `reflectOptions(store, ["reducedMotion"])` keeps `data-n133-opt-reduced-motion="true" | "false"` on the page's <html>,
2
+ // so a stylesheet can calm every animation of the game's own HUD:
3
+ //
4
+ // html[data-n133-opt-reduced-motion="true"] .my-hud * { animation: none !important; transition: none !important; }
5
+ //
6
+ // Echo Island set a class on its root for its "Less motion" (2026-11-01); this is the kit's one way. The attribute's name is "data-n133-opt-" and the key in
7
+ // kebab case ("emotes.show" gives data-n133-opt-emotes-show); its value is the setting's value as text.
8
+
9
+ import type { OptionsStore } from "./core.js";
10
+
11
+ /** The attribute that holds a key's value: "reducedMotion" gives "data-n133-opt-reduced-motion". */
12
+ export const optionAttribute = (key: string): string => `data-n133-opt-${key.replace(/\./g, "-").replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
13
+
14
+ /** Keeps each key's value on `target` (default: the page's <html>) as it changes. Returns the call that stops and takes the attributes off. */
15
+ export function reflectOptions(store: OptionsStore, keys: readonly string[], target: Element | null = globalThis.document?.documentElement ?? null): () => void {
16
+ if (!target) return () => {};
17
+ for (const k of keys) {
18
+ store.get(k); // an unknown key throws here, at once
19
+ target.setAttribute(optionAttribute(k), String(store.get(k)));
20
+ }
21
+ const off = store.subscribe((k, v) => {
22
+ if (keys.includes(k)) target.setAttribute(optionAttribute(k), String(v));
23
+ });
24
+ return () => {
25
+ off();
26
+ for (const k of keys) target.removeAttribute(optionAttribute(k));
27
+ };
28
+ }