@motion-proto/live-tokens 0.61.0 → 0.63.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 (83) hide show
  1. package/.claude/skills/live-tokens-adjust-geometry/SKILL.md +4 -4
  2. package/.claude/skills/live-tokens-build-page/SKILL.md +21 -3
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +16 -48
  4. package/.claude/skills/live-tokens-create-component/references/fixed-overlays.md +10 -1
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +7 -5
  6. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +1 -1
  7. package/.claude/skills/live-tokens-create-component/references/token-naming.md +50 -0
  8. package/.claude/skills/live-tokens-generate-theme/SKILL.md +16 -9
  9. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +8 -6
  10. package/.claude/skills/live-tokens-pick-component/SKILL.md +19 -5
  11. package/CHANGELOG.md +114 -0
  12. package/bin/migrate.mjs +6 -2
  13. package/dist-plugin/adjust/index.cjs +1 -1
  14. package/dist-plugin/adjust/index.d.cts +1 -1
  15. package/dist-plugin/adjust/index.d.ts +1 -1
  16. package/dist-plugin/adjust/index.js +1 -1
  17. package/dist-plugin/{chunk-232GZGQU.js → chunk-NDJJORKJ.js} +342 -5
  18. package/dist-plugin/{chunk-Y5CNFSSV.js → chunk-RVE3MNKM.js} +1 -1
  19. package/dist-plugin/{chunk-OIOXU7FR.js → chunk-ZHPX7ZYQ.js} +83 -25
  20. package/dist-plugin/{dataPaths-CRfD1LdA.d.ts → dataPaths-DZUzVv8H.d.cts} +3 -3
  21. package/dist-plugin/{dataPaths-CRfD1LdA.d.cts → dataPaths-DZUzVv8H.d.ts} +3 -3
  22. package/dist-plugin/fontPairing/index.cjs +1 -1
  23. package/dist-plugin/fontPairing/index.d.cts +1 -1
  24. package/dist-plugin/fontPairing/index.d.ts +1 -1
  25. package/dist-plugin/fontPairing/index.js +1 -1
  26. package/dist-plugin/generateColorsAndType/index.cjs +1 -1
  27. package/dist-plugin/generateColorsAndType/index.d.cts +1 -1
  28. package/dist-plugin/generateColorsAndType/index.d.ts +1 -1
  29. package/dist-plugin/generateColorsAndType/index.js +1 -1
  30. package/dist-plugin/index.cjs +461 -58
  31. package/dist-plugin/index.d.cts +1 -1
  32. package/dist-plugin/index.d.ts +1 -1
  33. package/dist-plugin/index.js +31 -23
  34. package/dist-plugin/migrateData/index.cjs +350 -9
  35. package/dist-plugin/migrateData/index.d.cts +1 -1
  36. package/dist-plugin/migrateData/index.d.ts +1 -1
  37. package/dist-plugin/migrateData/index.js +9 -5
  38. package/dist-plugin/tokensCssMigrations/index.cjs +83 -25
  39. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  40. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  41. package/dist-plugin/tokensCssMigrations/index.js +2 -2
  42. package/package.json +4 -2
  43. package/src/app/site.css +32 -0
  44. package/src/editor/component-editor/CardEditor.svelte +7 -1
  45. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +25 -2
  46. package/src/editor/core/preview/lookPreview.ts +46 -5
  47. package/src/editor/core/productionPulse.ts +6 -2
  48. package/src/editor/core/sketch/maskField.ts +75 -39
  49. package/src/editor/core/sketch/sketchLayer.ts +32 -11
  50. package/src/editor/core/sketch/sketchStore.ts +311 -86
  51. package/src/editor/core/sketch/{sketchPresetService.ts → sketchStyleService.ts} +14 -14
  52. package/src/editor/core/sketch/{sketchPresets.ts → sketchStyles.ts} +27 -28
  53. package/src/editor/core/themes/themeDocumentSync.ts +2 -0
  54. package/src/editor/core/themes/themeInit.ts +19 -1
  55. package/src/editor/core/themes/themeService.ts +7 -2
  56. package/src/editor/core/themes/themeTypes.ts +5 -0
  57. package/src/editor/docs/content/editing-tokens.md +1 -1
  58. package/src/editor/docs/content/sketch-mode.md +34 -18
  59. package/src/editor/docs/content/themes-workflow.md +30 -21
  60. package/src/editor/docs/content/where-themes-live.md +9 -5
  61. package/src/editor/docs/content.generated.ts +4 -4
  62. package/src/editor/overlay/LiveTokensRouter.svelte +8 -0
  63. package/src/editor/ui/EditorViewSwitcher.svelte +3 -3
  64. package/src/editor/ui/ThemePanel.svelte +47 -1
  65. package/src/editor/ui/sections/textStyles.ts +29 -1
  66. package/src/editor/ui/sketch/SketchPreview.svelte +3 -3
  67. package/src/editor/ui/sketch/SketchTab.svelte +120 -134
  68. package/src/live-tokens/data/colors-and-type/midnight-study.json +40 -30
  69. package/src/live-tokens/data/themes/autumn.json +3 -3
  70. package/src/live-tokens/data/themes/halloween.json +3 -3
  71. package/src/live-tokens/data/themes/midnight-study.json +61 -49
  72. package/src/live-tokens/data/themes/ocean.json +3 -3
  73. package/src/live-tokens/data/themes/royal-velvet.json +3 -3
  74. package/src/live-tokens/data/themes/sketchy.json +3 -3
  75. package/src/live-tokens/data/themes/spring-meadow.json +3 -3
  76. package/src/live-tokens/data/themes/sunset.json +3 -3
  77. package/src/system/components/Card.svelte +27 -9
  78. package/src/system/components/FloatingTokenTags.css +10 -8
  79. package/src/system/components/ImageLightbox.svelte +5 -2
  80. package/src/system/components/SectionDivider.svelte +3 -3
  81. package/src/system/components/SegmentedControl.svelte +11 -9
  82. package/src/system/styles/tokens.css +28 -5
  83. package/template/src/pages/Home.svelte +1 -11
@@ -4,26 +4,29 @@
4
4
  import UISegmentedControl from '../UISegmentedControl.svelte';
5
5
  import SketchDial from './SketchDial.svelte';
6
6
  import SketchRange from './SketchRange.svelte';
7
- import { buildMaskUri, MASK_TILE } from '../../core/sketch/maskField';
7
+ import { buildMaskUri } from '../../core/sketch/maskField';
8
8
  import SketchPreview from './SketchPreview.svelte';
9
9
  import { onMount } from 'svelte';
10
10
  import UIPillButton from '../UIPillButton.svelte';
11
11
  import UIReveal, { REVEAL_MS } from '../UIReveal.svelte';
12
12
  import { scrollSectionIntoView } from '../scrollSection';
13
- import { SKETCH_PRESETS } from '../../core/sketch/sketchPresets';
13
+ import { SKETCH_STYLES } from '../../core/sketch/sketchStyles';
14
+ import { openThemeSlug } from '../../core/store/editorConfigStore';
14
15
  import {
15
16
  sketchEnabled,
16
- sketchPreset,
17
+ setSketchEnabled,
18
+ sketchOffLook,
19
+ sketchStyleName,
17
20
  sketchDirty,
18
21
  sketchSettings,
19
- selectSketchPreset,
22
+ selectSketchStyle,
20
23
  updateSketchSettings,
21
- userSketchPresets,
22
- refreshUserPresets,
23
- selectUserSketchPreset,
24
- saveCurrentAsSketchPreset,
25
- deleteUserSketchPreset,
26
- USER_PRESET_PREFIX,
24
+ savedSketchStyles,
25
+ refreshSavedSketchStyles,
26
+ selectSavedSketchStyle,
27
+ saveCurrentSketchStyle,
28
+ deleteSavedSketchStyle,
29
+ USER_STYLE_PREFIX,
27
30
  } from '../../core/sketch/sketchStore';
28
31
 
29
32
  interface Props {
@@ -37,6 +40,11 @@
37
40
 
38
41
  let s = $derived($sketchSettings);
39
42
 
43
+ // Save is disabled for the protected Default theme (ThemePanel.svelte);
44
+ // Save As is the gesture actually on offer there, so the off-look copy has
45
+ // to name it instead.
46
+ let activeIsProtected = $derived($openThemeSlug === 'default');
47
+
40
48
  type SectionKey = 'border' | 'fill' | 'shape' | 'icons' | 'noise';
41
49
 
42
50
  const SECTION_KEYS: SectionKey[] = ['border', 'fill', 'shape', 'icons', 'noise'];
@@ -81,8 +89,8 @@
81
89
  const ROUGHNESS = ['smooth', 'grainy', 'rough'] as const;
82
90
 
83
91
  /** A layer's own wavelength: the multiple, and the length it resolves to
84
- against the shared wave. The ratio is what a preset carries; the pixels
85
- are what you can picture. */
92
+ against the shared wave. The ratio is what a sketchstyle carries; the
93
+ pixels are what you can picture. */
86
94
  const against = (multiple: number) => `${Math.round(s.wobble * multiple)}px`;
87
95
 
88
96
  /** The wave's shape, named rather than numbered: 1 is the field as the filter
@@ -110,52 +118,52 @@
110
118
  + (s.waveform > 1 ? ` · ${waveformLabel(s.waveform)}` : ''),
111
119
  });
112
120
 
113
- /** Shipped and saved presets are one choice, so they share one radio group:
114
- picking a saved one clears the shipped selection natively. */
115
- const PRESET_RADIO_GROUP = 'sketch-preset';
121
+ /** Shipped and saved sketchstyles are one choice, so they share one radio
122
+ group: picking a saved one clears the shipped selection natively. */
123
+ const STYLE_RADIO_GROUP = 'sketchstyle';
116
124
 
117
125
  /** What the readout under the grid says. The selection survives a dial move,
118
126
  so the base keeps its name here and the blurb reports the drift instead. */
119
127
  let active = $derived(
120
128
  $sketchDirty
121
129
  ? { name: s.label, blurb: `Modified from ${s.label}. Save it to keep it.` }
122
- : { name: s.label, blurb: s.blurb || 'Your saved preset.' },
130
+ : { name: s.label, blurb: s.blurb || 'Your saved sketchstyle.' },
123
131
  );
124
132
 
125
133
  let naming = $state(false);
126
134
  let draftName = $state('');
127
- let presetError = $state('');
135
+ let styleError = $state('');
128
136
 
129
137
  onMount(() => {
130
- // No dev plugin (a built preview, say) means no saved presets. That is a
131
- // missing door, not a fault worth reporting in the tab.
132
- refreshUserPresets().catch(() => {});
138
+ // No dev plugin (a built preview, say) means no saved sketchstyles. That
139
+ // is a missing door, not a fault worth reporting in the tab.
140
+ refreshSavedSketchStyles().catch(() => {});
133
141
  });
134
142
 
135
143
  function startNaming() {
136
- draftName = s.label && $sketchPreset.startsWith(USER_PRESET_PREFIX) ? s.label : '';
137
- presetError = '';
144
+ draftName = s.label && $sketchStyleName.startsWith(USER_STYLE_PREFIX) ? s.label : '';
145
+ styleError = '';
138
146
  naming = true;
139
147
  }
140
148
 
141
149
  async function commitSave(e: SubmitEvent) {
142
150
  e.preventDefault();
143
151
  try {
144
- await saveCurrentAsSketchPreset(draftName);
152
+ await saveCurrentSketchStyle(draftName);
145
153
  naming = false;
146
154
  draftName = '';
147
- presetError = '';
155
+ styleError = '';
148
156
  } catch (err) {
149
- presetError = err instanceof Error ? err.message : 'Save failed';
157
+ styleError = err instanceof Error ? err.message : 'Save failed';
150
158
  }
151
159
  }
152
160
 
153
- async function runPresetAction(action: Promise<unknown>) {
161
+ async function runStyleAction(action: Promise<unknown>) {
154
162
  try {
155
163
  await action;
156
- presetError = '';
164
+ styleError = '';
157
165
  } catch (err) {
158
- presetError = err instanceof Error ? err.message : 'That did not work';
166
+ styleError = err instanceof Error ? err.message : 'That did not work';
159
167
  }
160
168
  }
161
169
 
@@ -191,7 +199,12 @@
191
199
  ceiling is a few blobs across a card, which is where it still reads as
192
200
  uneven ink. */
193
201
  const SIZE = { min: 20, max: 250 };
194
- const BLUR_MAX = 8;
202
+ const BLUR_MAX = 64;
203
+ /** Squared response. A blur only reads as a different blur while it is small,
204
+ so a linear walk to 64px would spend a tenth of the dial on everything
205
+ legible and the rest sliding between shades of gone. */
206
+ const blurPos = (px: number) => Math.round(Math.sqrt(px / BLUR_MAX) * 100);
207
+ const blurVal = (t: number) => Math.round(BLUR_MAX * (t / 100) ** 2 * 4) / 4;
195
208
 
196
209
  /** Tones the field is flattened into, in the order the dial walks them:
197
210
  smooth, then coarsening all the way down to two. The stored value is the
@@ -213,36 +226,31 @@
213
226
  let outputMin = $derived(Math.round(s.maskOutputMin * 100));
214
227
  let outputMax = $derived(Math.round(s.maskOutputMax * 100));
215
228
  let stepPos = $derived(Math.max(0, STEP_TONES.indexOf(s.maskPosterize)));
229
+ let blurStep = $derived(blurPos(s.maskSoftness));
216
230
  const setOutput = (min: number, max: number) =>
217
231
  updateSketchSettings({ maskOutputMin: min / 100, maskOutputMax: max / 100 });
218
232
 
219
- // The same stages the fill filter runs, painted at true pixel size over a
220
- // sample card, so blob size here is blob size on the components and the
221
- // card's shadow is always on show: coverage that ate it would be a bug you
222
- // saw at once.
233
+ // The finished field, the last stage and the one the components get.
223
234
  let maskPreview = $derived(buildMaskUri(s));
224
- // The field itself after each stage, white for ink.
225
- let stageTiles = $derived(
226
- (['noise', 'output', 'blur'] as const).map((stage) => ({ stage, uri: buildMaskUri(s, 9, stage) })),
227
- );
228
235
  </script>
229
236
 
230
237
  <div class="sketch-tab">
231
238
  <header class="head">
232
239
  <div class="head-row">
233
- <h2 class="section-title">Sketch</h2>
240
+ <h2 class="section-title">Sketchstyle</h2>
234
241
  <div class="switch">
235
242
  <Toggle
236
243
  label="Sketch mode"
237
244
  labelFirst
238
245
  checked={$sketchEnabled}
239
- onchange={(v) => sketchEnabled.set(v)}
246
+ onchange={setSketchEnabled}
240
247
  />
241
248
  <UIInfoPopover title="Sketch mode">
242
249
  <p>
243
- An effect layer over the active theme. It reads no theme values and writes none:
244
- each component's fill and outline are redrawn from the tokens that component
245
- already owns, then pushed around a shared noise field.
250
+ An effect layer over the active theme. It repaints each component's fill and
251
+ outline from the tokens that component already owns, then pushes them around a
252
+ shared noise field. Save in the Theme panel folds the dials into the open theme,
253
+ or Save As when the open theme is Motion Proto, the read-only built-in one.
246
254
  </p>
247
255
  <p>
248
256
  While it is on, the effect applies to the page behind this editor as well as to the
@@ -256,28 +264,46 @@
256
264
  <div class="readout" aria-live="polite">
257
265
  <span class="readout-name">{active.name}</span>
258
266
  <p class="readout-blurb">{active.blurb}</p>
267
+ {#if $sketchOffLook}
268
+ {#if $sketchEnabled}
269
+ <p class="readout-off-look">
270
+ {#if activeIsProtected}
271
+ Motion Proto is read-only, and these dials are ahead of it. Use Save As in the
272
+ Theme panel to keep them in a new theme.
273
+ {:else}
274
+ These dials are ahead of the saved theme. Save it in the Theme panel to fold them
275
+ in.
276
+ {/if}
277
+ </p>
278
+ {:else}
279
+ <p class="readout-off-look">
280
+ The theme carries a sketchstyle this page is not painting. Saving now would drop
281
+ it.
282
+ </p>
283
+ {/if}
284
+ {/if}
259
285
  </div>
260
286
 
261
287
  <div class="presets">
262
- {#each Object.entries(SKETCH_PRESETS) as [name, preset] (name)}
288
+ {#each Object.entries(SKETCH_STYLES) as [name, style] (name)}
263
289
  <label class="preset">
264
290
  <input
265
291
  type="radio"
266
- name={PRESET_RADIO_GROUP}
292
+ name={STYLE_RADIO_GROUP}
267
293
  value={name}
268
- checked={$sketchPreset === name}
269
- onchange={() => selectSketchPreset(name)}
294
+ checked={$sketchStyleName === name}
295
+ onchange={() => selectSketchStyle(name)}
270
296
  />
271
- <span class="preset-name">{preset.label}</span>
297
+ <span class="preset-name">{style.label}</span>
272
298
  </label>
273
299
  {/each}
274
300
  </div>
275
301
 
276
302
  <div class="saved">
277
303
  <div class="saved-head">
278
- <span class="band-label">Saved</span>
304
+ <span class="band-label">Saved sketchstyles</span>
279
305
  <UIPillButton size="compact" icon="fa-floppy-disk" onclick={startNaming}>
280
- Save current
306
+ Save as sketchstyle…
281
307
  </UIPillButton>
282
308
  </div>
283
309
 
@@ -287,8 +313,8 @@
287
313
  <input
288
314
  class="save-name"
289
315
  bind:value={draftName}
290
- placeholder="Preset name"
291
- aria-label="Preset name"
316
+ placeholder="Sketchstyle name"
317
+ aria-label="Sketchstyle name"
292
318
  autofocus
293
319
  />
294
320
  <UIPillButton size="compact" variant="primary" type="submit">Save</UIPillButton>
@@ -296,17 +322,17 @@
296
322
  </form>
297
323
  {/if}
298
324
 
299
- {#if $userSketchPresets.length > 0}
325
+ {#if $savedSketchStyles.length > 0}
300
326
  <div class="presets">
301
- {#each $userSketchPresets as saved (saved.fileName)}
327
+ {#each $savedSketchStyles as saved (saved.fileName)}
302
328
  <div class="saved-item">
303
329
  <label class="preset">
304
330
  <input
305
331
  type="radio"
306
- name={PRESET_RADIO_GROUP}
307
- value={USER_PRESET_PREFIX + saved.fileName}
308
- checked={$sketchPreset === USER_PRESET_PREFIX + saved.fileName}
309
- onchange={() => runPresetAction(selectUserSketchPreset(saved.fileName))}
332
+ name={STYLE_RADIO_GROUP}
333
+ value={USER_STYLE_PREFIX + saved.fileName}
334
+ checked={$sketchStyleName === USER_STYLE_PREFIX + saved.fileName}
335
+ onchange={() => runStyleAction(selectSavedSketchStyle(saved.fileName))}
310
336
  />
311
337
  <span class="preset-name">{saved.name}</span>
312
338
  </label>
@@ -315,15 +341,15 @@
315
341
  class="saved-delete"
316
342
  title="Delete {saved.name}"
317
343
  aria-label="Delete {saved.name}"
318
- onclick={() => runPresetAction(deleteUserSketchPreset(saved.fileName))}
344
+ onclick={() => runStyleAction(deleteSavedSketchStyle(saved.fileName))}
319
345
  ><i class="fas fa-xmark"></i></button>
320
346
  </div>
321
347
  {/each}
322
348
  </div>
323
349
  {/if}
324
350
 
325
- {#if presetError}
326
- <p class="saved-error">{presetError}</p>
351
+ {#if styleError}
352
+ <p class="saved-error">{styleError}</p>
327
353
  {/if}
328
354
  </div>
329
355
  </div>
@@ -538,21 +564,7 @@
538
564
  </div>
539
565
 
540
566
  <figure class="scope" class:muted={!s.maskOn}>
541
- <div class="sample">
542
- <div
543
- class="ink"
544
- style:mask-image={maskPreview}
545
- style:mask-size="{MASK_TILE}px {MASK_TILE}px"
546
- ></div>
547
- </div>
548
- <div class="stages">
549
- {#each stageTiles as { stage, uri } (stage)}
550
- <figure>
551
- <div class="tile" style:mask-image={uri} style:mask-size="{MASK_TILE}px {MASK_TILE}px"></div>
552
- <figcaption>{stage}</figcaption>
553
- </figure>
554
- {/each}
555
- </div>
567
+ <div class="field" style:mask-image={maskPreview}></div>
556
568
  </figure>
557
569
 
558
570
  <h5>Noise</h5>
@@ -580,10 +592,13 @@
580
592
  onchange={(v) => updateSketchSettings({ maskOctaves: v })}
581
593
  />
582
594
 
583
- <h5>Output</h5>
595
+ <h5>Levels</h5>
584
596
  <p class="group-note">
585
- How much ink the field lays down. The low handle is the palest the fill
586
- gets and the high handle the densest: 0 is bare, 100 is whole.
597
+ The field runs black to white before these apply. Output states the
598
+ range of ink the mask paints between, 0 bare to 100 whole, and the
599
+ whole field is squeezed into it: raise the low handle and the
600
+ darkest patch rises with it, drop the high handle and the densest
601
+ falls. Nothing is ever cut off at a handle.
587
602
  </p>
588
603
  <SketchRange
589
604
  label="Output" low={outputMin} high={outputMax} min={0} max={100} step={1}
@@ -602,10 +617,11 @@
602
617
 
603
618
  <h5>Blur</h5>
604
619
  <SketchDial
605
- label="Blur" value={s.maskSoftness} min={0} max={BLUR_MAX} step={0.25}
620
+ label="Blur" value={blurStep} min={0} max={100} step={1}
606
621
  readout={px(s.maskSoftness)}
607
- hint="Softens the blotch edges. Few tones with a little blur give a hard blotch with a soft rim."
608
- onchange={(v) => updateSketchSettings({ maskSoftness: v })}
622
+ ends={['hard', 'clouded']}
623
+ hint="Softens the blotch edges. Few tones with a little blur give a hard blotch with a soft rim; wound right up the field goes to weather."
624
+ onchange={(v) => updateSketchSettings({ maskSoftness: blurVal(v) })}
609
625
  />
610
626
  </div>
611
627
  </div>
@@ -804,9 +820,9 @@
804
820
  }
805
821
 
806
822
 
807
- /* Grid, saved band and readout are one control: which preset is on, and what
808
- that preset is. Splitting them left the description reading as a stray
809
- caption under the tab. */
823
+ /* Grid, saved band and readout are one control: which sketchstyle is on, and
824
+ what that sketchstyle is. Splitting them left the description reading as a
825
+ stray caption under the tab. */
810
826
  .picker {
811
827
  display: flex;
812
828
  flex-direction: column;
@@ -999,6 +1015,13 @@
999
1015
  color: var(--ui-text-secondary);
1000
1016
  }
1001
1017
 
1018
+ .readout-off-look {
1019
+ margin: var(--ui-space-2) 0 0;
1020
+ font-size: var(--ui-font-size-sm);
1021
+ font-weight: var(--ui-font-weight-medium);
1022
+ color: var(--ui-text-primary);
1023
+ }
1024
+
1002
1025
  .dormant {
1003
1026
  margin: 0;
1004
1027
  font-size: var(--ui-font-size-sm);
@@ -1196,69 +1219,32 @@
1196
1219
  pointer-events: none;
1197
1220
  }
1198
1221
 
1222
+ /* The ground the field is cut out of, so a masked-away pixel reads as black
1223
+ rather than as the panel. */
1199
1224
  .scope {
1200
1225
  margin: 0;
1201
- display: flex;
1202
- flex-direction: column;
1203
- gap: var(--ui-space-4);
1204
- transition: opacity var(--ui-transition-base);
1205
- }
1206
-
1207
- .scope.muted {
1208
- opacity: 0.4;
1209
- }
1210
-
1211
- /* The shadow sits on the card and the mask on the fill inside it: the same
1212
- split the fill layer makes, so what this shows is what the page does. */
1213
- .sample {
1214
- height: 6rem;
1215
- padding: var(--ui-space-16) var(--ui-space-24);
1216
1226
  border: 1px solid var(--ui-border-low);
1217
1227
  border-radius: var(--ui-radius-sm);
1218
1228
  background: var(--ui-surface-lowest);
1219
1229
  overflow: hidden;
1230
+ transition: opacity var(--ui-transition-base);
1220
1231
  }
1221
1232
 
1222
- .ink {
1223
- box-shadow: var(--ui-shadow-md);
1224
- }
1225
-
1226
- .stages {
1227
- display: grid;
1228
- grid-template-columns: repeat(3, 1fr);
1229
- gap: var(--ui-space-8);
1230
- }
1231
-
1232
- .stages figure {
1233
- margin: 0;
1233
+ .scope.muted {
1234
+ opacity: 0.4;
1234
1235
  }
1235
1236
 
1236
- .tile {
1237
- height: var(--ui-space-48);
1238
- border-radius: var(--ui-radius-sm);
1237
+ /* One readout of the finished field: the whole tile, repeated across the
1238
+ strip, white on that ground so 0 reads as black and 100 as white. Neither
1239
+ was readable before. The field was painted at page-px size, which showed a
1240
+ crop off the top of the tile that never reached the bright end, over an ink
1241
+ colour that could not have painted it if it had. */
1242
+ .field {
1243
+ height: 7rem;
1239
1244
  background: var(--ui-text-primary);
1240
1245
  mask-mode: luminance;
1241
1246
  mask-repeat: repeat;
1242
- }
1243
-
1244
- .stages figcaption {
1245
- margin-top: var(--ui-space-2);
1246
- font-size: var(--ui-font-size-xs);
1247
- text-align: center;
1248
- text-transform: uppercase;
1249
- letter-spacing: 0.06em;
1250
- color: var(--ui-text-tertiary);
1251
- }
1252
-
1253
- .ink {
1254
- height: 100%;
1255
- border-radius: var(--ui-radius-md);
1256
- /* An ink colour, not a surface one: the point of the swatch is the
1257
- blotches, and the surface rungs sit within a shade or two of the ground
1258
- under them, which showed the finished field as a flat dark rectangle. */
1259
- background: var(--ui-text-muted);
1260
- mask-mode: luminance;
1261
- mask-repeat: repeat;
1247
+ mask-size: contain;
1262
1248
  }
1263
1249
 
1264
1250
  </style>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "Midnight Study",
3
- "createdAt": "2026-08-12T20:13:46.264Z",
4
- "updatedAt": "2026-08-13T14:14:20.015Z",
3
+ "createdAt": "2026-08-27T17:01:37.984Z",
4
+ "updatedAt": "2026-08-27T17:01:37.984Z",
5
5
  "editorConfigs": {
6
6
  "Brand": {
7
7
  "baseColor": {
@@ -2262,10 +2262,6 @@
2262
2262
  }
2263
2263
  },
2264
2264
  "cssVariables": {
2265
- "--gradient-1": "linear-gradient(90deg, var(--color-brand-400) 0%, var(--color-brand-700) 100%)",
2266
- "--gradient-2": "linear-gradient(135deg, var(--color-special-500) 0%, var(--color-brand-500) 100%)",
2267
- "--gradient-3": "linear-gradient(90deg, var(--color-brand-500) 0%, var(--color-accent-500) 100%)",
2268
- "--gradient-4": "linear-gradient(180deg, var(--color-canvas-600) 0%, var(--color-canvas-900) 100%)",
2269
2265
  "--badge-trait-surface": "var(--surface-brand)",
2270
2266
  "--badge-trait-text": "var(--text-brand)",
2271
2267
  "--badge-trait-text-font-family": "var(--font-sans)",
@@ -2316,7 +2312,15 @@
2316
2312
  "--shadow-md": "3px 3px 6px hsla(237, 18%, 3%, 0.9)",
2317
2313
  "--shadow-lg": "5px 5px 9px hsla(237, 18%, 3%, 0.9)",
2318
2314
  "--shadow-xl": "6px 7px 13px hsla(237, 18%, 3%, 0.9)",
2319
- "--shadow-2xl": "8px 9px 16px hsla(237, 18%, 3%, 0.9)"
2315
+ "--shadow-2xl": "8px 9px 16px hsla(237, 18%, 3%, 0.9)",
2316
+ "--gradient-1": "linear-gradient(90deg, var(--color-brand-400) 0%, var(--color-brand-700) 100%)",
2317
+ "--gradient-1-stops": "var(--color-brand-400) 0%, var(--color-brand-700) 100%",
2318
+ "--gradient-2": "linear-gradient(135deg, var(--color-special-500) 0%, var(--color-brand-500) 100%)",
2319
+ "--gradient-2-stops": "var(--color-special-500) 0%, var(--color-brand-500) 100%",
2320
+ "--gradient-3": "linear-gradient(90deg, var(--color-brand-500) 0%, var(--color-accent-500) 100%)",
2321
+ "--gradient-3-stops": "var(--color-brand-500) 0%, var(--color-accent-500) 100%",
2322
+ "--gradient-4": "linear-gradient(180deg, color-mix(in srgb, var(--color-canvas-600) 49%, transparent) 0%, var(--color-canvas-850) 14.2%, var(--color-canvas-900) 83.8%)",
2323
+ "--gradient-4-stops": "color-mix(in srgb, var(--color-canvas-600) 49%, transparent) 0%, var(--color-canvas-850) 14.2%, var(--color-canvas-900) 83.8%"
2320
2324
  },
2321
2325
  "fontSources": [
2322
2326
  {
@@ -2469,25 +2473,6 @@
2469
2473
  ]
2470
2474
  }
2471
2475
  ],
2472
- "harmonyAxes": [
2473
- {
2474
- "hue": 300,
2475
- "family": "Brand"
2476
- },
2477
- {
2478
- "hue": 340,
2479
- "family": "Accent"
2480
- },
2481
- {
2482
- "hue": 268,
2483
- "family": "Canvas"
2484
- },
2485
- {
2486
- "hue": 210,
2487
- "family": null
2488
- }
2489
- ],
2490
- "schemaVersion": 3,
2491
2476
  "gradients": [
2492
2477
  {
2493
2478
  "variable": "--gradient-1",
@@ -2541,13 +2526,38 @@
2541
2526
  "stops": [
2542
2527
  {
2543
2528
  "position": 0,
2544
- "color": "--color-canvas-600"
2529
+ "color": "--color-canvas-600",
2530
+ "opacity": 49
2545
2531
  },
2546
2532
  {
2547
- "position": 100,
2533
+ "position": 14.2,
2534
+ "color": "--color-canvas-850",
2535
+ "opacity": 100
2536
+ },
2537
+ {
2538
+ "position": 83.8,
2548
2539
  "color": "--color-canvas-900"
2549
2540
  }
2550
2541
  ]
2551
2542
  }
2552
- ]
2553
- }
2543
+ ],
2544
+ "harmonyAxes": [
2545
+ {
2546
+ "hue": 300,
2547
+ "family": "Brand"
2548
+ },
2549
+ {
2550
+ "hue": 340,
2551
+ "family": "Accent"
2552
+ },
2553
+ {
2554
+ "hue": 268,
2555
+ "family": "Canvas"
2556
+ },
2557
+ {
2558
+ "hue": 210,
2559
+ "family": null
2560
+ }
2561
+ ],
2562
+ "schemaVersion": 5
2563
+ }
@@ -3530,7 +3530,7 @@
3530
3530
  "--sectiondivider-lg-description": "--text-alternate",
3531
3531
  "--sectiondivider-lg-eyebrow": "--text-brand",
3532
3532
  "--sectiondivider-lg-border": "--color-transparent",
3533
- "--sectiondivider-lg-title-outline-color": "--surface-canvas-lowest",
3533
+ "--sectiondivider-lg-title-outline-color": "transparent",
3534
3534
  "--sectiondivider-lg-hairline-color": "--border-brand-medium",
3535
3535
  "--sectiondivider-md-title-font-family": "--font-display",
3536
3536
  "--sectiondivider-md-title-font-weight": "--font-weight-bold",
@@ -3559,7 +3559,7 @@
3559
3559
  "--sectiondivider-md-description": "--text-secondary",
3560
3560
  "--sectiondivider-md-eyebrow": "--text-tertiary",
3561
3561
  "--sectiondivider-md-border": "--color-transparent",
3562
- "--sectiondivider-md-title-outline-color": "--surface-accent-lowest",
3562
+ "--sectiondivider-md-title-outline-color": "transparent",
3563
3563
  "--sectiondivider-md-hairline-color": "--border-brand-medium",
3564
3564
  "--sectiondivider-sm-title-font-family": "--font-display",
3565
3565
  "--sectiondivider-sm-title-font-weight": "--font-weight-medium",
@@ -3588,7 +3588,7 @@
3588
3588
  "--sectiondivider-sm-description": "--text-secondary",
3589
3589
  "--sectiondivider-sm-eyebrow": "--text-tertiary",
3590
3590
  "--sectiondivider-sm-border": "--color-transparent",
3591
- "--sectiondivider-sm-title-outline-color": "color-mix(in srgb, var(--surface-neutral-lowest) 25%, transparent)",
3591
+ "--sectiondivider-sm-title-outline-color": "transparent",
3592
3592
  "--sectiondivider-sm-hairline-color": "color-mix(in srgb, var(--border-brand-medium) 50%, transparent)"
3593
3593
  }
3594
3594
  },
@@ -3569,7 +3569,7 @@
3569
3569
  "--sectiondivider-lg-description": "--text-alternate",
3570
3570
  "--sectiondivider-lg-eyebrow": "--text-brand",
3571
3571
  "--sectiondivider-lg-border": "--color-transparent",
3572
- "--sectiondivider-lg-title-outline-color": "--surface-canvas-lowest",
3572
+ "--sectiondivider-lg-title-outline-color": "transparent",
3573
3573
  "--sectiondivider-lg-hairline-color": "--border-brand-medium",
3574
3574
  "--sectiondivider-md-title-font-family": "--font-display",
3575
3575
  "--sectiondivider-md-title-font-weight": "--font-weight-bold",
@@ -3598,7 +3598,7 @@
3598
3598
  "--sectiondivider-md-description": "--text-secondary",
3599
3599
  "--sectiondivider-md-eyebrow": "--text-tertiary",
3600
3600
  "--sectiondivider-md-border": "--color-transparent",
3601
- "--sectiondivider-md-title-outline-color": "--surface-accent-lowest",
3601
+ "--sectiondivider-md-title-outline-color": "transparent",
3602
3602
  "--sectiondivider-md-hairline-color": "--border-brand-medium",
3603
3603
  "--sectiondivider-sm-title-font-family": "--font-display",
3604
3604
  "--sectiondivider-sm-title-font-weight": "--font-weight-medium",
@@ -3627,7 +3627,7 @@
3627
3627
  "--sectiondivider-sm-description": "--text-secondary",
3628
3628
  "--sectiondivider-sm-eyebrow": "--text-tertiary",
3629
3629
  "--sectiondivider-sm-border": "--color-transparent",
3630
- "--sectiondivider-sm-title-outline-color": "color-mix(in srgb, var(--surface-neutral-lowest) 25%, transparent)",
3630
+ "--sectiondivider-sm-title-outline-color": "transparent",
3631
3631
  "--sectiondivider-sm-hairline-color": "color-mix(in srgb, var(--border-brand-medium) 50%, transparent)"
3632
3632
  }
3633
3633
  },