import type { Mode, Preset } from '../types'; import { MODES } from '../constants'; import { PALETTE_STEPS } from '../palette'; import type { InvariantSelection, LinkedSelection, ModeTokenState, OverrideCatalogQueries, OverrideDiagnostic, PackageTokenExpression, RecipeDefinition, SelectionId, ThemeSelections, TokenPath, } from './contracts'; import { createOverrideDiagnostic } from './diagnostics'; interface ScaleReference { readonly family: string; readonly stepIndex: number; } export interface SelectionExpressionExpansion { readonly expressions: ModeTokenState; readonly diagnostics: readonly OverrideDiagnostic[]; } function referenceExpression(ref: string): PackageTokenExpression { return { kind: 'reference', ref }; } function asReference(expression: PackageTokenExpression | undefined): string | undefined { return expression?.kind === 'reference' ? expression.ref : undefined; } function parseScaleReference(ref: string): ScaleReference | undefined { const match = /^color\.([^.]+)\.(50|100|200|300|400|500|600|700|800|900|950)$/.exec(ref); if (!match) return undefined; return { family: match[1], stepIndex: PALETTE_STEPS.indexOf(match[2] as (typeof PALETTE_STEPS)[number]), }; } function inverseReference(ref: string): string { const scale = parseScaleReference(ref); if (scale) { return `color.${scale.family}.${PALETTE_STEPS[PALETTE_STEPS.length - 1 - scale.stepIndex]}`; } if (ref === 'color.white') return 'color.black'; if (ref === 'color.black') return 'color.white'; return ref; } function linkedAnchors( selection: LinkedSelection, defaultLight: string, defaultDark: string, ): Readonly> { if (selection.mode === 'unlinked') { return { light: selection.light.ref, dark: selection.dark.ref }; } return { light: selection.source.ref, dark: selection.source.ref === defaultLight ? defaultDark : inverseReference(selection.source.ref), }; } function transformScaleReference( ref: string, defaultAnchor: string, selectedAnchor: string, ): { readonly ref: string; readonly clamped: boolean } { const source = parseScaleReference(ref); const from = parseScaleReference(defaultAnchor); const selected = parseScaleReference(selectedAnchor); if (!source || !from || !selected || source.family !== from.family) { return { ref, clamped: false }; } const requested = selected.stepIndex + (source.stepIndex - from.stepIndex); const index = Math.max(0, Math.min(PALETTE_STEPS.length - 1, requested)); return { ref: `color.${selected.family}.${PALETTE_STEPS[index]}`, clamped: requested !== index, }; } function expandScaleFamily( defaults: Readonly>, anchorPath: TokenPath, selectedAnchor: string, ): { readonly targets: Readonly>; readonly clamped: boolean; } { const defaultAnchor = asReference(defaults[anchorPath]); if (!defaultAnchor) return { targets: defaults, clamped: false }; const targets: Record = {}; let clamped = false; for (const [tokenPath, expression] of Object.entries(defaults)) { if (tokenPath === anchorPath) { targets[tokenPath] = referenceExpression(selectedAnchor); continue; } if (expression.kind !== 'reference') { targets[tokenPath] = expression; continue; } const transformed = transformScaleReference(expression.ref, defaultAnchor, selectedAnchor); clamped ||= transformed.clamped; targets[tokenPath] = referenceExpression(transformed.ref); } return { targets, clamped }; } function scaleEdgeDiagnostic( selectionId: SelectionId, mode: Mode, anchorPath: TokenPath, ): OverrideDiagnostic { return createOverrideDiagnostic('override.rule_violation', { severity: 'warning', phase: 'rule', mode, intentPath: `selections.${selectionId}`, tokenPath: anchorPath, ruleId: `${selectionId}-scale-edge`, message: `${selectionId} reached a scale edge; interaction steps were capped to the nearest package primitive.`, }); } function expandLinkedRecipe( selectionId: SelectionId, selection: LinkedSelection, preset: Preset, recipe: RecipeDefinition, ): SelectionExpressionExpansion { const { anchorPath } = recipe.strategy; const defaultLight = asReference(recipe.byPreset[preset].light.targets[anchorPath]); const defaultDark = asReference(recipe.byPreset[preset].dark.targets[anchorPath]); if (!defaultLight || !defaultDark) { return { expressions: { light: {}, dark: {} }, diagnostics: [] }; } const anchors = linkedAnchors(selection, defaultLight, defaultDark); const expressions: { light: Record; dark: Record; } = { light: {}, dark: {} }; const diagnostics: OverrideDiagnostic[] = []; for (const mode of MODES) { const defaults = recipe.byPreset[preset][mode].targets; switch (recipe.strategy.kind) { case 'scale-family': { const expanded = expandScaleFamily(defaults, anchorPath, anchors[mode]); expressions[mode] = { ...expanded.targets }; if (expanded.clamped) diagnostics.push(scaleEdgeDiagnostic(selectionId, mode, anchorPath)); break; } case 'stepped-anchor': { if (anchors[mode] === (mode === 'light' ? defaultLight : defaultDark)) { expressions[mode] = { ...defaults }; break; } expressions[mode][anchorPath] = referenceExpression(anchors[mode]); const selected = parseScaleReference(anchors[mode]); for (const [index, path] of recipe.strategy.interactionPaths.entries()) { if (!selected) { expressions[mode][path] = defaults[path]; continue; } const direction = mode === 'light' ? recipe.strategy.lightDirection : recipe.strategy.darkDirection; const requested = selected.stepIndex + direction * (index + 1); const stepIndex = Math.max(0, Math.min(PALETTE_STEPS.length - 1, requested)); if (stepIndex !== requested) { diagnostics.push(scaleEdgeDiagnostic(selectionId, mode, anchorPath)); } expressions[mode][path] = referenceExpression( `color.${selected.family}.${PALETTE_STEPS[stepIndex]}`, ); } break; } case 'replace-anchor': expressions[mode] = { ...defaults, [anchorPath]: referenceExpression(anchors[mode]) }; break; case 'invariant-reference': break; } } return { expressions, diagnostics }; } function expandInvariantRecipe( selection: InvariantSelection, preset: Preset, recipe: RecipeDefinition, ): SelectionExpressionExpansion { const modeTargets = (mode: Mode): Readonly> => { const defaults = recipe.byPreset[preset][mode].targets; const defaultAnchor = asReference(defaults[recipe.strategy.anchorPath]); if (defaultAnchor === selection.source.ref) return defaults; return Object.fromEntries( Object.keys(defaults).map((path) => [path, referenceExpression(selection.source.ref)]), ); }; return { expressions: { light: modeTargets('light'), dark: modeTargets('dark'), }, diagnostics: [], }; } export function expandSelectionExpressions( selectionId: SelectionId, value: ThemeSelections[SelectionId], preset: Preset, catalogs: OverrideCatalogQueries, ): SelectionExpressionExpansion { if (value === undefined) return { expressions: { light: {}, dark: {} }, diagnostics: [] }; const definition = catalogs.getSelection(selectionId); const recipe = definition && catalogs.getRecipe(definition.recipeId); if (!recipe) return { expressions: { light: {}, dark: {} }, diagnostics: [] }; switch (recipe.strategy.kind) { case 'invariant-reference': return expandInvariantRecipe(value as InvariantSelection, preset, recipe); case 'scale-family': case 'replace-anchor': case 'stepped-anchor': return expandLinkedRecipe(selectionId, value as LinkedSelection, preset, recipe); } }