From c686149dd2ea93d50758f670bb8ed71d94c2cb41 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Thu, 11 Dec 2025 14:02:43 +0300 Subject: [PATCH 1/2] Support aliases in composite token components --- src/schema.ts | 168 ++++++++++++++++-- src/state.svelte.ts | 253 ++++++++++++++++++++++----- src/state.test.ts | 410 +++++++++++++++++++++++++++++++++++++++++++ src/tokens.test.ts | 412 +++++++++++++++++++++++++++++++++++++++++++- src/tokens.ts | 8 +- 5 files changed, 1185 insertions(+), 66 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 26334e9..9c5d46f 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -35,6 +35,11 @@ const ColorSchema = z.object({ value: ColorValueSchema, }); +const RawColorSchema = z.object({ + type: z.literal("color"), + value: z.union([ColorValueSchema, z.string()]), +}); + const DimensionValueSchema = z.object({ value: z.number(), unit: z.enum(["px", "rem"]), @@ -47,6 +52,11 @@ const DimensionSchema = z.object({ value: DimensionValueSchema, }); +const RawDimensionSchema = z.object({ + type: z.literal("dimension"), + value: z.union([DimensionValueSchema, z.string()]), +}); + const DurationValueSchema = z.object({ value: z.number(), unit: z.enum(["ms", "s"]), @@ -59,11 +69,21 @@ const DurationSchema = z.object({ value: DurationValueSchema, }); +const RawDurationSchema = z.object({ + type: z.literal("duration"), + value: z.union([DurationValueSchema, z.string()]), +}); + const NumberSchema = z.object({ type: z.literal("number"), value: z.number(), }); +const RawNumberSchema = z.object({ + type: z.literal("number"), + value: z.union([z.number(), z.string()]), +}); + const CubicBezierValueSchema = z .tuple([z.number(), z.number(), z.number(), z.number()]) .refine( @@ -78,6 +98,11 @@ const CubicBezierSchema = z.object({ value: CubicBezierValueSchema, }); +const RawCubicBezierSchema = z.object({ + type: z.literal("cubicBezier"), + value: z.union([CubicBezierValueSchema, z.string()]), +}); + const FontFamilyValueSchema = z.union([z.string(), z.array(z.string())]); export type FontFamilyValue = z.infer; @@ -87,6 +112,11 @@ const FontFamilySchema = z.object({ value: FontFamilyValueSchema, }); +const RawFontFamilySchema = z.object({ + type: z.literal("fontFamily"), + value: z.union([FontFamilyValueSchema, z.string()]), +}); + const FontWeightValueSchema = z.union([z.number(), z.string()]); const FontWeightSchema = z.object({ @@ -94,17 +124,9 @@ const FontWeightSchema = z.object({ value: FontWeightValueSchema, }); -const TransitionValueSchema = z.object({ - duration: DurationValueSchema, - delay: DurationValueSchema, - timingFunction: CubicBezierValueSchema, -}); - -export type TransitionValue = z.infer; - -const TransitionSchema = z.object({ - type: z.literal("transition"), - value: TransitionValueSchema, +const RawFontWeightSchema = z.object({ + type: z.literal("fontWeight"), + value: z.union([FontWeightValueSchema, z.string()]), }); const StrokeStyleValueSchema = z.union([ @@ -133,6 +155,36 @@ const StrokeStyleSchema = z.object({ value: StrokeStyleValueSchema, }); +const RawStrokeStyleSchema = z.object({ + type: z.literal("strokeStyle"), + value: z.union([StrokeStyleValueSchema, z.string()]), +}); + +const TransitionValueSchema = z.object({ + duration: DurationValueSchema, + delay: DurationValueSchema, + timingFunction: CubicBezierValueSchema, +}); + +export type TransitionValue = z.infer; + +const TransitionSchema = z.object({ + type: z.literal("transition"), + value: TransitionValueSchema, +}); + +const RawTransitionSchema = z.object({ + type: z.literal("transition"), + value: z.union([ + z.object({ + duration: z.union([DurationValueSchema, z.string()]), + delay: z.union([DurationValueSchema, z.string()]), + timingFunction: z.union([CubicBezierValueSchema, z.string()]), + }), + z.string(), // token reference + ]), +}); + export const ShadowItemSchema = z.object({ color: ColorValueSchema, offsetX: DimensionValueSchema, @@ -153,6 +205,23 @@ const ShadowSchema = z.object({ value: ShadowValueSchema, }); +const RawShadowItemSchema = z.object({ + color: z.union([ColorValueSchema, z.string()]), + offsetX: z.union([DimensionValueSchema, z.string()]), + offsetY: z.union([DimensionValueSchema, z.string()]), + blur: z.union([DimensionValueSchema, z.string()]), + spread: z.union([DimensionValueSchema, z.string()]).optional(), + inset: z.boolean().optional(), +}); + +const RawShadowSchema = z.object({ + type: z.literal("shadow"), + value: z.union([ + z.array(RawShadowItemSchema), + z.string(), // token reference + ]), +}); + const BorderValueSchema = z.object({ color: ColorValueSchema, width: DimensionValueSchema, @@ -166,6 +235,18 @@ const BorderSchema = z.object({ value: BorderValueSchema, }); +const RawBorderSchema = z.object({ + type: z.literal("border"), + value: z.union([ + z.object({ + color: z.union([ColorValueSchema, z.string()]), + width: z.union([DimensionValueSchema, z.string()]), + style: z.union([StrokeStyleValueSchema, z.string()]), + }), + z.string(), // token reference + ]), +}); + const TypographyValueSchema = z.object({ fontFamily: FontFamilyValueSchema, fontSize: DimensionValueSchema, @@ -181,12 +262,28 @@ const TypographySchema = z.object({ value: TypographyValueSchema, }); -const GradientStopSchema = z.object({ - color: ColorValueSchema, - position: z.number().min(0).max(1), +const RawTypographySchema = z.object({ + type: z.literal("typography"), + value: z.union([ + z.object({ + fontFamily: z.union([FontFamilyValueSchema, z.string()]), + fontSize: z.union([DimensionValueSchema, z.string()]), + fontWeight: z.union([FontWeightValueSchema, z.string()]), + letterSpacing: z.union([DimensionValueSchema, z.string()]), + lineHeight: z.union([z.number(), z.string()]), + }), + z.string(), // token reference + ]), }); -const GradientValueSchema = z.array(GradientStopSchema); +const GradientPosition = z.number().min(0).max(1); + +const GradientValueSchema = z.array( + z.object({ + color: ColorValueSchema, + position: GradientPosition, + }), +); export type GradientValue = z.infer; @@ -195,7 +292,21 @@ const GradientSchema = z.object({ value: GradientValueSchema, }); +const RawGradientSchema = z.object({ + type: z.literal("gradient"), + value: z.union([ + z.array( + z.object({ + color: z.union([ColorValueSchema, z.string()]), + position: GradientPosition, + }), + ), + z.string(), // token reference + ]), +}); + export const ValueSchema = z.union([ + // primitive tokens ColorSchema, DimensionSchema, DurationSchema, @@ -203,8 +314,9 @@ export const ValueSchema = z.union([ NumberSchema, FontFamilySchema, FontWeightSchema, - TransitionSchema, StrokeStyleSchema, + // composite tokens + TransitionSchema, ShadowSchema, BorderSchema, TypographySchema, @@ -212,3 +324,27 @@ export const ValueSchema = z.union([ ]); export type Value = z.infer; + +export const RawValueSchema = z.union([ + // primitive tokens + RawColorSchema, + RawDimensionSchema, + RawDurationSchema, + RawCubicBezierSchema, + RawNumberSchema, + RawFontFamilySchema, + RawFontWeightSchema, + RawStrokeStyleSchema, + // composite tokens + RawTransitionSchema, + RawShadowSchema, + RawBorderSchema, + RawTypographySchema, + RawGradientSchema, +]); + +export type RawValue = z.infer; + +/* make sure Value and Raw Value are in sync */ +(({}) as unknown as Value)["type"] satisfies RawValue["type"]; +(({}) as unknown as RawValue)["type"] satisfies Value["type"]; diff --git a/src/state.svelte.ts b/src/state.svelte.ts index 7458634..5f92019 100644 --- a/src/state.svelte.ts +++ b/src/state.svelte.ts @@ -1,6 +1,7 @@ +import { formatError } from "zod"; import { createSubscriber } from "svelte/reactivity"; import { TreeStore, type Transaction, type TreeNode } from "./store"; -import { type Value, ValueSchema } from "./schema"; +import { type RawValue, type Value, ValueSchema } from "./schema"; import { isTokenReference, serializeDesignTokens } from "./tokens"; import { setDataInUrl } from "./url-data"; @@ -17,7 +18,7 @@ export type TokenMeta = { nodeType: "token"; name: string; type?: Value["type"]; - value: string | Value["value"]; + value: RawValue["value"]; description?: string; deprecated?: boolean | string; extensions?: Record; @@ -48,41 +49,13 @@ export const findTokenType = ( }; /** - * "extends" resolution algorithm for aliases - * - * Parse reference: Extract token path from {group.token} - * Split path: Convert to segments ["group", "token"] - * Navigate to token: Find the target token object - * Validate token: Ensure target has $value property - * Return token value: Extract and return the $value content - * Check for cycles: Maintain stack of resolving references + * Helper function to resolve a single token reference */ -export const resolveTokenValue = ( - node: TreeNode, +const resolveTokenReference = ( + reference: string, nodes: Map>, - resolvingStack: Set = new Set(), -): Value => { - if (node.meta.nodeType !== "token") { - throw new Error("resolveTokenValue requires a token node"); - } - const token = node.meta; - // Check if value is a token reference string - const isReference = isTokenReference(token.value); - // If not a reference, resolve composite components if needed - if (!isReference) { - const resolvedType = token.type ?? findTokenType(node, nodes); - if (!resolvedType) { - throw new Error(`Token "${token.name}" has no determinable type`); - } - // Validate resolved value - const parsed = ValueSchema.parse({ - type: resolvedType, - value: token.value, - }); - return parsed; - } - // Handle token reference - const reference = token.value as string; + resolvingStack: Set, +): TreeNode => { // check for circular references if (resolvingStack.has(reference)) { throw new Error( @@ -108,16 +81,209 @@ export const resolveTokenValue = ( } // final token node const tokenNode = currentNodeId ? nodes.get(currentNodeId) : undefined; - if (tokenNode?.meta.nodeType !== "token") { + if (!tokenNode || tokenNode.meta.nodeType !== "token") { throw new Error( `Final token node not found while resolving "${reference}"`, ); } - // resolve token further if it's also a reference - const newStack = new Set(resolvingStack); - newStack.add(reference); - const resolved = resolveTokenValue(tokenNode, nodes, newStack); - return resolved; + return tokenNode; +}; + +const resolveRawValue = < + Input extends RawValue, + Output extends Extract, +>( + tokenValue: Input, + nodes: Map>, + resolvingStack: Set, +): Output => { + if (isTokenReference(tokenValue.value)) { + const reference = tokenValue.value; + const tokenNode = resolveTokenReference(reference, nodes, resolvingStack); + const newStack = new Set(resolvingStack); + newStack.add(reference); + return resolveTokenValue(tokenNode, nodes, newStack) as Output; + } + switch (tokenValue.type) { + case "transition": + return { + type: "transition", + value: { + duration: resolveRawValue( + { type: "duration", value: tokenValue.value.duration }, + nodes, + resolvingStack, + ).value, + delay: resolveRawValue( + { type: "duration", value: tokenValue.value.delay }, + nodes, + resolvingStack, + ).value, + timingFunction: resolveRawValue( + { type: "cubicBezier", value: tokenValue.value.timingFunction }, + nodes, + resolvingStack, + ).value, + }, + } satisfies Value as Output; + case "border": + return { + type: "border", + value: { + color: resolveRawValue( + { type: "color", value: tokenValue.value.color }, + nodes, + resolvingStack, + ).value, + width: resolveRawValue( + { type: "dimension", value: tokenValue.value.width }, + nodes, + resolvingStack, + ).value, + style: resolveRawValue( + { type: "strokeStyle", value: tokenValue.value.style }, + nodes, + resolvingStack, + ).value, + }, + } satisfies Value as Output; + case "shadow": + return { + type: "shadow", + value: tokenValue.value.map((shadow) => ({ + color: resolveRawValue( + { type: "color", value: shadow.color }, + nodes, + resolvingStack, + ).value, + offsetX: resolveRawValue( + { type: "dimension", value: shadow.offsetX }, + nodes, + resolvingStack, + ).value, + offsetY: resolveRawValue( + { type: "dimension", value: shadow.offsetY }, + nodes, + resolvingStack, + ).value, + blur: resolveRawValue( + { type: "dimension", value: shadow.blur }, + nodes, + resolvingStack, + ).value, + spread: shadow.spread + ? resolveRawValue( + { type: "dimension", value: shadow.spread }, + nodes, + resolvingStack, + ).value + : undefined, + inset: shadow.inset, + })), + } satisfies Value as Output; + case "typography": + return { + type: "typography", + value: { + fontFamily: resolveRawValue( + { type: "fontFamily", value: tokenValue.value.fontFamily }, + nodes, + resolvingStack, + ).value, + fontSize: resolveRawValue( + { type: "dimension", value: tokenValue.value.fontSize }, + nodes, + resolvingStack, + ).value, + fontWeight: resolveRawValue( + { type: "fontWeight", value: tokenValue.value.fontWeight }, + nodes, + resolvingStack, + ).value, + letterSpacing: resolveRawValue( + { type: "dimension", value: tokenValue.value.letterSpacing }, + nodes, + resolvingStack, + ).value, + lineHeight: resolveRawValue( + { type: "number", value: tokenValue.value.lineHeight }, + nodes, + resolvingStack, + ).value, + }, + } satisfies Value as Output; + case "gradient": + return { + type: "gradient", + value: tokenValue.value.map((gradient) => ({ + color: resolveRawValue( + { type: "color", value: gradient.color }, + nodes, + resolvingStack, + ).value, + position: gradient.position, + })), + } satisfies Value as Output; + default: + tokenValue.type satisfies + | "number" + | "color" + | "dimension" + | "duration" + | "cubicBezier" + | "fontFamily" + | "fontWeight" + | "strokeStyle"; + return { type: tokenValue.type, value: tokenValue.value } as Output; + } +}; + +/** + * "extends" resolution algorithm for aliases + * + * Parse reference: Extract token path from {group.token} + * Split path: Convert to segments ["group", "token"] + * Navigate to token: Find the target token object + * Validate token: Ensure target has $value property + * Return token value: Extract and return the $value content + * Check for cycles: Maintain stack of resolving references + */ +export const resolveTokenValue = ( + node: TreeNode, + nodes: Map>, + resolvingStack: Set = new Set(), +): Value => { + if (node.meta.nodeType !== "token") { + throw new Error("resolveTokenValue requires a token node"); + } + const token = node.meta; + // Check if value is a token reference string + // If not a reference, resolve composite components if needed + if (isTokenReference(token.value)) { + // Handle token reference + const reference = token.value; + const tokenNode = resolveTokenReference(reference, nodes, resolvingStack); + // resolve token further if it's also a reference + const newStack = new Set(resolvingStack); + newStack.add(reference); + return resolveTokenValue(tokenNode, nodes, newStack); + } + const resolvedType = token.type ?? findTokenType(node, nodes); + if (!resolvedType) { + throw new Error(`Token "${token.name}" has no determinable type`); + } + // Resolve any component-level references in composite values + const resolvedValue = resolveRawValue( + { type: resolvedType, value: node.meta.value } as RawValue, + nodes, + resolvingStack, + ); + // Validate resolved value + const parsed = ValueSchema.safeParse(resolvedValue); + if (!parsed.success) { + throw Error(formatError(parsed.error)._errors.join("\n")); + } + return parsed.data; }; /** @@ -138,10 +304,7 @@ export const isAliasCircular = ( const targetTokenMeta = targetNode.meta; // If target doesn't have a reference value, it's safe - const isTargetReference = - typeof targetTokenMeta.value === "string" && - isTokenReference(targetTokenMeta.value); - if (!isTargetReference) { + if (!isTokenReference(targetTokenMeta.value)) { return false; } diff --git a/src/state.test.ts b/src/state.test.ts index 54005fe..c6b2045 100644 --- a/src/state.test.ts +++ b/src/state.test.ts @@ -761,6 +761,416 @@ describe("resolveTokenValue", () => { value: 0, }); }); + + test("should resolve shadow with component aliases", () => { + const colorToken: TreeNode = { + nodeId: "color-node", + parentId: "colors-group", + index: "a0", + meta: { + nodeType: "token", + name: "black", + type: "color", + value: { colorSpace: "srgb", components: [0, 0, 0, 0.2] }, + }, + }; + const colorsGroup: TreeNode = { + nodeId: "colors-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "colors" }, + }; + const spacingToken: TreeNode = { + nodeId: "spacing-node", + parentId: "spacing-group", + index: "a0", + meta: { + nodeType: "token", + name: "md", + type: "dimension", + value: { value: 4, unit: "px" }, + }, + }; + const spacingGroup: TreeNode = { + nodeId: "spacing-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "spacing" }, + }; + const shadowToken: TreeNode = { + nodeId: "shadow-node", + parentId: "shadows-group", + index: "a0", + meta: { + nodeType: "token", + name: "primary", + type: "shadow", + value: [ + { + color: "{colors.black}", + offsetX: "{spacing.md}", + offsetY: "{spacing.md}", + blur: { value: 8, unit: "px" }, + spread: { value: 0, unit: "px" }, + inset: false, + }, + ], + }, + }; + const shadowsGroup: TreeNode = { + nodeId: "shadows-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "shadows" }, + }; + const nodes = createNodesMap([ + colorToken, + colorsGroup, + spacingToken, + spacingGroup, + shadowToken, + shadowsGroup, + ]); + const resolved = resolveTokenValue(shadowToken, nodes); + expect(resolved).toEqual({ + type: "shadow", + value: [ + { + color: { colorSpace: "srgb", components: [0, 0, 0, 0.2] }, + offsetX: { value: 4, unit: "px" }, + offsetY: { value: 4, unit: "px" }, + blur: { value: 8, unit: "px" }, + spread: { value: 0, unit: "px" }, + inset: false, + }, + ], + }); + }); + + test("should resolve border with component aliases", () => { + const colorToken: TreeNode = { + nodeId: "color-node", + parentId: "colors-group", + index: "a0", + meta: { + nodeType: "token", + name: "gray", + type: "color", + value: { colorSpace: "srgb", components: [0.5, 0.5, 0.5] }, + }, + }; + const colorsGroup: TreeNode = { + nodeId: "colors-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "colors" }, + }; + const spacingToken: TreeNode = { + nodeId: "spacing-node", + parentId: "spacing-group", + index: "a0", + meta: { + nodeType: "token", + name: "sm", + type: "dimension", + value: { value: 1, unit: "px" }, + }, + }; + const spacingGroup: TreeNode = { + nodeId: "spacing-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "spacing" }, + }; + const borderToken: TreeNode = { + nodeId: "border-node", + parentId: "borders-group", + index: "a0", + meta: { + nodeType: "token", + name: "default", + type: "border", + value: { + color: "{colors.gray}", + width: "{spacing.sm}", + style: "solid", + }, + }, + }; + const bordersGroup: TreeNode = { + nodeId: "borders-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "borders" }, + }; + const nodes = createNodesMap([ + colorToken, + colorsGroup, + spacingToken, + spacingGroup, + borderToken, + bordersGroup, + ]); + const resolved = resolveTokenValue(borderToken, nodes); + expect(resolved.type).toBe("border"); + const borderValue = resolved.value as { + color: { colorSpace: string; components: number[] }; + width: { value: number; unit: string }; + style: string; + }; + expect(borderValue.color).toEqual({ + colorSpace: "srgb", + components: [0.5, 0.5, 0.5], + }); + expect(borderValue.width).toEqual({ value: 1, unit: "px" }); + expect(borderValue.style).toBe("solid"); + }); + + test("should resolve typography with component aliases", () => { + const fontToken: TreeNode = { + nodeId: "font-node", + parentId: "fonts-group", + index: "a0", + meta: { + nodeType: "token", + name: "body", + type: "fontFamily", + value: "sans-serif", + }, + }; + const fontsGroup: TreeNode = { + nodeId: "fonts-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "fonts" }, + }; + const spacingToken: TreeNode = { + nodeId: "spacing-node", + parentId: "spacing-group", + index: "a0", + meta: { + nodeType: "token", + name: "md", + type: "dimension", + value: { value: 16, unit: "px" }, + }, + }; + const spacingGroup: TreeNode = { + nodeId: "spacing-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "spacing" }, + }; + const typographyToken: TreeNode = { + nodeId: "typography-node", + parentId: "typography-group", + index: "a0", + meta: { + nodeType: "token", + name: "base", + type: "typography", + value: { + fontFamily: "{fonts.body}", + fontSize: "{spacing.md}", + fontWeight: 400, + lineHeight: 1.5, + letterSpacing: { value: 0, unit: "px" }, + }, + }, + }; + const typographyGroup: TreeNode = { + nodeId: "typography-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "typography" }, + }; + const nodes = createNodesMap([ + fontToken, + fontsGroup, + spacingToken, + spacingGroup, + typographyToken, + typographyGroup, + ]); + const resolved = resolveTokenValue(typographyToken, nodes); + expect(resolved.type).toBe("typography"); + const typographyValue = resolved.value as { + fontFamily: string; + fontSize: { value: number; unit: string }; + fontWeight: number; + lineHeight: number; + letterSpacing: { value: number; unit: string }; + }; + expect(typographyValue.fontFamily).toBe("sans-serif"); + expect(typographyValue.fontSize).toEqual({ value: 16, unit: "px" }); + expect(typographyValue.fontWeight).toBe(400); + expect(typographyValue.lineHeight).toBe(1.5); + }); + + test("should resolve transition with component aliases", () => { + const durationToken: TreeNode = { + nodeId: "duration-node", + parentId: "durations-group", + index: "a0", + meta: { + nodeType: "token", + name: "quick", + type: "duration", + value: { value: 300, unit: "ms" }, + }, + }; + const durationsGroup: TreeNode = { + nodeId: "durations-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "durations" }, + }; + const easingToken: TreeNode = { + nodeId: "easing-node", + parentId: "easing-group", + index: "a0", + meta: { + nodeType: "token", + name: "ease", + type: "cubicBezier", + value: [0.25, 0.1, 0.25, 1], + }, + }; + const easingGroup: TreeNode = { + nodeId: "easing-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "easing" }, + }; + const delayToken: TreeNode = { + nodeId: "delay-node", + parentId: "durations-group", + index: "a1", + meta: { + nodeType: "token", + name: "slowDelay", + type: "duration", + value: { value: 100, unit: "ms" }, + }, + }; + const transitionToken: TreeNode = { + nodeId: "transition-node", + parentId: "transitions-group", + index: "a0", + meta: { + nodeType: "token", + name: "smooth", + type: "transition", + value: { + duration: "{durations.quick}", + delay: "{durations.slowDelay}", + timingFunction: "{easing.ease}", + }, + }, + }; + const transitionsGroup: TreeNode = { + nodeId: "transitions-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "transitions" }, + }; + const nodes = createNodesMap([ + durationToken, + durationsGroup, + easingToken, + easingGroup, + delayToken, + transitionToken, + transitionsGroup, + ]); + expect(resolveTokenValue(transitionToken, nodes)).toEqual({ + type: "transition", + value: { + duration: { value: 300, unit: "ms" }, + delay: { value: 100, unit: "ms" }, + timingFunction: [0.25, 0.1, 0.25, 1], + }, + }); + }); + + test("should resolve gradient with component aliases", () => { + const redToken: TreeNode = { + nodeId: "red-node", + parentId: "colors-group", + index: "a0", + meta: { + nodeType: "token", + name: "red", + type: "color", + value: { colorSpace: "srgb", components: [1, 0, 0] }, + }, + }; + const blueToken: TreeNode = { + nodeId: "blue-node", + parentId: "colors-group", + index: "a1", + meta: { + nodeType: "token", + name: "blue", + type: "color", + value: { colorSpace: "srgb", components: [0, 0, 1] }, + }, + }; + const colorsGroup: TreeNode = { + nodeId: "colors-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "colors" }, + }; + const gradientToken: TreeNode = { + nodeId: "gradient-node", + parentId: "gradients-group", + index: "a0", + meta: { + nodeType: "token", + name: "redToBlue", + type: "gradient", + value: [ + { + color: "{colors.red}", + position: 0, + }, + { + color: "{colors.blue}", + position: 1, + }, + ], + }, + }; + const gradientsGroup: TreeNode = { + nodeId: "gradients-group", + parentId: undefined, + index: "a0", + meta: { nodeType: "token-group", name: "gradients" }, + }; + const nodes = createNodesMap([ + redToken, + blueToken, + colorsGroup, + gradientToken, + gradientsGroup, + ]); + const resolved = resolveTokenValue(gradientToken, nodes); + expect(resolved.type).toBe("gradient"); + const gradientValue = resolved.value as Array<{ + color: { colorSpace: string; components: number[] }; + position: number; + }>; + expect(Array.isArray(gradientValue)).toBe(true); + expect(gradientValue[0].color).toEqual({ + colorSpace: "srgb", + components: [1, 0, 0], + }); + expect(gradientValue[1].color).toEqual({ + colorSpace: "srgb", + components: [0, 0, 1], + }); + }); }); describe("isAliasCircular", () => { diff --git a/src/tokens.test.ts b/src/tokens.test.ts index aa2aefc..6790434 100644 --- a/src/tokens.test.ts +++ b/src/tokens.test.ts @@ -255,7 +255,7 @@ describe("parseDesignTokens", () => { const result = parseDesignTokens({ myNumber: { $type: "number", - $value: "not a number", + $value: true, }, }); expect(result.nodes).toHaveLength(0); @@ -1191,4 +1191,414 @@ describe("serializeDesignTokens", () => { }, }); }); + + test("accepts shadow with component aliases", () => { + const result = parseDesignTokens({ + colors: { + $type: "color", + black: { + $value: { colorSpace: "srgb", components: [0, 0, 0, 0.2] }, + }, + }, + spacing: { + $type: "dimension", + md: { + $value: { value: 4, unit: "px" }, + }, + }, + shadows: { + $type: "shadow", + primary: { + $value: { + color: "{colors.black}", + offsetX: "{spacing.md}", + offsetY: "{spacing.md}", + blur: { value: 8, unit: "px" }, + spread: { value: 0, unit: "px" }, + inset: false, + }, + }, + }, + }); + expect(result.errors).toHaveLength(0); + expect(result.nodes).toHaveLength(6); + const shadowToken = result.nodes.find( + (n) => n.meta.nodeType === "token" && n.meta.name === "primary", + ); + expect(shadowToken?.meta).toEqual( + expect.objectContaining({ + nodeType: "token", + name: "primary", + type: "shadow", + value: [ + expect.objectContaining({ + color: "{colors.black}", + offsetX: "{spacing.md}", + offsetY: "{spacing.md}", + blur: { value: 8, unit: "px" }, + }), + ], + }), + ); + }); + + test("accepts border with component aliases", () => { + const result = parseDesignTokens({ + colors: { + $type: "color", + gray: { + $value: { colorSpace: "srgb", components: [0.5, 0.5, 0.5] }, + }, + }, + spacing: { + $type: "dimension", + sm: { + $value: { value: 1, unit: "px" }, + }, + }, + borders: { + $type: "border", + default: { + $value: { + color: "{colors.gray}", + width: "{spacing.sm}", + style: "solid", + }, + }, + }, + }); + expect(result.errors).toHaveLength(0); + expect(result.nodes).toHaveLength(6); + const borderToken = result.nodes.find( + (n) => n.meta.nodeType === "token" && n.meta.name === "default", + ); + expect(borderToken?.meta).toEqual( + expect.objectContaining({ + nodeType: "token", + name: "default", + type: "border", + value: expect.objectContaining({ + color: "{colors.gray}", + width: "{spacing.sm}", + style: "solid", + }), + }), + ); + }); + + test("accepts typography with component aliases", () => { + const result = parseDesignTokens({ + fonts: { + $type: "fontFamily", + body: { + $value: "sans-serif", + }, + }, + spacing: { + $type: "dimension", + md: { + $value: { value: 16, unit: "px" }, + }, + }, + typography: { + $type: "typography", + base: { + $value: { + fontFamily: "{fonts.body}", + fontSize: "{spacing.md}", + fontWeight: 400, + lineHeight: 1.5, + letterSpacing: { value: 0, unit: "px" }, + }, + }, + }, + }); + expect(result.errors).toHaveLength(0); + expect(result.nodes).toHaveLength(6); + const typographyToken = result.nodes.find( + (n) => n.meta.nodeType === "token" && n.meta.name === "base", + ); + expect(typographyToken?.meta).toEqual( + expect.objectContaining({ + nodeType: "token", + name: "base", + type: "typography", + value: expect.objectContaining({ + fontFamily: "{fonts.body}", + fontSize: "{spacing.md}", + fontWeight: 400, + lineHeight: 1.5, + letterSpacing: { value: 0, unit: "px" }, + }), + }), + ); + }); + + test("accepts transition with component aliases", () => { + const result = parseDesignTokens({ + durations: { + $type: "duration", + quick: { + $value: { value: 300, unit: "ms" }, + }, + slowDelay: { + $value: { value: 100, unit: "ms" }, + }, + }, + easing: { + $type: "cubicBezier", + ease: { + $value: [0.25, 0.1, 0.25, 1], + }, + }, + transitions: { + $type: "transition", + smooth: { + $value: { + duration: "{durations.quick}", + delay: "{durations.slowDelay}", + timingFunction: "{easing.ease}", + }, + }, + }, + }); + expect(result.errors).toHaveLength(0); + expect(result.nodes).toHaveLength(7); + const transitionToken = result.nodes.find( + (n) => n.meta.nodeType === "token" && n.meta.name === "smooth", + ); + expect(transitionToken?.meta).toEqual( + expect.objectContaining({ + nodeType: "token", + name: "smooth", + type: "transition", + value: expect.objectContaining({ + duration: "{durations.quick}", + delay: "{durations.slowDelay}", + timingFunction: "{easing.ease}", + }), + }), + ); + }); + + test("accepts gradient with component aliases", () => { + const result = parseDesignTokens({ + colors: { + $type: "color", + red: { + $value: { colorSpace: "srgb", components: [1, 0, 0] }, + }, + blue: { + $value: { colorSpace: "srgb", components: [0, 0, 1] }, + }, + }, + gradients: { + $type: "gradient", + redToBlue: { + $value: [ + { + color: "{colors.red}", + position: 0, + }, + { + color: "{colors.blue}", + position: 1, + }, + ], + }, + }, + }); + expect(result.errors).toHaveLength(0); + expect(result.nodes).toHaveLength(5); + const gradientToken = result.nodes.find( + (n) => n.meta.nodeType === "token" && n.meta.name === "redToBlue", + ); + expect(gradientToken?.meta.nodeType).toBe("token"); + if (gradientToken?.meta.nodeType === "token") { + expect(gradientToken.meta).toEqual( + expect.objectContaining({ + nodeType: "token", + name: "redToBlue", + type: "gradient", + }), + ); + const gradientValue = gradientToken.meta.value as Array<{ + color: string | object; + position: number; + }>; + expect(Array.isArray(gradientValue)).toBe(true); + expect(gradientValue[0]).toEqual( + expect.objectContaining({ + color: "{colors.red}", + position: 0, + }), + ); + expect(gradientValue[1]).toEqual( + expect.objectContaining({ + color: "{colors.blue}", + position: 1, + }), + ); + } + }); + + test("serializes shadow with component aliases", () => { + const input = { + colors: { + $type: "color", + black: { + $value: { colorSpace: "srgb", components: [0, 0, 0, 0.2] }, + }, + }, + spacing: { + $type: "dimension", + md: { + $value: { value: 4, unit: "px" }, + }, + }, + shadows: { + $type: "shadow", + primary: { + $value: { + color: "{colors.black}", + offsetX: "{spacing.md}", + offsetY: "{spacing.md}", + blur: { value: 8, unit: "px" }, + spread: { value: 0, unit: "px" }, + inset: false, + }, + }, + }, + }; + const parsed = parseDesignTokens(input); + const serialized = serializeDesignTokens(nodesToMap(parsed.nodes)); + expect(serialized).toEqual(input); + }); + + test("serializes border with component aliases", () => { + const input = { + colors: { + $type: "color", + gray: { + $value: { colorSpace: "srgb", components: [0.5, 0.5, 0.5] }, + }, + }, + spacing: { + $type: "dimension", + sm: { + $value: { value: 1, unit: "px" }, + }, + }, + borders: { + $type: "border", + default: { + $value: { + color: "{colors.gray}", + width: "{spacing.sm}", + style: "solid", + }, + }, + }, + }; + const parsed = parseDesignTokens(input); + const serialized = serializeDesignTokens(nodesToMap(parsed.nodes)); + expect(serialized).toEqual(input); + }); + + test("serializes typography with component aliases", () => { + const input = { + fonts: { + $type: "fontFamily", + body: { + $value: "sans-serif", + }, + }, + spacing: { + $type: "dimension", + md: { + $value: { value: 16, unit: "px" }, + }, + }, + typography: { + $type: "typography", + base: { + $value: { + fontFamily: "{fonts.body}", + fontSize: "{spacing.md}", + fontWeight: 400, + lineHeight: 1.5, + letterSpacing: { value: 0, unit: "px" }, + }, + }, + }, + }; + const parsed = parseDesignTokens(input); + const serialized = serializeDesignTokens(nodesToMap(parsed.nodes)); + expect(serialized).toEqual(input); + }); + + test("serializes transition with component aliases", () => { + const input = { + durations: { + $type: "duration", + quick: { + $value: { value: 300, unit: "ms" }, + }, + slowDelay: { + $value: { value: 100, unit: "ms" }, + }, + }, + easing: { + $type: "cubicBezier", + ease: { + $value: [0.25, 0.1, 0.25, 1], + }, + }, + transitions: { + $type: "transition", + smooth: { + $value: { + duration: "{durations.quick}", + delay: "{durations.slowDelay}", + timingFunction: "{easing.ease}", + }, + }, + }, + }; + const parsed = parseDesignTokens(input); + const serialized = serializeDesignTokens(nodesToMap(parsed.nodes)); + expect(serialized).toEqual(input); + }); + + test("serializes gradient with component aliases", () => { + const input = { + colors: { + $type: "color", + red: { + $value: { colorSpace: "srgb", components: [1, 0, 0] }, + }, + blue: { + $value: { colorSpace: "srgb", components: [0, 0, 1] }, + }, + }, + gradients: { + $type: "gradient", + redToBlue: { + $value: [ + { + color: "{colors.red}", + position: 0, + }, + { + color: "{colors.blue}", + position: 1, + }, + ], + }, + }, + }; + const parsed = parseDesignTokens(input); + const serialized = serializeDesignTokens(nodesToMap(parsed.nodes)); + expect(serialized).toEqual(input); + }); }); diff --git a/src/tokens.ts b/src/tokens.ts index baf2021..2b80792 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -1,7 +1,7 @@ import { generateKeyBetween } from "fractional-indexing"; import { compareTreeNodes, type TreeNode } from "./store"; import type { GroupMeta, TokenMeta } from "./state.svelte"; -import { ValueSchema, type Value } from "./schema"; +import { RawValueSchema, type RawValue } from "./schema"; type TreeNodeMeta = GroupMeta | TokenMeta; @@ -142,7 +142,7 @@ export const parseDesignTokens = (input: unknown): ParseResult => { description, deprecated, extensions, - ...(type && { type: type as Value["type"] }), + ...(type && { type: type as RawValue["type"] }), value, }); return; @@ -164,7 +164,7 @@ export const parseDesignTokens = (input: unknown): ParseResult => { valueToValidate = [valueToValidate]; } - const parsed = ValueSchema.safeParse({ + const parsed = RawValueSchema.safeParse({ type: inheritedType, value: valueToValidate, }); @@ -183,7 +183,7 @@ export const parseDesignTokens = (input: unknown): ParseResult => { extensions, // when value exists always infer and store type in tokens // to alloww groups lock and unlock type freely - type: inheritedType as Value["type"], + type: inheritedType as RawValue["type"], value: parsed.data.value, }); }; From 3223bb9bf582a49b3d37ada5b3f475d8aa923b2c Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Thu, 11 Dec 2025 18:03:47 +0300 Subject: [PATCH 2/2] Add new UI for aliases --- package.json | 1 + pnpm-lock.yaml | 8 + src/alias-token.svelte | 239 ++++++++++++++++++ src/app.css | 47 +++- src/app.d.ts | 7 + src/app.svelte | 1 + src/editor.svelte | 539 ++++++++++++++--------------------------- 7 files changed, 482 insertions(+), 360 deletions(-) create mode 100644 src/alias-token.svelte create mode 100644 src/app.d.ts diff --git a/package.json b/package.json index f814055..73ed133 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dialog-closedby-polyfill": "^1.1.0", "fractional-indexing": "^3.2.0", "hdr-color-input": "^0.2.5", + "interestfor": "^1.0.7", "invokers-polyfill": "^0.5.7", "json-stringify-pretty-compact": "^4.0.0", "keyux": "^0.11.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ab9839..124666a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: hdr-color-input: specifier: ^0.2.5 version: 0.2.5 + interestfor: + specifier: ^1.0.7 + version: 1.0.7 invokers-polyfill: specifier: ^0.5.7 version: 0.5.7 @@ -579,6 +582,9 @@ packages: hdr-color-input@0.2.5: resolution: {integrity: sha512-/3AUOnWIOxh0qJbnFOFa33LnTGgHxiQgiI1KI7LILus0qcMYw07v11GjXnXfmPfntAhVaGXXjNDDhgmiUJ4vWw==} + interestfor@1.0.7: + resolution: {integrity: sha512-qJaN6JMrAjiA+aQ6TkQemZhLx5E8kGI9s0yXAJJbQHSKs0iBKrDc5nMNgGqH1V4x0ZveFJjVbmText/zTI7ZBg==} + invokers-polyfill@0.5.7: resolution: {integrity: sha512-T1MmQ40+ARzC0W3YgyVUyncXIi1G7jnUq1PVdsYNr1ql7etYGHWNr3RKvJABIiFO5Ktk3xK9/A8+1lFASvcrKw==} @@ -1186,6 +1192,8 @@ snapshots: '@preact/signals-core': 1.12.1 colorjs.io: 0.5.2 + interestfor@1.0.7: {} + invokers-polyfill@0.5.7: {} is-reference@3.0.3: diff --git a/src/alias-token.svelte b/src/alias-token.svelte new file mode 100644 index 0000000..95bdf31 --- /dev/null +++ b/src/alias-token.svelte @@ -0,0 +1,239 @@ + + + + +
+ {#if reference} + {reference.replace(/[{}]/g, "").split(".").join(" > ")} + {:else} + Make an alias for another token + {/if} +
+ +
+
+ + { + aliasSearchInput = event.currentTarget.value; + selectedAliasIndex = 0; + }} + onkeydown={handleAliasKeyDown} + /> + {#if reference} + + {/if} +
+ +
+ + diff --git a/src/app.css b/src/app.css index 3d7ea11..7057e47 100644 --- a/src/app.css +++ b/src/app.css @@ -9,17 +9,31 @@ --bg-secondary: #393939; --bg-tertiary: #383838; --bg-hover: #404040; + --bg-reference: oklch(42% 0.2 300); + --bg-reference-hover: oklch(50% 0.2 300); --border-color: #454545; --text-primary: #e6e6e6; --text-secondary: #999999; --accent: #18a0fb; --accent-hover: #27affe; + --popover-shadow: + 0 2px 5px 0 rgb(0 0 0 / 35%), inset 0 0 0.5px 0 rgb(255 255 255 / 35%), + 0 10px 16px 0 rgb(0 0 0 / 35%), inset 0 0.5px 0 0 rgb(255 255 255 / 8%); --panel-header-height: 40px; font-family: var(--typography-geometric-humanist); accent-color: var(--bg-secondary); } +/* reduce show delay when another tooltip is shown already */ +:root:has([interestfor]:has-interest) [interestfor] { + interest-show-delay: 100ms; +} + +:root:has([interestfor].has-interest) [interestfor] { + --interest-show-delay: 100ms; +} + * { box-sizing: border-box; scrollbar-width: thin; @@ -119,19 +133,26 @@ color-input::part(trigger) { align-items: center; justify-content: center; border: 1px solid transparent; - background: transparent; + background-color: transparent; border-radius: 4px; color: var(--text-secondary); transition: all 0.2s ease; font-family: inherit; font-size: 14px; font-weight: 600; + + &[aria-pressed="true"] { + background-color: var(--bg-reference); + &:hover { + background-color: var(--bg-reference-hover); + } + } } .a-button:hover, /* cannot use css nesting inside of pseudo-element */ color-input::part(trigger):hover { - background: var(--bg-hover); + background-color: var(--bg-hover); color: var(--text-primary); } @@ -166,20 +187,30 @@ color-input::part(trigger):focus-visible { background: var(--bg-primary); border: 0; padding: 0; - box-shadow: - 0 2px 5px 0 rgb(0 0 0 / 35%), - inset 0 0 0.5px 0 rgb(255 255 255 / 35%), - 0 10px 16px 0 rgb(0 0 0 / 35%), - inset 0 0.5px 0 0 rgb(255 255 255 / 8%); + box-shadow: var(--popover-shadow); } .a-menu { - position-area: center bottom; + position-area: bottom; position-try-fallbacks: flip-block; margin-inline: 0; margin-block: 8px; } +.a-tooltip { + width: max-content; + background: var(--bg-secondary); + color: var(--text-primary); + border: 0; + padding: 4px 12px; + margin-inline: 0; + margin-block: 8px; + border-radius: 4px; + box-shadow: var(--popover-shadow); + position-area: center top; + position-try-fallbacks: flip-block; +} + .a-item { display: block; width: 100%; diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..0f70856 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,7 @@ +declare module "svelte/elements" { + export interface DOMAttributes { + interestfor?: string; + } +} + +export {}; diff --git a/src/app.svelte b/src/app.svelte index acf2e4c..c990652 100644 --- a/src/app.svelte +++ b/src/app.svelte @@ -3,6 +3,7 @@ import "invokers-polyfill"; import "dialog-closedby-polyfill"; import "hdr-color-input"; + import "interestfor"; {#snippet dimensionEditor( @@ -511,13 +394,29 @@
- handleNameChange(e.currentTarget.value)} - /> +
+ handleNameChange(e.currentTarget.value)} + /> + {#if node?.meta.nodeType === "token" && tokenValue} + { + /* set resolved value when reference is removed */ + updateMeta({ value: newReference ?? tokenValue.value }); + }} + /> + {/if} +
@@ -582,86 +481,12 @@ {/if}
- {#if meta?.nodeType === "token" && availableTokens.length > 0} -
- -
- { - aliasSearchInput = event.currentTarget.value; - selectedAliasIndex = 0; - aliasPopoverElement?.showPopover(); - }} - onkeydown={handleAliasKeyDown} - onclick={() => aliasPopoverElement?.showPopover()} - onfocus={() => aliasPopoverElement?.showPopover()} - onblur={() => { - // Clear search after a brief delay to allow click handling - setTimeout(() => { - aliasSearchInput = ""; - selectedAliasIndex = 0; - aliasPopoverElement?.hidePopover(); - }, 200); - }} - /> - {#if isAlias} - - {/if} -
- - - -
- {/if} - {#if tokenValue?.type === "color"}
{ // track both open and close because of bug in css-color-component const input = event.target as HTMLInputElement; @@ -685,7 +510,6 @@ class="a-field dimension-value" type="number" value={tokenValue.value.value} - disabled={isAlias} oninput={(e) => { const value = Number.parseFloat(e.currentTarget.value); if (!Number.isNaN(value)) { @@ -699,7 +523,6 @@ id="dimension-unit-input" class="a-field dimension-unit-select" value={tokenValue.value.unit} - disabled={isAlias} onchange={(e) => { updateMeta({ value: { @@ -726,7 +549,6 @@ class="a-field duration-value" type="number" value={tokenValue.value.value} - disabled={isAlias} oninput={(e) => { const value = Number.parseFloat(e.currentTarget.value); if (!Number.isNaN(value)) { @@ -740,7 +562,6 @@ id="duration-unit-input" class="a-field duration-unit-select" value={tokenValue.value.unit} - disabled={isAlias} onchange={(e) => { updateMeta({ value: { @@ -765,7 +586,6 @@ class="a-field" type="number" value={tokenValue.value} - disabled={isAlias} oninput={(e) => { const value = Number.parseFloat(e.currentTarget.value); if (!Number.isNaN(value)) { @@ -781,13 +601,9 @@
- {@render fontFamilyEditor( - tokenValue.value, - (value) => { - updateMeta({ value }); - }, - isAlias, - )} + {@render fontFamilyEditor(tokenValue.value, (value) => { + updateMeta({ value }); + })}
{/if} @@ -795,13 +611,9 @@
- {@render fontWeightEditor( - tokenValue.value, - (value) => { - updateMeta({ value }); - }, - isAlias, - )} + {@render fontWeightEditor(tokenValue.value, (value) => { + updateMeta({ value }); + })}
{/if} @@ -811,7 +623,6 @@ { updateMeta({ value }); }} @@ -829,7 +640,6 @@ class="a-field duration-value" type="number" value={tokenValue.value.duration.value} - disabled={isAlias} step="1" placeholder="Value" oninput={(e) => { @@ -850,7 +660,6 @@ { updateMeta({ value: { @@ -923,7 +730,6 @@ { updateMeta({ value: { ...tokenValue.value, timingFunction: value }, @@ -937,79 +743,151 @@
- {@render fontFamilyEditor( - tokenValue.value.fontFamily, - (fontFamily) => { - updateMeta({ - value: { ...tokenValue.value, fontFamily }, - }); - }, - isAlias, - )} + {@render fontFamilyEditor(tokenValue.value.fontFamily, (fontFamily) => { + updateMeta({ + value: { ...tokenValue.value, fontFamily }, + }); + })}
- {@render dimensionEditor( - tokenValue.value.fontSize, - (fontSize) => { +
+ {@render dimensionEditor(tokenValue.value.fontSize, (fontSize) => { updateMeta({ value: { ...tokenValue.value, fontSize }, }); - }, - isAlias, - )} + })} + {#if node?.meta?.nodeType === "token" && !isTokenReference(node.meta.value)} + { + updateMeta({ + value: { + ...(node.meta as any).value, + fontSize: newReference ?? tokenValue.value.fontSize, + }, + }); + }} + /> + {/if} +
- {@render fontWeightEditor( - tokenValue.value.fontWeight, - (fontWeight) => { - updateMeta({ - value: { ...tokenValue.value, fontWeight }, - }); - }, - isAlias, - )} +
+ {@render fontWeightEditor( + tokenValue.value.fontWeight, + (fontWeight) => { + updateMeta({ + value: { ...tokenValue.value, fontWeight }, + }); + }, + )} + {#if node?.meta?.nodeType === "token" && !isTokenReference(node.meta.value)} + { + updateMeta({ + value: { + ...(node.meta as any).value, + fontWeight: newReference ?? tokenValue.value.fontWeight, + }, + }); + }} + /> + {/if} +
- { - const value = Number.parseFloat(e.currentTarget.value); - if (!Number.isNaN(value)) { - updateMeta({ - value: { ...tokenValue.value, lineHeight: value }, - }); - } - }} - step="0.1" - placeholder="e.g., 1.5" - /> +
+ { + const value = Number.parseFloat(e.currentTarget.value); + if (!Number.isNaN(value)) { + updateMeta({ + value: { ...tokenValue.value, lineHeight: value }, + }); + } + }} + step="0.1" + placeholder="e.g., 1.5" + /> + {#if node?.meta?.nodeType === "token" && !isTokenReference(node.meta.value)} + { + updateMeta({ + value: { + ...(node.meta as any).value, + lineHeight: newReference ?? tokenValue.value.lineHeight, + }, + }); + }} + /> + {/if} +
- {@render dimensionEditor( - tokenValue.value.letterSpacing, - (letterSpacing) => { - updateMeta({ - value: { ...tokenValue.value, letterSpacing }, - }); - }, - isAlias, - )} +
+ {@render dimensionEditor( + tokenValue.value.letterSpacing, + (letterSpacing) => { + updateMeta({ + value: { ...tokenValue.value, letterSpacing }, + }); + }, + )} + {#if node?.meta?.nodeType === "token" && !isTokenReference(node.meta.value)} + { + updateMeta({ + value: { + ...(node.meta as any).value, + letterSpacing: + newReference ?? tokenValue.value.letterSpacing, + }, + }); + }} + /> + {/if} +
{/if} @@ -1018,13 +896,9 @@
- {@render strokeStyleEditor( - tokenValue.value, - (value) => { - updateMeta({ value }); - }, - isAlias, - )} + {@render strokeStyleEditor(tokenValue.value, (value) => { + updateMeta({ value }); + })}
{/if} @@ -1042,7 +916,6 @@ class="a-checkbox" type="checkbox" checked={item.inset ?? false} - disabled={isAlias} onchange={(e) => { const updated = [...shadows]; updated[index].inset = e.currentTarget.checked || undefined; @@ -1058,7 +931,6 @@
{/each}