@motion-proto/live-tokens 0.91.2 → 0.93.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-create-page/SKILL.md +10 -3
- package/.claude/skills/live-tokens-set-type/SKILL.md +3 -3
- package/CHANGELOG.md +171 -0
- package/bin/lib/tokenVocabulary.mjs +2 -2
- package/bin/rules/testRuns.mjs +1 -1
- package/bin/rules/tokens.mjs +3 -3
- package/dist-plugin/chunk-7XSUHJUH.js +1652 -0
- package/dist-plugin/index.cjs +950 -15
- package/dist-plugin/index.js +1 -1
- package/dist-plugin/setType/index.cjs +23 -2
- package/dist-plugin/setType/index.js +23 -2
- package/dist-plugin/tokensCssMigrations/index.cjs +954 -15
- package/dist-plugin/tokensCssMigrations/index.d.cts +23 -3
- package/dist-plugin/tokensCssMigrations/index.d.ts +23 -3
- package/dist-plugin/tokensCssMigrations/index.js +5 -1
- package/package.json +1 -1
- package/src/app/site.css +243 -9
- package/src/demo/TestingLoops.svelte +9 -9
- package/src/editor/core/fonts/weightCoverage.ts +31 -2
- package/src/editor/core/palettes/tokenRegistry.ts +36 -37
- package/src/editor/core/store/editorKeybindings.ts +1 -1
- package/src/editor/core/themes/parsers/mediaBlocks.ts +46 -0
- package/src/editor/core/typeScale/typeScale.ts +341 -0
- package/src/editor/skill-atlas/SourcePane.svelte +3 -3
- package/src/editor/skill-atlas/skillSources.generated.ts +2 -2
- package/src/editor/skill-atlas/trees/create-page.ts +14 -14
- package/src/editor/skill-atlas/trees/set-type.ts +2 -2
- package/src/editor/ui/UIFontFamilySelector.svelte +5 -1
- package/src/editor/ui/UIFontWeightSelector.svelte +4 -0
- package/src/editor/ui/UILetterSpacingSelector.svelte +4 -0
- package/src/editor/ui/UILineHeightSelector.svelte +4 -0
- package/src/editor/ui/UITextTransformSelector.svelte +4 -0
- package/src/editor/ui/UIVariantSelector.svelte +6 -8
- package/src/editor/ui/VariablesTab.svelte +4 -4
- package/src/editor/ui/colors/ColorsTab.svelte +99 -5
- package/src/editor/ui/colors/paletteBaseColor.ts +17 -0
- package/src/editor/ui/sections/textStyles.ts +44 -92
- package/src/editor/ui/text-styles/ControlBody.svelte +100 -0
- package/src/editor/ui/text-styles/ResetIcon.svelte +60 -0
- package/src/editor/ui/text-styles/StepRow.svelte +330 -0
- package/src/editor/ui/text-styles/StyleControl.svelte +128 -0
- package/src/editor/ui/text-styles/TextStylesSection.svelte +551 -0
- package/src/editor/ui/text-styles/textStyleEdits.ts +308 -0
- package/src/editor/ui/variantScales.ts +16 -0
- package/src/system/styles/tokens.css +412 -48
- package/src/testing-js/page-compliance.contract.js +233 -80
- package/src/testing-js/page-compliance.contract.js.map +1 -1
- package/dist-plugin/chunk-GWMEYBZ2.js +0 -715
- package/src/editor/ui/TextStylesSection.svelte +0 -171
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { get } from 'svelte/store';
|
|
2
|
+
import { editorState, mutate, transaction } from '../../core/store/editorStore';
|
|
3
|
+
import type { EditorState } from '../../core/store/editorTypes';
|
|
4
|
+
import {
|
|
5
|
+
COMPRESSION_RANGE,
|
|
6
|
+
CUSTOM_RATIO_RANGE,
|
|
7
|
+
DEFAULTS,
|
|
8
|
+
EDITABLE_NAMES,
|
|
9
|
+
EYEBROW_PREFIX,
|
|
10
|
+
INTERVALS,
|
|
11
|
+
ROOT_PX,
|
|
12
|
+
axisName,
|
|
13
|
+
compressionName,
|
|
14
|
+
formatNumber,
|
|
15
|
+
formatRem,
|
|
16
|
+
recommend,
|
|
17
|
+
recommendedEditedExpression,
|
|
18
|
+
scalePx,
|
|
19
|
+
sizeName,
|
|
20
|
+
stepPrefix,
|
|
21
|
+
usageName,
|
|
22
|
+
type CompressedViewport,
|
|
23
|
+
type Interval,
|
|
24
|
+
type Step,
|
|
25
|
+
type StylePrefix,
|
|
26
|
+
type Usage,
|
|
27
|
+
type Viewport,
|
|
28
|
+
} from '../../core/typeScale/typeScale';
|
|
29
|
+
|
|
30
|
+
export type Declared = (name: string) => string | null;
|
|
31
|
+
|
|
32
|
+
export interface TokenView {
|
|
33
|
+
stored: Readonly<Record<string, string>>;
|
|
34
|
+
declared: Declared;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Planned writes: a value to store, or null to delete the key. */
|
|
38
|
+
export type Patch = Record<string, string | null>;
|
|
39
|
+
|
|
40
|
+
export interface StyleTarget {
|
|
41
|
+
prefix: StylePrefix;
|
|
42
|
+
/** The usage whose face and weight the style links to. */
|
|
43
|
+
usage: Usage;
|
|
44
|
+
/** Null for the eyebrow, whose sizes follow `EYEBROW_FOLLOWS`. */
|
|
45
|
+
step: Step | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type LinkAxis = 'font-family' | 'font-weight';
|
|
49
|
+
|
|
50
|
+
export interface SizeView {
|
|
51
|
+
px: number;
|
|
52
|
+
recommendedPx: number;
|
|
53
|
+
edited: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const COMPRESSED: readonly CompressedViewport[] = ['tablet', 'phone'];
|
|
57
|
+
export const SIZE_RANGE_PX = { min: 6, max: 200 } as const;
|
|
58
|
+
export const BASE_RANGE_PX = { min: 8, max: 160 } as const;
|
|
59
|
+
/** A typed size this close to its recommendation follows the recommendation. */
|
|
60
|
+
export const SIZE_TOLERANCE_PX = 0.05;
|
|
61
|
+
export const SMALL_SIZE_PX = 12;
|
|
62
|
+
|
|
63
|
+
export const stepTarget = (usage: Usage, step: Step): StyleTarget => ({
|
|
64
|
+
prefix: stepPrefix(usage, step),
|
|
65
|
+
usage,
|
|
66
|
+
step,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const EYEBROW: StyleTarget = { prefix: EYEBROW_PREFIX, usage: DEFAULTS.eyebrow.usage, step: null };
|
|
70
|
+
const EYEBROW_FOLLOWS = stepTarget(DEFAULTS.eyebrow.usage, DEFAULTS.eyebrow.sizeStep);
|
|
71
|
+
|
|
72
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
|
|
73
|
+
|
|
74
|
+
export function valueOf(view: TokenView, name: string): string {
|
|
75
|
+
return view.stored[name] ?? view.declared(name) ?? '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const isStored = (view: TokenView, name: string): boolean => Object.hasOwn(view.stored, name);
|
|
79
|
+
|
|
80
|
+
const numberOf = (view: TokenView, name: string): number => Number(valueOf(view, name));
|
|
81
|
+
|
|
82
|
+
/** A length the editor wrote, in px. An expression follows a recommendation and parses to null. */
|
|
83
|
+
export function literalPx(value: string): number | null {
|
|
84
|
+
const m = /^(-?\d*\.?\d+)(rem|px)$/.exec(value.trim());
|
|
85
|
+
if (!m) return null;
|
|
86
|
+
return m[2] === 'rem' ? Number(m[1]) * ROOT_PX : Number(m[1]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface UsageSettings {
|
|
90
|
+
base: number;
|
|
91
|
+
ratio: number;
|
|
92
|
+
face: string;
|
|
93
|
+
weight: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function settingsOf(view: TokenView, usage: Usage): UsageSettings {
|
|
97
|
+
return {
|
|
98
|
+
base: numberOf(view, usageName(usage, 'scale-base')),
|
|
99
|
+
ratio: numberOf(view, usageName(usage, 'scale-ratio')),
|
|
100
|
+
face: valueOf(view, usageName(usage, 'font-family')),
|
|
101
|
+
weight: valueOf(view, usageName(usage, 'font-weight')),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const compressionOf = (view: TokenView, viewport: CompressedViewport): number =>
|
|
106
|
+
numberOf(view, compressionName(viewport));
|
|
107
|
+
|
|
108
|
+
const CUSTOM_INTERVAL = INTERVALS.find((i) => i.ratio === null)!;
|
|
109
|
+
|
|
110
|
+
export function intervalOf(ratio: number): Interval {
|
|
111
|
+
return INTERVALS.find((i) => i.ratio !== null && Math.abs(i.ratio - ratio) < 1e-6) ?? CUSTOM_INTERVAL;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function ratioLabel(ratio: number): string {
|
|
115
|
+
const interval = intervalOf(ratio);
|
|
116
|
+
return interval.ratio === null ? `${interval.label} ${formatNumber(ratio)}` : interval.label;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sizeOf(value: string, recommendedPx: number): SizeView {
|
|
120
|
+
const px = literalPx(value);
|
|
121
|
+
return { px: px ?? recommendedPx, recommendedPx, edited: px !== null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Each viewport's size: the stored edit when it holds a literal, the
|
|
126
|
+
* recommendation otherwise. The eyebrow follows body sm until its desktop
|
|
127
|
+
* size holds an edit.
|
|
128
|
+
*/
|
|
129
|
+
export function sizesOf(view: TokenView, target: StyleTarget): Record<Viewport, SizeView> {
|
|
130
|
+
const follows = target.step === null ? sizesOf(view, EYEBROW_FOLLOWS) : null;
|
|
131
|
+
let desktopRecommended: number;
|
|
132
|
+
if (follows) desktopRecommended = follows.desktop.px;
|
|
133
|
+
else {
|
|
134
|
+
const { base, ratio } = settingsOf(view, target.usage);
|
|
135
|
+
desktopRecommended = scalePx(base, ratio, target.step!);
|
|
136
|
+
}
|
|
137
|
+
const desktop = sizeOf(valueOf(view, sizeName(target.prefix, 'desktop')), desktopRecommended);
|
|
138
|
+
const out = { desktop } as Record<Viewport, SizeView>;
|
|
139
|
+
for (const vp of COMPRESSED) {
|
|
140
|
+
const recommended = follows && !desktop.edited
|
|
141
|
+
? follows[vp].px
|
|
142
|
+
: recommend(desktop.px, compressionOf(view, vp));
|
|
143
|
+
out[vp] = sizeOf(valueOf(view, sizeName(target.prefix, vp)), recommended);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function linkExpression(target: StyleTarget, axis: LinkAxis): string {
|
|
149
|
+
return `var(${usageName(target.usage, axis)})`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isLinked(view: TokenView, target: StyleTarget, axis: LinkAxis): boolean {
|
|
153
|
+
return valueOf(view, axisName(target.prefix, axis)) === linkExpression(target, axis);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `var(--font-display)` reads as "Display", and a stack reads as its first family. */
|
|
157
|
+
export function faceLabel(value: string): string {
|
|
158
|
+
const token = /^var\(--font-([a-z]+)\)$/.exec(value);
|
|
159
|
+
if (token) return capitalize(token[1]);
|
|
160
|
+
return value.split(',')[0].trim().replace(/^['"]|['"]$/g, '');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function weightLabel(value: string): string {
|
|
164
|
+
const token = /^var\(--font-weight-([a-z]+)\)$/.exec(value);
|
|
165
|
+
return token ? capitalize(token[1]) : value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
|
169
|
+
|
|
170
|
+
export const formatPx = (px: number): string => px.toFixed(1);
|
|
171
|
+
|
|
172
|
+
export function compressionEffect(compression: number): string {
|
|
173
|
+
return `${[72, 48, 24].map((px) => `${px} → ${formatPx(recommend(px, compression))}`).join(' · ')} px`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every editable name a usage's tab shows. The Body tab also holds the eyebrow. */
|
|
177
|
+
export function usageKeys(usage: Usage): string[] {
|
|
178
|
+
return [...EDITABLE_NAMES].filter(
|
|
179
|
+
(name) => name.startsWith(`--${usage}-`)
|
|
180
|
+
|| (usage === EYEBROW.usage && name.startsWith(`${EYEBROW_PREFIX}-`)),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const hasStored = (view: TokenView, names: Iterable<string>): boolean =>
|
|
185
|
+
[...names].some((name) => isStored(view, name));
|
|
186
|
+
|
|
187
|
+
/** Store `value`, or delete the key when `tokens.css` already declares it. */
|
|
188
|
+
function put(view: TokenView, patch: Patch, name: string, value: string): Patch {
|
|
189
|
+
patch[name] = value === view.declared(name) ? null : value;
|
|
190
|
+
return patch;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The declared tablet and phone sizes compute from the scale, so a desktop
|
|
195
|
+
* edit rewrites both recommendations around its own rem count. A tablet or
|
|
196
|
+
* phone size that holds an edit keeps it.
|
|
197
|
+
*/
|
|
198
|
+
function planDesktop(view: TokenView, target: StyleTarget, rem: number | null): Patch {
|
|
199
|
+
const patch: Patch = { [sizeName(target.prefix, 'desktop')]: rem === null ? null : formatRem(rem) };
|
|
200
|
+
for (const vp of COMPRESSED) {
|
|
201
|
+
const name = sizeName(target.prefix, vp);
|
|
202
|
+
if (literalPx(valueOf(view, name)) !== null) continue;
|
|
203
|
+
patch[name] = rem === null ? null : recommendedEditedExpression(target.prefix, rem, vp);
|
|
204
|
+
}
|
|
205
|
+
return patch;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function planFollow(view: TokenView, target: StyleTarget, vp: CompressedViewport): Patch {
|
|
209
|
+
const desktop = sizesOf(view, target).desktop;
|
|
210
|
+
const name = sizeName(target.prefix, vp);
|
|
211
|
+
if (!desktop.edited) return { [name]: null };
|
|
212
|
+
return { [name]: recommendedEditedExpression(target.prefix, desktop.px / ROOT_PX, vp) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function planSize(view: TokenView, target: StyleTarget, vp: Viewport, px: number): Patch {
|
|
216
|
+
if (!Number.isFinite(px)) return {};
|
|
217
|
+
const follows = Math.abs(px - sizesOf(view, target)[vp].recommendedPx) < SIZE_TOLERANCE_PX;
|
|
218
|
+
const rem = Number(formatNumber(clamp(px, SIZE_RANGE_PX.min, SIZE_RANGE_PX.max) / ROOT_PX));
|
|
219
|
+
if (vp === 'desktop') return planDesktop(view, target, follows ? null : rem);
|
|
220
|
+
if (follows) return planFollow(view, target, vp);
|
|
221
|
+
return { [sizeName(target.prefix, vp)]: formatRem(rem) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function planSizeReset(view: TokenView, target: StyleTarget, vp: Viewport): Patch {
|
|
225
|
+
return vp === 'desktop' ? planDesktop(view, target, null) : planFollow(view, target, vp);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function planCompression(view: TokenView, vp: CompressedViewport, value: number): Patch {
|
|
229
|
+
if (!Number.isFinite(value)) return {};
|
|
230
|
+
const c = clamp(value, COMPRESSION_RANGE.min, COMPRESSION_RANGE.max);
|
|
231
|
+
return put(view, {}, compressionName(vp), formatNumber(c));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function planBase(view: TokenView, usage: Usage, px: number): Patch {
|
|
235
|
+
if (!Number.isFinite(px)) return {};
|
|
236
|
+
const rem = clamp(px, BASE_RANGE_PX.min, BASE_RANGE_PX.max) / ROOT_PX;
|
|
237
|
+
return put(view, {}, usageName(usage, 'scale-base'), formatNumber(rem));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function planRatio(view: TokenView, usage: Usage, ratio: number): Patch {
|
|
241
|
+
if (!Number.isFinite(ratio)) return {};
|
|
242
|
+
const r = clamp(ratio, CUSTOM_RATIO_RANGE.min, CUSTOM_RATIO_RANGE.max);
|
|
243
|
+
return put(view, {}, usageName(usage, 'scale-ratio'), formatNumber(r));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A selector's write: a bare `--token`, a literal such as a font stack, or null to reset. */
|
|
247
|
+
export function planToken(view: TokenView, name: string, value: string | null): Patch {
|
|
248
|
+
if (value === null) return { [name]: null };
|
|
249
|
+
return put(view, {}, name, value.startsWith('--') ? `var(${value})` : value);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Unlinking copies the usage's current value; relinking restores the link expression. */
|
|
253
|
+
export function planLinkToggle(view: TokenView, target: StyleTarget, axis: LinkAxis): Patch {
|
|
254
|
+
const name = axisName(target.prefix, axis);
|
|
255
|
+
const value = isLinked(view, target, axis)
|
|
256
|
+
? valueOf(view, usageName(target.usage, axis))
|
|
257
|
+
: linkExpression(target, axis);
|
|
258
|
+
return put(view, {}, name, value);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function planReset(view: TokenView, names: Iterable<string>): Patch {
|
|
262
|
+
const patch: Patch = {};
|
|
263
|
+
for (const name of names) if (isStored(view, name)) patch[name] = null;
|
|
264
|
+
return patch;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function applyPatch(label: string, patch: Patch): void {
|
|
268
|
+
const stored = get(editorState).cssVars;
|
|
269
|
+
const changes = Object.entries(patch).filter(([name, value]) =>
|
|
270
|
+
value === null ? Object.hasOwn(stored, name) : stored[name] !== value,
|
|
271
|
+
);
|
|
272
|
+
if (changes.length === 0) return;
|
|
273
|
+
const write = (s: EditorState) => {
|
|
274
|
+
for (const [name, value] of changes) {
|
|
275
|
+
if (value === null) delete s.cssVars[name];
|
|
276
|
+
else s.cssVars[name] = value;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
if (changes.length === 1) mutate(label, write);
|
|
280
|
+
else transaction(label, write);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export type TextStyleEdits = ReturnType<typeof textStyleEdits>;
|
|
284
|
+
|
|
285
|
+
/** Each action writes one history entry. */
|
|
286
|
+
export function textStyleEdits(declared: Declared) {
|
|
287
|
+
const view = (): TokenView => ({ stored: get(editorState).cssVars, declared });
|
|
288
|
+
return {
|
|
289
|
+
view,
|
|
290
|
+
setSize: (target: StyleTarget, vp: Viewport, px: number) =>
|
|
291
|
+
applyPatch('edit text style size', planSize(view(), target, vp, px)),
|
|
292
|
+
resetSize: (target: StyleTarget, vp: Viewport) =>
|
|
293
|
+
applyPatch('reset text style size', planSizeReset(view(), target, vp)),
|
|
294
|
+
setCompression: (vp: CompressedViewport, value: number) =>
|
|
295
|
+
applyPatch(`edit ${vp} scaling`, planCompression(view(), vp, value)),
|
|
296
|
+
setBase: (usage: Usage, px: number) =>
|
|
297
|
+
applyPatch(`edit ${usage} base`, planBase(view(), usage, px)),
|
|
298
|
+
setRatio: (usage: Usage, ratio: number) =>
|
|
299
|
+
applyPatch(`edit ${usage} ratio`, planRatio(view(), usage, ratio)),
|
|
300
|
+
writeToken: (name: string, value: string | null) =>
|
|
301
|
+
applyPatch(`edit ${name}`, planToken(view(), name, value)),
|
|
302
|
+
toggleLink: (target: StyleTarget, axis: LinkAxis) =>
|
|
303
|
+
applyPatch(`toggle ${axisName(target.prefix, axis)} link`, planLinkToggle(view(), target, axis)),
|
|
304
|
+
resetKey: (name: string) => applyPatch(`reset ${name}`, planReset(view(), [name])),
|
|
305
|
+
resetUsage: (usage: Usage) => applyPatch(`reset ${usage} text styles`, planReset(view(), usageKeys(usage))),
|
|
306
|
+
resetAll: () => applyPatch('reset text styles', planReset(view(), EDITABLE_NAMES)),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -21,6 +21,22 @@ export interface VariantScaleEntry {
|
|
|
21
21
|
options: ReadonlyArray<VariantScaleOption>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The option key a value selects: the whole value is `var(<varPrefix><key>)`
|
|
26
|
+
* and `key` is one of `validKeys`. A `var()` inside an expression such as
|
|
27
|
+
* `calc(var(--x) * 2)` selects nothing.
|
|
28
|
+
*/
|
|
29
|
+
export function parseVariantRef(
|
|
30
|
+
value: string,
|
|
31
|
+
varPrefix: string,
|
|
32
|
+
validKeys: ReadonlySet<string>,
|
|
33
|
+
): string | null {
|
|
34
|
+
const name = value.trim().match(/^var\(\s*(--[a-z0-9-]+)\s*\)$/i)?.[1];
|
|
35
|
+
if (!name?.startsWith(varPrefix)) return null;
|
|
36
|
+
const key = name.slice(varPrefix.length);
|
|
37
|
+
return validKeys.has(key) ? key : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
export const BLUR: VariantScaleEntry = {
|
|
25
41
|
varPrefix: '--blur-',
|
|
26
42
|
options: [
|