@motion-proto/live-tokens 0.67.1 → 0.69.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.
- package/.claude/skills/live-tokens-build-page/SKILL.md +16 -5
- package/.claude/skills/live-tokens-create-component/SKILL.md +43 -8
- package/.claude/skills/live-tokens-create-component/references/token-naming.md +30 -1
- package/.claude/skills/live-tokens-fix-findings/SKILL.md +131 -0
- package/.claude/skills/live-tokens-pick-component/SKILL.md +2 -1
- package/CHANGELOG.md +239 -0
- package/README.md +11 -4
- package/bin/check-component.mjs +367 -63
- package/bin/check-page.mjs +409 -0
- package/bin/cli.mjs +57 -8
- package/bin/generate-theme.mjs +5 -4
- package/bin/lib/cssValues.mjs +50 -0
- package/bin/lib/findings.mjs +106 -0
- package/bin/lib/tokenVocabulary.mjs +213 -0
- package/dist-plugin/adjust/index.cjs +174 -23
- package/dist-plugin/adjust/index.d.cts +1 -1
- package/dist-plugin/adjust/index.d.ts +1 -1
- package/dist-plugin/adjust/index.js +68 -23
- package/dist-plugin/{chunk-J2JT4UEA.js → chunk-2YNERPXY.js} +26 -26
- package/dist-plugin/{chunk-NE6N66EE.js → chunk-GPIBU44G.js} +107 -1
- package/dist-plugin/{chunk-ZHPX7ZYQ.js → chunk-RFVYPNRO.js} +39 -1
- package/dist-plugin/{dataPaths-DZUzVv8H.d.ts → dataPaths-bJTCEO4H.d.cts} +1 -1
- package/dist-plugin/{dataPaths-DZUzVv8H.d.cts → dataPaths-bJTCEO4H.d.ts} +1 -1
- package/dist-plugin/fontPairing/index.d.cts +1 -1
- package/dist-plugin/fontPairing/index.d.ts +1 -1
- package/dist-plugin/generateColorsAndType/index.cjs +107 -1
- package/dist-plugin/generateColorsAndType/index.d.cts +1 -1
- package/dist-plugin/generateColorsAndType/index.d.ts +1 -1
- package/dist-plugin/generateColorsAndType/index.js +1 -1
- package/dist-plugin/index.cjs +173 -29
- package/dist-plugin/index.d.cts +1 -1
- package/dist-plugin/index.d.ts +1 -1
- package/dist-plugin/index.js +7 -7
- package/dist-plugin/migrateData/index.cjs +132 -26
- package/dist-plugin/migrateData/index.d.cts +1 -1
- package/dist-plugin/migrateData/index.d.ts +1 -1
- package/dist-plugin/migrateData/index.js +2 -2
- package/dist-plugin/tokensCssMigrations/index.cjs +39 -1
- package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
- package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
- package/dist-plugin/tokensCssMigrations/index.js +1 -1
- package/package.json +6 -5
- package/src/app/site.css +4 -4
- package/src/editor/bootstrap.ts +6 -6
- package/src/editor/component-editor/ButtonEditor.svelte +71 -4
- package/src/editor/component-editor/CardEditor.svelte +6 -6
- package/src/editor/component-editor/DialogEditor.svelte +3 -3
- package/src/editor/component-editor/IconButtonEditor.svelte +68 -3
- package/src/editor/component-editor/ImageEditor.svelte +8 -8
- package/src/editor/component-editor/ImageLightboxEditor.svelte +13 -2
- package/src/editor/component-editor/MenuSelectEditor.svelte +63 -3
- package/src/editor/component-editor/SegmentedControlEditor.svelte +63 -3
- package/src/editor/component-editor/SideNavigationEditor.svelte +67 -3
- package/src/editor/component-editor/SliderEditor.svelte +186 -0
- package/src/editor/component-editor/TabBarEditor.svelte +63 -3
- package/src/editor/component-editor/registry.ts +10 -0
- package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +4 -4
- package/src/editor/core/components/aliasKinds.ts +51 -28
- package/src/editor/core/fonts/fontLoader.ts +2 -2
- package/src/editor/core/preview/{lookPreview.ts → themePreview.ts} +27 -27
- package/src/editor/core/productionPulse.ts +3 -3
- package/src/editor/core/sketch/index.ts +52 -44
- package/src/editor/core/sketch/maskField.ts +9 -9
- package/src/editor/core/sketch/sketchLayer.ts +25 -9
- package/src/editor/core/sketch/sketchRegistry.ts +34 -34
- package/src/editor/core/sketch/sketchStore.ts +98 -83
- package/src/editor/core/sketch/sketchStyleService.ts +4 -4
- package/src/editor/core/sketch/sketchStyles.ts +28 -28
- package/src/editor/core/store/editorPersistence.ts +44 -1
- package/src/editor/core/store/editorRenderer.ts +2 -2
- package/src/editor/core/store/editorStore.ts +18 -18
- package/src/editor/core/store/editorTypes.ts +7 -6
- package/src/editor/core/themes/colorsAndTypeService.ts +1 -1
- package/src/editor/core/themes/loadRows.ts +8 -8
- package/src/editor/core/themes/migrations/2026-09-01-gate-suffix-enabled.ts +31 -0
- package/src/editor/core/themes/migrations/2026-09-01-scrim-rename.ts +52 -0
- package/src/editor/core/themes/migrations/2026-09-01-tabbar-active-tint.ts +27 -0
- package/src/editor/core/themes/migrations/2026-09-01-tint-rename.ts +42 -0
- package/src/editor/core/themes/migrations/index.ts +16 -0
- package/src/editor/core/themes/slices/domainVars.ts +2 -2
- package/src/editor/core/themes/slices/washes.ts +107 -0
- package/src/editor/core/themes/themeDocumentSync.ts +2 -2
- package/src/editor/core/themes/themeInit.ts +2 -2
- package/src/editor/core/themes/themeService.ts +17 -17
- package/src/editor/core/themes/{lookSummary.ts → themeSummary.ts} +9 -9
- package/src/editor/core/themes/themeTypes.ts +10 -6
- package/src/editor/docs/content/editing-tokens.md +5 -3
- package/src/editor/docs/content/getting-started.md +1 -1
- package/src/editor/docs/content/sketch-mode.md +22 -17
- package/src/editor/docs/content/themes-workflow.md +11 -11
- package/src/editor/docs/content/where-themes-live.md +6 -6
- package/src/editor/docs/content.generated.ts +5 -5
- package/src/editor/index.ts +2 -2
- package/src/editor/pages/EditorShell.svelte +1 -1
- package/src/editor/ui/SurfacesTab.svelte +3 -3
- package/src/editor/ui/ThemePanel.svelte +52 -52
- package/src/editor/ui/UITokenSelector.svelte +1 -0
- package/src/editor/ui/VariablesTab.svelte +2 -2
- package/src/editor/ui/sections/{OverlaysSection.svelte → WashesSection.svelte} +44 -43
- package/src/editor/ui/sketch/SketchPreview.svelte +2 -2
- package/src/editor/ui/sketch/SketchTab.svelte +64 -38
- package/src/live-tokens/data/colors-and-type/autumn.json +6 -6
- package/src/live-tokens/data/colors-and-type/default.json +6 -6
- package/src/live-tokens/data/colors-and-type/halloween.json +6 -6
- package/src/live-tokens/data/colors-and-type/midnight-study.json +6 -6
- package/src/live-tokens/data/colors-and-type/ocean.json +6 -6
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +6 -6
- package/src/live-tokens/data/colors-and-type/sketchy.json +6 -6
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +6 -6
- package/src/live-tokens/data/colors-and-type/sunset.json +6 -6
- package/src/live-tokens/data/sketch-styles/dry.json +5 -5
- package/src/live-tokens/data/sketch-styles/hatched.json +9 -9
- package/src/live-tokens/data/themes/autumn.json +82 -14
- package/src/live-tokens/data/themes/halloween.json +82 -14
- package/src/live-tokens/data/themes/midnight-study.json +82 -14
- package/src/live-tokens/data/themes/ocean.json +82 -14
- package/src/live-tokens/data/themes/royal-velvet.json +82 -14
- package/src/live-tokens/data/themes/sketchy.json +82 -14
- package/src/live-tokens/data/themes/spring-meadow.json +82 -14
- package/src/live-tokens/data/themes/sunset.json +82 -14
- package/src/live-tokens/data/tokens.generated.css +6 -6
- package/src/system/backdrop/backdrop.ts +1 -1
- package/src/system/components/Button.svelte +28 -10
- package/src/system/components/Card.svelte +6 -6
- package/src/system/components/Dialog.svelte +3 -3
- package/src/system/components/IconButton.svelte +23 -7
- package/src/system/components/Image.svelte +6 -6
- package/src/system/components/MenuSelect.svelte +17 -1
- package/src/system/components/SectionDivider.svelte +1 -1
- package/src/system/components/SegmentedControl.svelte +20 -2
- package/src/system/components/SideNavigation.svelte +22 -2
- package/src/system/components/Slider.svelte +348 -0
- package/src/system/components/TabBar.svelte +20 -2
- package/src/system/styles/CONVENTIONS.md +2 -2
- package/src/system/styles/tokens.css +12 -4
- package/template/package.json +3 -2
- package/template/src/pages/Home.svelte +1 -1
- package/src/editor/core/themes/slices/overlays.ts +0 -101
|
@@ -6,7 +6,7 @@ import dashed from '../../../live-tokens/data/sketch-styles/dashed.json';
|
|
|
6
6
|
import napkin from '../../../live-tokens/data/sketch-styles/napkin.json';
|
|
7
7
|
import dry from '../../../live-tokens/data/sketch-styles/dry.json';
|
|
8
8
|
|
|
9
|
-
export interface
|
|
9
|
+
export interface SketchStyleSettings {
|
|
10
10
|
label: string;
|
|
11
11
|
blurb: string;
|
|
12
12
|
/** How far the fill's edge travels at its furthest, in px. Every dial that
|
|
@@ -80,7 +80,7 @@ export interface SketchStyle {
|
|
|
80
80
|
/** Whether the two move together. Unlinked they part, and the field comes out
|
|
81
81
|
stretched: blobs wider than they are tall read as a wash dragged sideways,
|
|
82
82
|
the way ink pulled across a page does. Stored rather than inferred from
|
|
83
|
-
the pair matching, so a
|
|
83
|
+
the pair matching, so a sketchstyle that stretches to exactly square keeps its
|
|
84
84
|
dials apart. */
|
|
85
85
|
maskBlobLinked: boolean;
|
|
86
86
|
/** Output levels on the coverage field, 0 to 1: the palest the fill gets and
|
|
@@ -144,40 +144,40 @@ export interface SketchStyle {
|
|
|
144
144
|
/**
|
|
145
145
|
* The shipped sketchstyles, read from the files the package distributes. The
|
|
146
146
|
* files are the source: a project shadows one by saving a sketchstyle under the
|
|
147
|
-
* same id, the editor restores a shipped
|
|
147
|
+
* same id, the editor restores a shipped sketchstyle by deleting that file, and
|
|
148
148
|
* `themeFileApi` serves these as the read-only fallback behind the project's own
|
|
149
|
-
* directory. Editing a
|
|
149
|
+
* directory. Editing a sketchstyle here means editing its JSON, which is what the
|
|
150
150
|
* Sketchstyle view already writes.
|
|
151
151
|
*
|
|
152
152
|
* Each file carries every dial, so there is nothing to merge a default into.
|
|
153
153
|
* `sketchStyles.test.ts` pins that: the seven key sets have to match, and a dial
|
|
154
|
-
* added to `
|
|
154
|
+
* added to `SketchStyleSettings` has to reach all seven before the suite goes green.
|
|
155
155
|
*
|
|
156
156
|
* Order is picker order.
|
|
157
157
|
*/
|
|
158
158
|
const SHIPPED_FILES = { pencil, marker, whiteboard, hatched, dashed, napkin, dry };
|
|
159
159
|
|
|
160
|
-
export const
|
|
161
|
-
Object.entries(SHIPPED_FILES).map(([id, file]) => [id, file.settings as unknown as
|
|
160
|
+
export const SHIPPED_SKETCH_SETTINGS: Record<string, SketchStyleSettings> = Object.fromEntries(
|
|
161
|
+
Object.entries(SHIPPED_FILES).map(([id, file]) => [id, file.settings as unknown as SketchStyleSettings]),
|
|
162
162
|
);
|
|
163
163
|
|
|
164
164
|
export const DEFAULT_SKETCH_STYLE = 'marker';
|
|
165
165
|
|
|
166
|
-
/** The id
|
|
166
|
+
/** The id for sketch settings a theme carries, in the same id namespace as the shipped
|
|
167
167
|
sketchstyles so one picker row and one `setSketch` call cover both. Never a
|
|
168
|
-
key of `
|
|
169
|
-
own
|
|
168
|
+
key of `SHIPPED_SKETCH_SETTINGS`: a shipped style claiming it would shadow the theme's
|
|
169
|
+
own sketchstyle in every picker. `index.test.ts` pins that. */
|
|
170
170
|
export const THEME_SKETCH_ID = 'theme';
|
|
171
171
|
|
|
172
172
|
/** Reconciled against a full sketchstyle in both directions: a value stored before a
|
|
173
173
|
control existed picks up the default, and a value stored for a control since
|
|
174
174
|
retired is dropped. Without the drop, a stale key survives every spread and
|
|
175
175
|
makes the settings compare unequal to any baseline forever. */
|
|
176
|
-
export function
|
|
177
|
-
const base =
|
|
178
|
-
const stored = (raw ?? {}) as Partial<
|
|
176
|
+
export function hydrateSketchSettings(raw: unknown): SketchStyleSettings {
|
|
177
|
+
const base = SHIPPED_SKETCH_SETTINGS[DEFAULT_SKETCH_STYLE];
|
|
178
|
+
const stored = (raw ?? {}) as Partial<SketchStyleSettings>;
|
|
179
179
|
const out = { ...base };
|
|
180
|
-
for (const key of Object.keys(base) as (keyof
|
|
180
|
+
for (const key of Object.keys(base) as (keyof SketchStyleSettings)[]) {
|
|
181
181
|
if (stored[key] !== undefined) (out[key] as unknown) = stored[key];
|
|
182
182
|
}
|
|
183
183
|
// A retired option: the fill's presence belongs to the theme, not the effect.
|
|
@@ -194,9 +194,9 @@ export function hydrateSketchStyle(raw: unknown): SketchStyle {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
/** The second pass used to sit at a distance derived from the stroke width,
|
|
197
|
-
with no dial of its own. A
|
|
197
|
+
with no dial of its own. A sketchstyle stored before the dial comes back at that
|
|
198
198
|
distance rather than at whatever the fallback sketchstyle happens to carry. */
|
|
199
|
-
function restoreDerivedRetrace(stored: Record<string, unknown>, out:
|
|
199
|
+
function restoreDerivedRetrace(stored: Record<string, unknown>, out: SketchStyleSettings): void {
|
|
200
200
|
if (stored.retraceOffset === undefined) {
|
|
201
201
|
out.retraceOffset = Number(Math.max(1.2, out.strokeWidth * 0.55).toFixed(2));
|
|
202
202
|
}
|
|
@@ -204,7 +204,7 @@ function restoreDerivedRetrace(stored: Record<string, unknown>, out: SketchStyle
|
|
|
204
204
|
|
|
205
205
|
/** The four displacement dials used to be stated as the map's own `scale`,
|
|
206
206
|
which is the full swing: the number on the dial was twice the furthest
|
|
207
|
-
anything actually moved. They are peak travel in px now, so a
|
|
207
|
+
anything actually moved. They are peak travel in px now, so a sketchstyle stored
|
|
208
208
|
under the old names comes back halved and renders identically. */
|
|
209
209
|
const SWING_DIALS = {
|
|
210
210
|
fillScale: 'fillTravel',
|
|
@@ -216,11 +216,11 @@ const SWING_DIALS = {
|
|
|
216
216
|
/** The pen wobble used to be stated as `frequency`, in cycles per px, which is
|
|
217
217
|
the number the filter wants and not one anybody can picture. It is a
|
|
218
218
|
wavelength in px now, the way the mask states its blobs. */
|
|
219
|
-
function convertCyclesToWavelength(stored: Record<string, unknown>, out:
|
|
219
|
+
function convertCyclesToWavelength(stored: Record<string, unknown>, out: SketchStyleSettings): void {
|
|
220
220
|
const cycles = stored.frequency;
|
|
221
221
|
if (typeof cycles === 'number' && cycles > 0) out.wobble = Math.round(1 / cycles);
|
|
222
222
|
// The layer count was `octaves`, and its dial ran to 5. The top two moved
|
|
223
|
-
// nothing, so a
|
|
223
|
+
// nothing, so a sketchstyle stored there comes back at the roughest that reads.
|
|
224
224
|
// Both were multipliers on the frequency, so they ran the other way.
|
|
225
225
|
for (const [legacy, key] of [
|
|
226
226
|
['borderFrequency', 'borderWavelength'], ['iconFrequency', 'iconWavelength'],
|
|
@@ -234,9 +234,9 @@ function convertCyclesToWavelength(stored: Record<string, unknown>, out: SketchS
|
|
|
234
234
|
if (typeof layers === 'number') out.roughness = Math.min(3, Math.max(1, layers));
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
/** The blob size was one number for both axes. A
|
|
238
|
-
comes back square, with the two dials linked, which is the
|
|
239
|
-
function splitBlobAxes(stored: Record<string, unknown>, out:
|
|
237
|
+
/** The blob size was one number for both axes. A sketchstyle stored before the split
|
|
238
|
+
comes back square, with the two dials linked, which is the shape it had. */
|
|
239
|
+
function splitBlobAxes(stored: Record<string, unknown>, out: SketchStyleSettings): void {
|
|
240
240
|
const blob = stored.maskBlob;
|
|
241
241
|
if (typeof blob !== 'number') return;
|
|
242
242
|
out.maskBlobX = blob;
|
|
@@ -249,13 +249,13 @@ function splitBlobAxes(stored: Record<string, unknown>, out: SketchStyle): void
|
|
|
249
249
|
of the field, so it came out either untouched or gone. It is a share of the
|
|
250
250
|
glyph now, and the old default reads as one period across the glyph, which
|
|
251
251
|
is what that size was aiming at. */
|
|
252
|
-
function convertIconTileToScale(stored: Record<string, unknown>, out:
|
|
252
|
+
function convertIconTileToScale(stored: Record<string, unknown>, out: SketchStyleSettings): void {
|
|
253
253
|
const tile = stored.iconMaskTile;
|
|
254
254
|
if (typeof tile !== 'number') return;
|
|
255
255
|
out.iconMaskScale = Math.min(5, Math.max(0.5, Number((tile / 90).toFixed(2))));
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
function halveSwingDials(stored: Record<string, unknown>, out:
|
|
258
|
+
function halveSwingDials(stored: Record<string, unknown>, out: SketchStyleSettings): void {
|
|
259
259
|
for (const [legacy, key] of Object.entries(SWING_DIALS)) {
|
|
260
260
|
const value = stored[legacy];
|
|
261
261
|
if (typeof value === 'number') out[key as 'fillTravel'] = value / 2;
|
|
@@ -265,7 +265,7 @@ function halveSwingDials(stored: Record<string, unknown>, out: SketchStyle): voi
|
|
|
265
265
|
/** The mask used to be a 600-unit tile painted at `maskScale` px, with the
|
|
266
266
|
coverage point buried in the hardness slope. Recover page-px blobs and
|
|
267
267
|
softness, and the levels the old slope and floor put the edge at. */
|
|
268
|
-
function convertTiledMask(stored: Record<string, unknown>, out:
|
|
268
|
+
function convertTiledMask(stored: Record<string, unknown>, out: SketchStyleSettings): void {
|
|
269
269
|
const scale = stored.maskScale;
|
|
270
270
|
if (typeof scale !== 'number') return;
|
|
271
271
|
const freq = stored.maskFrequency;
|
|
@@ -283,7 +283,7 @@ function convertTiledMask(stored: Record<string, unknown>, out: SketchStyle): vo
|
|
|
283
283
|
* levels the pair put the edge between, and rescale them onto the field as it
|
|
284
284
|
* is now: stretched onto its full range before the levels see it.
|
|
285
285
|
*/
|
|
286
|
-
function convertCutToLevels(stored: Record<string, unknown>, out:
|
|
286
|
+
function convertCutToLevels(stored: Record<string, unknown>, out: SketchStyleSettings): void {
|
|
287
287
|
const contrast = stored.maskContrast;
|
|
288
288
|
const coverage = stored.maskCoverage;
|
|
289
289
|
if (typeof contrast !== 'number' || typeof coverage !== 'number') return;
|
|
@@ -297,9 +297,9 @@ function convertCutToLevels(stored: Record<string, unknown>, out: SketchStyle):
|
|
|
297
297
|
|
|
298
298
|
/** The pair were input levels, a cut through the field, and are output levels
|
|
299
299
|
now, the palest and densest the fill gets. The same two numbers carry over:
|
|
300
|
-
a
|
|
300
|
+
a sketchstyle cut between 20% and 50% comes back as a wash between 20% and 50% ink,
|
|
301
301
|
which keeps its spread and loses only the hole. */
|
|
302
|
-
function carryLevelsToOutput(stored: Record<string, unknown>, out:
|
|
302
|
+
function carryLevelsToOutput(stored: Record<string, unknown>, out: SketchStyleSettings): void {
|
|
303
303
|
if (typeof stored.maskLevelMin === 'number') out.maskOutputMin = stored.maskLevelMin;
|
|
304
304
|
if (typeof stored.maskLevelMax === 'number') out.maskOutputMax = stored.maskLevelMax;
|
|
305
305
|
}
|
|
@@ -20,6 +20,7 @@ import { store } from './editorCore';
|
|
|
20
20
|
import { quietGet, quietSet } from '../storage/storage';
|
|
21
21
|
import { isGradientSlot, makeDefaultGradients } from '../themes/slices/gradients';
|
|
22
22
|
import { seedShadowsFromDom } from '../themes/slices/shadows';
|
|
23
|
+
import { makeDefaultScrimTokens, makeDefaultTintTokens } from '../themes/slices/washes';
|
|
23
24
|
import { sanitizeHarmonyAxes } from '../palettes/colorHarmony';
|
|
24
25
|
import {
|
|
25
26
|
renameBackgroundPaletteKey,
|
|
@@ -77,6 +78,48 @@ function migrateGradients(state: EditorState): EditorState {
|
|
|
77
78
|
// calls `Object.entries(slice.config)` unconditionally, so backfill the
|
|
78
79
|
// required fields and drop any non-object slice before the state reaches the
|
|
79
80
|
// renderer. Spread preserves optional fields like `unlinked`.
|
|
81
|
+
// The washes slice was `overlays: { tokens, hoverTokens }` before the scrim and
|
|
82
|
+
// tint renames, and `hydrate` shallow-merges, so a persisted state from an
|
|
83
|
+
// older build replaces `washes` wholesale and arrives without `tints`.
|
|
84
|
+
// `washesToVars` iterates both lists unconditionally, so reshape here: carry the
|
|
85
|
+
// stops across under their new names, and rewrite the variable each one carries.
|
|
86
|
+
const WASH_VAR_RENAMES: Record<string, string> = {
|
|
87
|
+
'--overlay-low': '--scrim-low',
|
|
88
|
+
'--overlay': '--scrim',
|
|
89
|
+
'--overlay-high': '--scrim-high',
|
|
90
|
+
'--hover-low': '--tint-low',
|
|
91
|
+
'--hover': '--tint',
|
|
92
|
+
'--hover-high': '--tint-high',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type LegacyWashes = {
|
|
96
|
+
scrims?: unknown;
|
|
97
|
+
tints?: unknown;
|
|
98
|
+
hoverTokens?: unknown;
|
|
99
|
+
tokens?: unknown;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Carry a persisted stop list forward, or fall back to the shipped defaults. */
|
|
103
|
+
function washList(raw: unknown, fallback: () => EditorState['washes']['scrims']) {
|
|
104
|
+
if (!Array.isArray(raw) || raw.length === 0) return fallback();
|
|
105
|
+
const carried = raw
|
|
106
|
+
.filter((t): t is EditorState['washes']['scrims'][number] => !!t && typeof t === 'object' && 'variable' in t)
|
|
107
|
+
.map((t) => ({ ...t, variable: WASH_VAR_RENAMES[t.variable] ?? t.variable }));
|
|
108
|
+
return carried.length > 0 ? carried : fallback();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function normalizeWashes(state: EditorState): EditorState {
|
|
112
|
+
const washes = (state.washes ?? {}) as LegacyWashes;
|
|
113
|
+
const legacy = ((state as unknown as { overlays?: LegacyWashes }).overlays ?? {}) as LegacyWashes;
|
|
114
|
+
const next = { ...state } as EditorState & { overlays?: unknown };
|
|
115
|
+
next.washes = {
|
|
116
|
+
scrims: washList(washes.scrims ?? legacy.tokens, makeDefaultScrimTokens),
|
|
117
|
+
tints: washList(washes.tints ?? washes.hoverTokens ?? legacy.hoverTokens, makeDefaultTintTokens),
|
|
118
|
+
};
|
|
119
|
+
delete next.overlays;
|
|
120
|
+
return next;
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
export function normalizeComponents(state: EditorState): EditorState {
|
|
81
124
|
const raw = state.components;
|
|
82
125
|
if (!raw || typeof raw !== 'object') return { ...state, components: {} };
|
|
@@ -134,7 +177,7 @@ export function hydrate(): void {
|
|
|
134
177
|
store.set(
|
|
135
178
|
normalizeHarmonyAxes(
|
|
136
179
|
normalizeBaseAnchors(
|
|
137
|
-
normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(migrateGradients(merged)))),
|
|
180
|
+
normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(normalizeWashes(migrateGradients(merged))))),
|
|
138
181
|
),
|
|
139
182
|
),
|
|
140
183
|
);
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
editorState,
|
|
20
20
|
columnsToVars,
|
|
21
21
|
columnsEqualsDefault,
|
|
22
|
-
|
|
22
|
+
washesToVars,
|
|
23
23
|
shadowsToVars,
|
|
24
24
|
gradientsToVars,
|
|
25
25
|
componentsToVars,
|
|
@@ -30,7 +30,7 @@ export function deriveCssVars(state: EditorState): Record<string, string> {
|
|
|
30
30
|
if (!columnsEqualsDefault(state.columns)) {
|
|
31
31
|
Object.assign(out, columnsToVars(state.columns));
|
|
32
32
|
}
|
|
33
|
-
Object.assign(out,
|
|
33
|
+
Object.assign(out, washesToVars(state.washes));
|
|
34
34
|
if (state.shadows.tokens.length > 0) {
|
|
35
35
|
Object.assign(out, shadowsToVars(state.shadows));
|
|
36
36
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Central editor state store — the single mutation funnel.
|
|
3
3
|
*
|
|
4
|
-
* All editor state (palettes, fonts, shadows,
|
|
4
|
+
* All editor state (palettes, fonts, shadows, washes, columns, ad-hoc CSS
|
|
5
5
|
* vars) lives in one `EditorState` tree. Every change must go through
|
|
6
6
|
* `mutate` (or a transaction). History is captured automatically; the
|
|
7
7
|
* renderer subscribes and writes derived CSS vars to :root via `cssVarSync`
|
|
@@ -62,10 +62,10 @@ import {
|
|
|
62
62
|
loadColumnsFromVars,
|
|
63
63
|
} from '../themes/slices/columns';
|
|
64
64
|
import {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} from '../themes/slices/
|
|
65
|
+
loadWashesFromVars,
|
|
66
|
+
makeDefaultWashesState,
|
|
67
|
+
washesToVars,
|
|
68
|
+
} from '../themes/slices/washes';
|
|
69
69
|
import {
|
|
70
70
|
loadShadowsFromVars,
|
|
71
71
|
shadowsToVars,
|
|
@@ -99,7 +99,7 @@ function emptyState(): EditorState {
|
|
|
99
99
|
tokens: [],
|
|
100
100
|
overrides: {},
|
|
101
101
|
},
|
|
102
|
-
|
|
102
|
+
washes: makeDefaultWashesState(),
|
|
103
103
|
columns: { ...DEFAULT_COLUMNS },
|
|
104
104
|
components: {},
|
|
105
105
|
gradients: { tokens: makeDefaultGradients() },
|
|
@@ -150,15 +150,15 @@ export {
|
|
|
150
150
|
} from '../themes/slices/columns';
|
|
151
151
|
|
|
152
152
|
export {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
} from '../themes/slices/
|
|
153
|
+
washesToVars,
|
|
154
|
+
WASH_VAR_NAMES,
|
|
155
|
+
applyWashVarsToState,
|
|
156
|
+
makeDefaultWashesState,
|
|
157
|
+
makeDefaultScrimTokens,
|
|
158
|
+
makeDefaultTintTokens,
|
|
159
|
+
washTokenToCss,
|
|
160
|
+
parseWashCss,
|
|
161
|
+
} from '../themes/slices/washes';
|
|
162
162
|
|
|
163
163
|
export {
|
|
164
164
|
shadowsToVars,
|
|
@@ -364,7 +364,7 @@ type DomainLoader = (next: EditorState, rawVars: Record<string, string>) => void
|
|
|
364
364
|
|
|
365
365
|
const domainLoaders: Record<string, DomainLoader> = {
|
|
366
366
|
columns: loadColumnsFromVars,
|
|
367
|
-
|
|
367
|
+
washes: loadWashesFromVars,
|
|
368
368
|
shadows: loadShadowsFromVars,
|
|
369
369
|
components: loadComponentsFromVars,
|
|
370
370
|
};
|
|
@@ -428,7 +428,7 @@ function colorsAndTypeContent(state: EditorState): Omit<ColorsAndType, 'name' |
|
|
|
428
428
|
if (!columnsEqualsDefault(state.columns)) {
|
|
429
429
|
Object.assign(cssVariables, columnsToVars(state.columns));
|
|
430
430
|
}
|
|
431
|
-
Object.assign(cssVariables,
|
|
431
|
+
Object.assign(cssVariables, washesToVars(state.washes));
|
|
432
432
|
if (state.shadows.tokens.length > 0) {
|
|
433
433
|
Object.assign(cssVariables, shadowsToVars(state.shadows));
|
|
434
434
|
}
|
|
@@ -450,7 +450,7 @@ function colorsAndTypeContent(state: EditorState): Omit<ColorsAndType, 'name' |
|
|
|
450
450
|
|
|
451
451
|
/**
|
|
452
452
|
* Serialize current state for saving. Domains with their own typed state
|
|
453
|
-
* (columns,
|
|
453
|
+
* (columns, washes, shadows) fold derived vars into `cssVariables` only
|
|
454
454
|
* when they diverge from defaults; the catch-all `cssVars` bag carries
|
|
455
455
|
* everything not yet migrated to a typed domain.
|
|
456
456
|
*
|
|
@@ -23,9 +23,10 @@ export interface ShadowOverrideFlags {
|
|
|
23
23
|
distance: boolean; blur: boolean; size: boolean;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
* `color-mix(in srgb, var(<alias>) <opacity%>, transparent)`.
|
|
28
|
-
|
|
26
|
+
/** A wash: an aliased color token + an opacity. Emits as
|
|
27
|
+
* `color-mix(in srgb, var(<alias>) <opacity%>, transparent)`. Scrims dim what
|
|
28
|
+
* is behind them; tints shade the surface they sit on. */
|
|
29
|
+
export interface WashToken {
|
|
29
30
|
variable: string;
|
|
30
31
|
label: string;
|
|
31
32
|
alias: string;
|
|
@@ -111,9 +112,9 @@ export interface EditorState {
|
|
|
111
112
|
tokens: ShadowToken[];
|
|
112
113
|
overrides: Record<string, ShadowOverrideFlags>;
|
|
113
114
|
};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
washes: {
|
|
116
|
+
scrims: WashToken[];
|
|
117
|
+
tints: WashToken[];
|
|
117
118
|
};
|
|
118
119
|
columns: ColumnsState;
|
|
119
120
|
components: Record<string, ComponentSlice>;
|
|
@@ -65,7 +65,7 @@ export const sanitizeFileName = sanitizeFileNameImpl;
|
|
|
65
65
|
|
|
66
66
|
/** Flush the editor state to the buffer under `displayName`, the name of the
|
|
67
67
|
* theme the buffer belongs to, and clear the dirty flag. A capture reads the
|
|
68
|
-
* buffer back, so this is what makes Save and Adopt mean the
|
|
68
|
+
* buffer back, so this is what makes Save and Adopt mean the theme on screen. */
|
|
69
69
|
export async function persistColorsAndType(
|
|
70
70
|
state: EditorState,
|
|
71
71
|
displayName: string,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ThemeMeta, ColorsAndTypeMeta } from './themeTypes';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The one Load list. A theme is the whole
|
|
4
|
+
* The one Load list. A theme is the whole theme; a colors and type file is a
|
|
5
5
|
* preset holding that half of one. Both belong in the same window, told apart
|
|
6
6
|
* by a badge rather than by living in separate managers.
|
|
7
7
|
*
|
|
@@ -10,7 +10,7 @@ import type { ThemeMeta, ColorsAndTypeMeta } from './themeTypes';
|
|
|
10
10
|
* that copy is exactly the file the list must keep reachable.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export type LoadRowKind = '
|
|
13
|
+
export type LoadRowKind = 'theme' | 'layer';
|
|
14
14
|
|
|
15
15
|
export interface LoadRow {
|
|
16
16
|
/** `<kind>:<slug>`. The two kinds are separate resources with separate name
|
|
@@ -25,12 +25,12 @@ export interface LoadRow {
|
|
|
25
25
|
|
|
26
26
|
export const loadRowId = (kind: LoadRowKind, slug: string): string => `${kind}:${slug}`;
|
|
27
27
|
|
|
28
|
-
export function buildLoadRows(
|
|
29
|
-
const
|
|
28
|
+
export function buildLoadRows(themes: ThemeMeta[], layers: ColorsAndTypeMeta[]): LoadRow[] {
|
|
29
|
+
const themeRows: LoadRow[] = themes
|
|
30
30
|
.map((f): LoadRow => ({
|
|
31
|
-
fileName: loadRowId('
|
|
31
|
+
fileName: loadRowId('theme', f.fileName),
|
|
32
32
|
slug: f.fileName,
|
|
33
|
-
kind: '
|
|
33
|
+
kind: 'theme',
|
|
34
34
|
name: f.name,
|
|
35
35
|
updatedAt: f.updatedAt,
|
|
36
36
|
isProtected: f.isProtected,
|
|
@@ -46,12 +46,12 @@ export function buildLoadRows(looks: ThemeMeta[], layers: ColorsAndTypeMeta[]):
|
|
|
46
46
|
updatedAt: f.updatedAt,
|
|
47
47
|
isProtected: false,
|
|
48
48
|
}));
|
|
49
|
-
return [...
|
|
49
|
+
return [...themeRows, ...layerRows];
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Whether picking this row loads colors and type alone. A layer file holds
|
|
54
|
-
* nothing else, so it ignores the toggle; a
|
|
54
|
+
* nothing else, so it ignores the toggle; a theme honors it.
|
|
55
55
|
*/
|
|
56
56
|
export function isColorsOnly(row: LoadRow | null, colorsOnly: boolean): boolean {
|
|
57
57
|
if (row?.kind === 'layer') return true;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Migration } from './index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gate suffix, state word to `-enabled` (2026-09-01).
|
|
5
|
+
*
|
|
6
|
+
* An optional interaction is switched by a gate variable. Those gates were named
|
|
7
|
+
* with a state word at the end (`--card-hover-border-active`,
|
|
8
|
+
* `--image-zoom-hover`), which reads as state-after-property and fails
|
|
9
|
+
* `check-component`. A gate is not a state: it says whether the interaction is
|
|
10
|
+
* on at all, so it takes `-enabled`.
|
|
11
|
+
*
|
|
12
|
+
* Values are unchanged; a gate still holds either its off value or its on value.
|
|
13
|
+
*/
|
|
14
|
+
const RENAMED: Record<string, string> = {
|
|
15
|
+
'--card-hover-border-active': '--card-hover-border-enabled',
|
|
16
|
+
'--card-hover-shadow-active': '--card-hover-shadow-enabled',
|
|
17
|
+
'--image-zoom-hover': '--image-zoom-enabled',
|
|
18
|
+
'--image-grow-hover': '--image-grow-enabled',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const componentMigration_2026_09_01_gateSuffixEnabled: Migration = {
|
|
22
|
+
id: '2026-09-01-gate-suffix-enabled',
|
|
23
|
+
fromVersion: 24,
|
|
24
|
+
toVersion: 25,
|
|
25
|
+
appliesTo: 'component-config',
|
|
26
|
+
apply(rawVars) {
|
|
27
|
+
const out: Record<string, string> = {};
|
|
28
|
+
for (const [key, value] of Object.entries(rawVars)) out[RENAMED[key] ?? key] = value;
|
|
29
|
+
return out;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Migration } from './index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scrim rename (2026-09-01): `--overlay-*` became `--scrim-*`.
|
|
5
|
+
*
|
|
6
|
+
* An overlay is a scrim, a translucent layer that dims what is behind it. The
|
|
7
|
+
* name had spread to cover surface tints too, which are the opposite operation,
|
|
8
|
+
* and it collided with `backdrop`, an exported concept about what paints behind
|
|
9
|
+
* an element. Each name now means one thing.
|
|
10
|
+
*
|
|
11
|
+
* Values are unchanged, so this is a pure key rename in colors-and-type files.
|
|
12
|
+
* Component configs need both halves: Dialog's `--dialog-overlay-surface` is a
|
|
13
|
+
* key, and any alias *value* pointing at a scrim stop is a bare token name that
|
|
14
|
+
* has to be rebound.
|
|
15
|
+
*/
|
|
16
|
+
const RENAMED: Record<string, string> = {
|
|
17
|
+
'--overlay-low': '--scrim-low',
|
|
18
|
+
'--overlay': '--scrim',
|
|
19
|
+
'--overlay-high': '--scrim-high',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const RENAMED_COMPONENT_KEYS: Record<string, string> = {
|
|
23
|
+
'--dialog-overlay-surface': '--dialog-scrim-surface',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const colorsAndTypeMigration_2026_09_01_scrimRename: Migration = {
|
|
27
|
+
id: '2026-09-01-scrim-rename-theme',
|
|
28
|
+
fromVersion: 5,
|
|
29
|
+
toVersion: 6,
|
|
30
|
+
appliesTo: 'colors-and-type',
|
|
31
|
+
apply(rawVars) {
|
|
32
|
+
const out: Record<string, string> = {};
|
|
33
|
+
for (const [key, value] of Object.entries(rawVars)) {
|
|
34
|
+
out[RENAMED[key] ?? key] = value;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const componentMigration_2026_09_01_scrimRename: Migration = {
|
|
41
|
+
id: '2026-09-01-scrim-rename-component',
|
|
42
|
+
fromVersion: 21,
|
|
43
|
+
toVersion: 22,
|
|
44
|
+
appliesTo: 'component-config',
|
|
45
|
+
apply(rawVars) {
|
|
46
|
+
const out: Record<string, string> = {};
|
|
47
|
+
for (const [key, value] of Object.entries(rawVars)) {
|
|
48
|
+
out[RENAMED_COMPONENT_KEYS[key] ?? key] = RENAMED[value] ?? value;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Migration } from './index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TabBar active surface, scrim to tint (2026-09-01, tabbar only).
|
|
5
|
+
*
|
|
6
|
+
* The active tab is a wash *on* the bar, not a layer dimming what is behind it,
|
|
7
|
+
* so it belongs to the tint family. It only ever read a scrim because no tint
|
|
8
|
+
* family existed. Rebinding is a visible change: the 38% near-black scrim
|
|
9
|
+
* becomes a 10% white tint, so the active tab reads lighter rather than darker.
|
|
10
|
+
*
|
|
11
|
+
* Scoped to the shipped default value. A theme that pointed this alias
|
|
12
|
+
* somewhere else keeps its choice.
|
|
13
|
+
*/
|
|
14
|
+
export const componentMigration_2026_09_01_tabbarActiveTint: Migration = {
|
|
15
|
+
id: '2026-09-01-tabbar-active-tint',
|
|
16
|
+
fromVersion: 23,
|
|
17
|
+
toVersion: 24,
|
|
18
|
+
appliesTo: 'component-config',
|
|
19
|
+
apply(rawVars, meta) {
|
|
20
|
+
if (meta.component !== 'tabbar') return { ...rawVars };
|
|
21
|
+
const out = { ...rawVars };
|
|
22
|
+
if (out['--tabbar-active-surface'] === '--scrim-low') {
|
|
23
|
+
out['--tabbar-active-surface'] = '--tint-low';
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Migration } from './index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tint rename (2026-09-01): `--hover-*` became `--tint-*`.
|
|
5
|
+
*
|
|
6
|
+
* A state is a segment of a property name (`--button-outline-hover-surface`),
|
|
7
|
+
* not a token of its own, so the three stops are named for what they are: a
|
|
8
|
+
* tint shades the surface it sits on. Values are unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Nothing shipped read the old names, so this only touches saved files that
|
|
11
|
+
* captured them from the editor's own stops.
|
|
12
|
+
*/
|
|
13
|
+
const RENAMED: Record<string, string> = {
|
|
14
|
+
'--hover-low': '--tint-low',
|
|
15
|
+
'--hover': '--tint',
|
|
16
|
+
'--hover-high': '--tint-high',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const colorsAndTypeMigration_2026_09_01_tintRename: Migration = {
|
|
20
|
+
id: '2026-09-01-tint-rename-theme',
|
|
21
|
+
fromVersion: 6,
|
|
22
|
+
toVersion: 7,
|
|
23
|
+
appliesTo: 'colors-and-type',
|
|
24
|
+
apply(rawVars) {
|
|
25
|
+
const out: Record<string, string> = {};
|
|
26
|
+
for (const [key, value] of Object.entries(rawVars)) out[RENAMED[key] ?? key] = value;
|
|
27
|
+
return out;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const componentMigration_2026_09_01_tintRename: Migration = {
|
|
32
|
+
id: '2026-09-01-tint-rename-component',
|
|
33
|
+
fromVersion: 22,
|
|
34
|
+
toVersion: 23,
|
|
35
|
+
appliesTo: 'component-config',
|
|
36
|
+
apply(rawVars) {
|
|
37
|
+
const out: Record<string, string> = {};
|
|
38
|
+
// Alias values are bare token names; rebind any that pointed at a hover stop.
|
|
39
|
+
for (const [key, value] of Object.entries(rawVars)) out[key] = RENAMED[value] ?? value;
|
|
40
|
+
return out;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -62,6 +62,16 @@ import {
|
|
|
62
62
|
colorsAndTypeMigration_2026_08_15_lineHeightScaleRename,
|
|
63
63
|
componentMigration_2026_08_15_lineHeightScaleRename,
|
|
64
64
|
} from './2026-08-15-line-height-scale-rename';
|
|
65
|
+
import {
|
|
66
|
+
colorsAndTypeMigration_2026_09_01_scrimRename,
|
|
67
|
+
componentMigration_2026_09_01_scrimRename,
|
|
68
|
+
} from './2026-09-01-scrim-rename';
|
|
69
|
+
import {
|
|
70
|
+
colorsAndTypeMigration_2026_09_01_tintRename,
|
|
71
|
+
componentMigration_2026_09_01_tintRename,
|
|
72
|
+
} from './2026-09-01-tint-rename';
|
|
73
|
+
import { componentMigration_2026_09_01_tabbarActiveTint } from './2026-09-01-tabbar-active-tint';
|
|
74
|
+
import { componentMigration_2026_09_01_gateSuffixEnabled } from './2026-09-01-gate-suffix-enabled';
|
|
65
75
|
|
|
66
76
|
/**
|
|
67
77
|
* Registered migrations. Order in this array does not matter — the runner
|
|
@@ -94,6 +104,12 @@ export const MIGRATIONS: Migration[] = [
|
|
|
94
104
|
colorsAndTypeMigration_2026_08_13_dropLegacyShapeSpaceKeys,
|
|
95
105
|
colorsAndTypeMigration_2026_08_15_lineHeightScaleRename,
|
|
96
106
|
componentMigration_2026_08_15_lineHeightScaleRename,
|
|
107
|
+
colorsAndTypeMigration_2026_09_01_scrimRename,
|
|
108
|
+
componentMigration_2026_09_01_scrimRename,
|
|
109
|
+
colorsAndTypeMigration_2026_09_01_tintRename,
|
|
110
|
+
componentMigration_2026_09_01_tintRename,
|
|
111
|
+
componentMigration_2026_09_01_tabbarActiveTint,
|
|
112
|
+
componentMigration_2026_09_01_gateSuffixEnabled,
|
|
97
113
|
];
|
|
98
114
|
|
|
99
115
|
function countFor(kind: 'colors-and-type' | 'component-config'): number {
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* so the store stays the single source of truth.
|
|
6
6
|
*/
|
|
7
7
|
import { COLUMN_VAR_NAMES } from './columns';
|
|
8
|
-
import {
|
|
8
|
+
import { WASH_VAR_NAMES } from './washes';
|
|
9
9
|
import { SHADOW_VAR_NAMES } from './shadows';
|
|
10
10
|
|
|
11
11
|
export const DOMAIN_VAR_NAMES: readonly string[] = [
|
|
12
12
|
...COLUMN_VAR_NAMES,
|
|
13
|
-
...
|
|
13
|
+
...WASH_VAR_NAMES,
|
|
14
14
|
...SHADOW_VAR_NAMES,
|
|
15
15
|
];
|