Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 118 additions & 12 deletions packages/tokens/config/tokens.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ export const config: TokensConfig = {
removed_in: '0.3.0',
reason: 'Accent ghost was never in Figma; SPEC §10.D1.',
},
'--fx-glow-success': {
replacement: '--fx-glow-brand',
removed_in: '0.3.0',
reason: 'FX.Glow.Success is not present in Figma Variables.',
},
'--fx-glow-info': {
replacement: '--fx-glow-brand',
removed_in: '0.3.0',
reason: 'FX.Glow.Info is not present in Figma Variables.',
},
'--fx-focus-ring': {
replacement: '--fx-focus-ring-brand',
removed_in: '0.3.0',
reason: 'FX.Focus-ring is split by Figma slot.',
},
'--fx-skeleton': {
replacement: '--fx-skeleton-base',
removed_in: '0.3.0',
reason: 'FX.Skeleton is split into Base and Highlight.',
},
},

colors: {
Expand Down Expand Up @@ -438,6 +458,7 @@ export const config: TokensConfig = {
statics: {
white: { L: 1.0, C: 0, H: 0 },
dark: { L: 0.08, C: 0, H: 0 }, // non-pure black for shadows
skeleton_mid: { L: 0.575, C: 0.012, H: 286 },
},

// ─── Opacity primitive (29 stops) ────────────────────────────────
Expand Down Expand Up @@ -600,40 +621,125 @@ export const config: TokensConfig = {

fx: {
glow: {
Neutral: {
kind: 'direct',
ref: { family: 'static', id: 'white', opacity_stop: 52 },
},
Inverted: {
kind: 'direct',
ref: { family: 'neutral', id: '4' },
},
Brand: {
kind: 'direct',
ref: { family: 'accent', id: 'brand', opacity_stop: 40 },
ref: { family: 'accent', id: 'brand', opacity_stop: 52 },
},
Danger: {
kind: 'direct',
ref: { family: 'accent', id: 'red', opacity_stop: 40 },
ref: { family: 'accent', id: 'red', opacity_stop: 52 },
},
Warning: {
kind: 'direct',
ref: { family: 'accent', id: 'orange', opacity_stop: 40 },
ref: { family: 'accent', id: 'orange', opacity_stop: 52 },
},
},
legacy_glow: {
Success: {
kind: 'direct',
ref: { family: 'accent', id: 'green', opacity_stop: 40 },
ref: { family: 'accent', id: 'green', opacity_stop: 52 },
},
Info: {
kind: 'direct',
ref: { family: 'accent', id: 'blue', opacity_stop: 40 },
ref: { family: 'accent', id: 'blue', opacity_stop: 52 },
},
},
focus_ring: {
Neutral: {
kind: 'mode-branch',
branches: {
'light/normal': { family: 'static', id: 'dark' },
'light/ic': { family: 'static', id: 'dark' },
'dark/normal': { family: 'static', id: 'white' },
'dark/ic': { family: 'static', id: 'white' },
},
},
Brand: {
kind: 'direct',
ref: { family: 'accent', id: 'brand' },
},
Danger: {
kind: 'direct',
ref: { family: 'accent', id: 'red' },
},
Warning: {
kind: 'direct',
ref: { family: 'accent', id: 'orange' },
},
},
focus_ring_legacy: {
kind: 'direct',
ref: { family: 'accent', id: 'brand', opacity_stop: 40 },
ref: { family: 'accent', id: 'brand' },
},
Comment on lines +678 to 681

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Legacy --fx-focus-ring lost opacity_stop during deprecation window, changing alpha from 0.4 to 1.0

The deprecated --fx-focus-ring token's value changed from semi-transparent (alpha=0.4) to fully opaque (alpha=1.0) during its deprecation grace period. The old config at packages/tokens/config/tokens.config.ts:625 (pre-PR) had ref: { family: 'accent', id: 'brand', opacity_stop: 40 }, producing CSS like oklch(0.603 0.218 257.4 / 0.4). The new focus_ring_legacy at line 680 drops opacity_stop, producing oklch(0.603 0.218 257.4) (alpha=1). Any consumer relying on --fx-focus-ring during the grace period (before removed_in: '0.3.0') will see their focus rings go from 40% transparent to fully opaque — a visually breaking change that defeats the purpose of the deprecation window.

Suggested change
focus_ring_legacy: {
kind: 'direct',
ref: { family: 'accent', id: 'brand', opacity_stop: 40 },
ref: { family: 'accent', id: 'brand' },
},
focus_ring_legacy: {
kind: 'direct',
ref: { family: 'accent', id: 'brand', opacity_stop: 40 },
},
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

skeleton: {
kind: 'direct',
ref: { family: 'neutral', id: '6', opacity_stop: 16 },
base: {
kind: 'mode-branch',
branches: {
'light/normal': { family: 'static', id: 'skeleton_mid', opacity_stop: 8 },
'light/ic': { family: 'static', id: 'skeleton_mid', opacity_stop: 12 },
'dark/normal': { family: 'static', id: 'skeleton_mid', opacity_stop: 12 },
'dark/ic': { family: 'static', id: 'skeleton_mid', opacity_stop: 16 },
},
},
highlight: {
kind: 'direct',
ref: { family: 'static', id: 'skeleton_mid', opacity_stop: 4 },
},
legacy: {
kind: 'mode-branch',
branches: {
'light/normal': { family: 'static', id: 'skeleton_mid', opacity_stop: 8 },
'light/ic': { family: 'static', id: 'skeleton_mid', opacity_stop: 12 },
'dark/normal': { family: 'static', id: 'skeleton_mid', opacity_stop: 12 },
'dark/ic': { family: 'static', id: 'skeleton_mid', opacity_stop: 16 },
},
},
},
shadow_tints: {
minor: { family: 'static', id: 'dark', opacity_stop: 1 },
ambient: { family: 'static', id: 'dark', opacity_stop: 2 },
penumbra: { family: 'static', id: 'dark', opacity_stop: 4 },
major: { family: 'static', id: 'dark', opacity_stop: 12 },
minor: {
kind: 'mode-branch',
branches: {
'light/normal': { family: 'static', id: 'dark', opacity_stop: 1 },
'light/ic': { family: 'static', id: 'dark', opacity_stop: 1 },
'dark/normal': { family: 'static', id: 'dark', opacity_stop: 2 },
'dark/ic': { family: 'static', id: 'dark', opacity_stop: 2 },
},
},
ambient: {
kind: 'mode-branch',
branches: {
'light/normal': { family: 'static', id: 'dark', opacity_stop: 2 },
'light/ic': { family: 'static', id: 'dark', opacity_stop: 2 },
'dark/normal': { family: 'static', id: 'dark', opacity_stop: 4 },
'dark/ic': { family: 'static', id: 'dark', opacity_stop: 4 },
},
},
penumbra: {
kind: 'mode-branch',
branches: {
'light/normal': { family: 'static', id: 'dark', opacity_stop: 4 },
'light/ic': { family: 'static', id: 'dark', opacity_stop: 4 },
'dark/normal': { family: 'static', id: 'dark', opacity_stop: 12 },
'dark/ic': { family: 'static', id: 'dark', opacity_stop: 12 },
},
},
major: {
kind: 'mode-branch',
branches: {
'light/normal': { family: 'static', id: 'dark', opacity_stop: 12 },
'light/ic': { family: 'static', id: 'dark', opacity_stop: 12 },
'dark/normal': { family: 'static', id: 'dark', opacity_stop: 20 },
'dark/ic': { family: 'static', id: 'dark', opacity_stop: 20 },
},
},
},
shadow_presets: {
xs: [{ y: 1, blur: 2, spread: 0, tint: 'minor' }],
Expand Down
9 changes: 8 additions & 1 deletion packages/tokens/docs/test-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Auto-generated from `@layer` / `@governs` / `@invariant` headers in every
`tests/**/*.test.ts` file. Run `bun run catalog` to regenerate.

**Total:** 42 test files
**Total:** 43 test files

## Calibration

Expand Down Expand Up @@ -214,6 +214,13 @@ Auto-generated from `@layer` / `@governs` / `@invariant` headers in every
- **Why:** Unified spine model — tiers are solid colors at distinct L targets, not opacity-washes of a single anchor.
- **On fail:** inspect SemanticsConfig — labels must use kind=pipeline without opacity_stop.

### `tests/L4-semantic/fx-contract.test.ts`

- **Governs:** plan-v2 §5.5 · FX
- **Invariant:** FX emits the Figma slot skeleton: Glow has Neutral/Inverted/Brand/Danger/Warning only as canonical slots, Focus-ring is split by neutral/brand/danger/warning, Skeleton is split into base/highlight, and Shadow tint alpha is sector-aware.
- **Why:** Button halo, focus, loading, and shadow recipes are consumed directly by components; collapsed or invented cells break the public contract.
- **On fail:** inspect config.semantics.fx and collectEntries() emission order.

### `tests/references.test.ts`

- **Governs:** plan-v2 §5 · Semantic tree
Expand Down
7 changes: 7 additions & 0 deletions packages/tokens/src/generators/primitive-colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ function generateStatics(colors: ColorsConfig): ResolvedPrimitive[] {
'alias' in darkDef
? { L: 0.08, C: 0, H: 0 } // TODO: resolve alias to neutral step
: darkDef
const skeletonMid = colors.statics.skeleton_mid

const fill = (v: OklchValue): Record<OutputKey, OklchValue> => {
const out: Partial<Record<OutputKey, OklchValue>> = {}
Expand All @@ -250,6 +251,12 @@ function generateStatics(colors: ColorsConfig): ResolvedPrimitive[] {
return [
{ name: 'static-white', group: 'static', id: 'white', values: fill(white) },
{ name: 'static-dark', group: 'static', id: 'dark', values: fill(dark) },
{
name: 'static-skeleton-mid',
group: 'static',
id: 'skeleton_mid',
values: fill(skeletonMid),
},
]
}

Expand Down
66 changes: 47 additions & 19 deletions packages/tokens/src/generators/semantic-colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import type {
ShadowPreset,
ShadowLayerDef,
ShadowPresetsConfig,
ShadowTintsConfig,
TierName,
} from '../types'
import { BASE_MODES, CONTRASTS, outputKey } from '../types'
Expand Down Expand Up @@ -114,10 +113,7 @@ export function generateSemanticColors(
tokens.push(token)
}

const shadow_presets = buildShadowPresets(
semantics.fx.shadow_presets,
semantics.fx.shadow_tints,
)
const shadow_presets = buildShadowPresets(semantics.fx.shadow_presets)

return { tokens, shadow_presets }
}
Expand Down Expand Up @@ -275,36 +271,71 @@ function collectEntries(semantics: SemanticsConfig): SemEntry[] {

// FX
const fx = semantics.fx
for (const [sent, def] of Object.entries(fx.glow)) {
for (const name of ['Neutral', 'Inverted', 'Brand', 'Danger', 'Warning'] as const) {
const def = fx.glow[name]
out.push({
name: `fx-glow-${sent.toLowerCase()}`,
path: `fx.glow.${sent}`,
name: `fx-glow-${name.toLowerCase()}`,
path: `fx.glow.${name}`,
def,
})
}
out.push({ name: 'fx-focus-ring', path: 'fx.focus_ring', def: fx.focus_ring })
out.push({ name: 'fx-skeleton', path: 'fx.skeleton', def: fx.skeleton })
for (const name of ['Success', 'Info'] as const) {
const def = fx.legacy_glow[name]
out.push({
name: `fx-glow-${name.toLowerCase()}`,
path: `fx.legacy_glow.${name}`,
def,
})
}
for (const name of ['Neutral', 'Brand', 'Danger', 'Warning'] as const) {
const def = fx.focus_ring[name]
out.push({
name: `fx-focus-ring-${name.toLowerCase()}`,
path: `fx.focus_ring.${name}`,
def,
})
}
out.push({
name: 'fx-focus-ring',
path: 'fx.focus_ring_legacy',
def: fx.focus_ring_legacy,
})
out.push({
name: 'fx-skeleton-base',
path: 'fx.skeleton.base',
def: fx.skeleton.base,
})
out.push({
name: 'fx-skeleton-highlight',
path: 'fx.skeleton.highlight',
def: fx.skeleton.highlight,
})
out.push({
name: 'fx-skeleton',
path: 'fx.skeleton.legacy',
def: fx.skeleton.legacy,
})

// Shadow tints (flat primitive refs — emit as semantics so consumers can use them)
// Shadow tints (colors used inside progressive shadow preset strings).
out.push({
name: 'fx-shadow-minor',
path: 'fx.shadow_tints.minor',
def: { kind: 'direct', ref: fx.shadow_tints.minor },
def: fx.shadow_tints.minor,
})
out.push({
name: 'fx-shadow-ambient',
path: 'fx.shadow_tints.ambient',
def: { kind: 'direct', ref: fx.shadow_tints.ambient },
def: fx.shadow_tints.ambient,
})
out.push({
name: 'fx-shadow-penumbra',
path: 'fx.shadow_tints.penumbra',
def: { kind: 'direct', ref: fx.shadow_tints.penumbra },
def: fx.shadow_tints.penumbra,
})
out.push({
name: 'fx-shadow-major',
path: 'fx.shadow_tints.major',
def: { kind: 'direct', ref: fx.shadow_tints.major },
def: fx.shadow_tints.major,
})

// Misc
Expand Down Expand Up @@ -389,10 +420,7 @@ const BORDER_TIERS_ACCENT = ['strong', 'base', 'soft', 'ghost'] as const

// ─── Shadow presets ─────────────────────────────────────────────────────

function buildShadowPresets(
presets: ShadowPresetsConfig,
_tints: ShadowTintsConfig,
): ShadowPreset[] {
function buildShadowPresets(presets: ShadowPresetsConfig): ShadowPreset[] {
const tintToVar: Record<ShadowLayerDef['tint'], string> = {
minor: '--fx-shadow-minor',
ambient: '--fx-shadow-ambient',
Expand Down
35 changes: 26 additions & 9 deletions packages/tokens/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,11 @@ export interface StaticsConfig {
* Otherwise an explicit OKLCH value.
*/
dark: OklchValue | { alias: string }
/**
* System mid-gray used by Skeleton and neutral translucent fills.
* Registered as a named primitive so the FX recipe stays auditable.
*/
skeleton_mid: OklchValue
}

// ─── Config: perceptual compensation ────────────────────────────────────
Expand Down Expand Up @@ -579,12 +584,9 @@ export interface ShadowLayerDef {
tint: 'minor' | 'ambient' | 'penumbra' | 'major'
}

export interface ShadowTintsConfig {
minor: PrimitiveRef
ambient: PrimitiveRef
penumbra: PrimitiveRef
major: PrimitiveRef
}
export type ShadowTintName = 'minor' | 'ambient' | 'penumbra' | 'major'

export type ShadowTintsConfig = Record<ShadowTintName, SemanticDef>

export interface ShadowPresetsConfig {
xs: ShadowLayerDef[]
Expand All @@ -594,10 +596,25 @@ export interface ShadowPresetsConfig {
xl: ShadowLayerDef[]
}

export type FxGlowName = 'Neutral' | 'Inverted' | 'Brand' | 'Danger' | 'Warning'
export type FxLegacyGlowName = 'Success' | 'Info'
export type FxFocusRingName = 'Neutral' | 'Brand' | 'Danger' | 'Warning'

export interface FxSkeletonConfig {
base: SemanticDef
highlight: SemanticDef
/** Legacy alias retained through the 0.2.x deprecation window. */
legacy: SemanticDef
}

export interface FxConfig {
glow: Record<SentimentName, SemanticDef>
focus_ring: SemanticDef
skeleton: SemanticDef
glow: Record<FxGlowName, SemanticDef>
/** Deprecated 0.2.x aliases; not part of the canonical Figma FX.Glow contract. */
legacy_glow: Record<FxLegacyGlowName, SemanticDef>
focus_ring: Record<FxFocusRingName, SemanticDef>
/** Legacy alias retained through the 0.2.x deprecation window. */
focus_ring_legacy: SemanticDef
skeleton: FxSkeletonConfig
shadow_tints: ShadowTintsConfig
shadow_presets: ShadowPresetsConfig
}
Expand Down
Loading
Loading