@apollion-dsi/tokens 5.6.0 → 5.8.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.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Layout builder — **Strangler Fig duplicate** of
3
+ * `@apollion-dsi/core/themes/layout` (ADR-025 invariant 5).
4
+ *
5
+ * Mirrors the container-size scale (the `cq` channel thresholds — px, slot
6
+ * widths, NOT viewport breakpoints) and the page-layout constants
7
+ * (`pageMaxWidth`). Axis-invariant.
8
+ *
9
+ * @see ./colors.ts (Strangler Fig contract notes)
10
+ */
11
+ import type { ContainerSizesInput, LayoutInput } from '@apollion-dsi/core/themes/layout';
12
+ import { defaultInputContainerSizes, defaultInputLayout } from '@apollion-dsi/core/themes/layout';
13
+
14
+ /** Resolved `[key, '<value>px']` pairs of the container-size scale, ascending. */
15
+ export function containerSizeEntries(
16
+ input: ContainerSizesInput = defaultInputContainerSizes,
17
+ ): ReadonlyArray<readonly [string, string]> {
18
+ return Object.entries(input.values).map(([key, value]) => [key, `${value}px`] as const);
19
+ }
20
+
21
+ /** Resolved `[key, '<value>px']` pairs of the page-layout constants. */
22
+ export function layoutEntries(input: LayoutInput = defaultInputLayout): ReadonlyArray<readonly [string, string]> {
23
+ return [['page-max-width', `${input.pageMaxWidth}px`] as const];
24
+ }
@@ -59,6 +59,7 @@ export const ApollionConfigSchema = z
59
59
  ts: z.boolean().optional(),
60
60
  designMd: z.boolean().optional(),
61
61
  tailwind: z.boolean().optional(),
62
+ cssModes: z.boolean().optional(),
62
63
  })
63
64
  .passthrough()
64
65
  .optional(),
@@ -45,6 +45,11 @@ export interface ApollionOutputFlags {
45
45
  * channel-triplet companion vars to `dist/css/` so opaque colour tokens
46
46
  * support Tailwind's alpha modifier. See `renderers/tailwind.ts`. */
47
47
  tailwind?: boolean;
48
+ /** `dist/css/<brand>.<surface>.<dimension>.modes.css` — both modes in ONE file,
49
+ * scoped by `html[data-apollion-mode]` (+ `prefers-color-scheme` fallback) the
50
+ * way the core mode engine scopes them (ADR-024). Requires `modes` to include
51
+ * both `light` and `dark`. See `renderers/css.ts` (`renderCssModes`). */
52
+ cssModes?: boolean;
48
53
  }
49
54
 
50
55
  export interface ApollionConfig {
package/src/ir.ts CHANGED
@@ -38,6 +38,8 @@ import type { SpacingAlias } from '@apollion-dsi/core/themes/spacing';
38
38
  import { defaultInputSpacing } from '@apollion-dsi/core/themes/spacing';
39
39
  import { converter, formatHex, parse } from 'culori';
40
40
 
41
+ import { breakpointEntries } from './builders/breakpoints';
42
+ import { containerSizeEntries, layoutEntries } from './builders/layout';
41
43
  import { createSpacing } from './builders/spacing';
42
44
  import type { Variant } from './config-schema';
43
45
  import type { ColorsThemeInterface } from './theme-factory';
@@ -265,6 +267,11 @@ function buildColorPrimitives(colors: ColorsThemeInterface): IRGroup {
265
267
  }
266
268
  out.grayscale = recordColorGroup(colors.grayscale);
267
269
  out.neutral = recordColorGroup(colors.neutral);
270
+ // ADR-023 — surface ladder roles (mode-aware grounds / inks / edges / washes).
271
+ out.surface = recordColorGroup(colors.surface as unknown as Record<string, string>);
272
+ out.ink = recordColorGroup(colors.ink as unknown as Record<string, string>);
273
+ out.edge = recordColorGroup(colors.edge as unknown as Record<string, string>);
274
+ out.wash = recordColorGroup(colors.wash as unknown as Record<string, string>);
268
275
  out.baseDark = colorToken(colors.baseDark ?? '#000000');
269
276
  out.baseLight = colorToken(colors.baseLight ?? '#ffffff');
270
277
  out.deepDark = colorToken(colors.deepDark ?? '#000000');
@@ -317,6 +324,11 @@ const BG_REF: Readonly<Record<string, string>> = {
317
324
  danger: 'color.danger.base',
318
325
  warning: 'color.warning.base',
319
326
  info: 'color.information.base',
327
+ canvas: 'color.surface.canvas',
328
+ sunken: 'color.surface.sunken',
329
+ paper: 'color.surface.paper',
330
+ raised: 'color.surface.raised',
331
+ overlay: 'color.surface.overlay',
320
332
  };
321
333
 
322
334
  const TEXT_REF: Readonly<Record<string, string>> = {
@@ -327,6 +339,17 @@ const TEXT_REF: Readonly<Record<string, string>> = {
327
339
  onDanger: 'color.baseLight',
328
340
  onWarning: 'color.baseDark',
329
341
  onInfo: 'color.baseLight',
342
+ primary: 'color.ink.primary',
343
+ secondary: 'color.ink.secondary',
344
+ muted: 'color.ink.muted',
345
+ disabled: 'color.ink.disabled',
346
+ };
347
+
348
+ /** Foundation `edge.*` → `color.edge.*` primitive (ADR-023 ink-alpha lines). */
349
+ const EDGE_REF: Readonly<Record<string, string>> = {
350
+ hairline: 'color.edge.hairline',
351
+ border: 'color.edge.border',
352
+ strong: 'color.edge.strong',
330
353
  };
331
354
 
332
355
  /** Navigate the structural palette by IR path (`color.x.y`). */
@@ -640,10 +663,52 @@ export function assertSlicesMetaComplete(slices: readonly IRSlice[]): void {
640
663
  }
641
664
 
642
665
  /**
643
- * Axis-invariant base slice: border (radius/width/style). `border.width`
644
- * references the `border-width.*` primitives, which travel WITH this slice
645
- * (`border-width` namespace) so the set is reference-closed. Identical for every
646
- * brand/mode/surface/dimension.
666
+ * Viewport breakpoint scale (APOLLION-BREAKPOINT-TOKENS) — axis-invariant,
667
+ * px by contract, half-open `[value, next)` bands over `min-width` sets.
668
+ * Values mirror `@apollion-dsi/core/themes/breakpoints` via the Strangler Fig
669
+ * builder; the parity suite pins the mirror.
670
+ *
671
+ * CSS emission (`--apollion-breakpoint-*`) is **informational only**: `var()`
672
+ * is invalid inside a `@media` prelude, so those custom properties serve JS
673
+ * `getComputedStyle` readers, never media queries. Query-capable surfaces are
674
+ * the TS record and the Tailwind `screens` scale (literal px).
675
+ */
676
+ function buildBreakpoints(): IRGroup {
677
+ const out: Record<string, IRToken> = {};
678
+ for (const [key, px] of breakpointEntries()) out[key] = { type: 'dimension', value: toDimensionValue(px) };
679
+ return out;
680
+ }
681
+
682
+ /**
683
+ * Container-size scale + page-layout constants (ADR-025 invariant 5) —
684
+ * axis-invariant mirrors of `theme.containerSizes` / `theme.layout`.
685
+ * `container-size.*` are the `cq` channel thresholds (slot widths, a
686
+ * deliberately separate scale from `breakpoint.*`).
687
+ */
688
+ function buildContainerSizes(): IRGroup {
689
+ const out: Record<string, IRToken> = {};
690
+ for (const [key, px] of containerSizeEntries()) out[key] = { type: 'dimension', value: toDimensionValue(px) };
691
+ return out;
692
+ }
693
+
694
+ function buildLayoutConstants(): IRGroup {
695
+ const out: Record<string, IRToken> = {};
696
+ for (const [key, px] of layoutEntries()) out[key] = { type: 'dimension', value: toDimensionValue(px) };
697
+ return out;
698
+ }
699
+
700
+ /** `$extensions` payload documenting the breakpoint interval contract. */
701
+ const BREAKPOINT_EXTENSION = {
702
+ unit: 'px',
703
+ interval: 'half-open [value, next) — min-width sets, ascending',
704
+ cssVarsUsage: 'informational only — var() is invalid inside a @media prelude',
705
+ } as const;
706
+
707
+ /**
708
+ * Axis-invariant base slice: border (radius/width/style) + the viewport
709
+ * `breakpoint.*` scale. `border.width` references the `border-width.*`
710
+ * primitives, which travel WITH this slice (`border-width` namespace) so the
711
+ * set is reference-closed. Identical for every brand/mode/surface/dimension.
647
712
  *
648
713
  * **ADR-011:** font left the base set — its `size` scale is now density-aware,
649
714
  * so it lives in per-dimension `font.<dimension>` sets ({@link buildFontIR}),
@@ -654,13 +719,22 @@ export function buildBaseIR(): IRSlice {
654
719
  const primitives: IRGroup = { borderWidth };
655
720
  const border = buildBorder(primitives);
656
721
 
657
- const extensions: Record<string, unknown> = {};
722
+ const extensions: Record<string, unknown> = { 'com.apollion.breakpoint': BREAKPOINT_EXTENSION };
658
723
  if (Object.keys(border.extensions).length > 0) {
659
724
  extensions['com.apollion.border'] = { radius: border.extensions };
660
725
  }
661
726
 
662
727
  const seen = new Set<string>();
663
- const groups = decorateSlice({ 'border-width': borderWidth, border: border.group }, seen);
728
+ const groups = decorateSlice(
729
+ {
730
+ 'border-width': borderWidth,
731
+ border: border.group,
732
+ breakpoint: buildBreakpoints(),
733
+ 'container-size': buildContainerSizes(),
734
+ layout: buildLayoutConstants(),
735
+ },
736
+ seen,
737
+ );
664
738
  return { groups, extensions };
665
739
  }
666
740
 
@@ -691,6 +765,7 @@ export function buildColorIR(foundation: FoundationLayer, colors: ColorsThemeInt
691
765
  color,
692
766
  bg: colorRefGroup(foundation.bg as unknown as Record<string, string>, colors, BG_REF),
693
767
  text: colorRefGroup(foundation.text as unknown as Record<string, string>, colors, TEXT_REF),
768
+ edge: colorRefGroup(foundation.edge as unknown as Record<string, string>, colors, EDGE_REF),
694
769
  tx: buildReadableTextGroup(colors),
695
770
  shadow: buildShadow(colors),
696
771
  },
@@ -737,7 +812,11 @@ export function buildIR(foundation: FoundationLayer, colors: ColorsThemeInterfac
737
812
  const border = buildBorder(primitives);
738
813
  const font = buildFont(variant.dimension);
739
814
 
740
- const extensions: Record<string, unknown> = { 'com.apollion.variant': meta, 'com.apollion.font': font.extensions };
815
+ const extensions: Record<string, unknown> = {
816
+ 'com.apollion.variant': meta,
817
+ 'com.apollion.font': font.extensions,
818
+ 'com.apollion.breakpoint': BREAKPOINT_EXTENSION,
819
+ };
741
820
  if (Object.keys(border.extensions).length > 0) {
742
821
  extensions['com.apollion.border'] = { radius: border.extensions };
743
822
  }
@@ -749,9 +828,13 @@ export function buildIR(foundation: FoundationLayer, colors: ColorsThemeInterfac
749
828
  groups: {
750
829
  bg: colorRefGroup(foundation.bg as unknown as Record<string, string>, colors, BG_REF),
751
830
  text: colorRefGroup(foundation.text as unknown as Record<string, string>, colors, TEXT_REF),
831
+ edge: colorRefGroup(foundation.edge as unknown as Record<string, string>, colors, EDGE_REF),
752
832
  tx: buildReadableTextGroup(colors),
753
833
  spacing: dimensionRefGroup(foundation.spacing as unknown as Record<string, string>, primitives, SPACE_REF),
754
834
  border: border.group,
835
+ breakpoint: buildBreakpoints(),
836
+ 'container-size': buildContainerSizes(),
837
+ layout: buildLayoutConstants(),
755
838
  font: font.group,
756
839
  shadow: buildShadow(colors),
757
840
  },
@@ -90,3 +90,51 @@ export function renderCss(ir: IRDocument, options: RenderCssOptions = {}): strin
90
90
 
91
91
  return lines.join('\n');
92
92
  }
93
+
94
+ /** Options of {@link renderCssModes}. */
95
+ export interface RenderCssModesOptions extends RenderCssOptions {
96
+ /** `<html>` attribute carrying the resolved mode (same default as the core mode engine). */
97
+ attribute?: string;
98
+ }
99
+
100
+ /**
101
+ * Mode-scoped CSS (APOLLION-DARK-MODE-ENGINE, ADR-024 — `output.cssModes`):
102
+ * ONE file per (brand × surface × dimension) carrying BOTH modes, scoped the
103
+ * way the core mode engine scopes them — `:root`/`[attr="light"]` for light,
104
+ * `[attr="dark"]` for dark, plus a `prefers-color-scheme` fallback for pages
105
+ * that never set the attribute. Consumers without React link this file and
106
+ * flip the mode with the attribute (`getModeScript` / `syncDocumentMode`)
107
+ * — the "CSS-only switch" of the docs, made real.
108
+ */
109
+ export function renderCssModes(light: IRDocument, dark: IRDocument, options: RenderCssModesOptions = {}): string {
110
+ const { attribute = 'data-apollion-mode', rgbCompanions } = options;
111
+ const { brand, surface, dimension } = light.meta;
112
+ const lines: string[] = [HEADER_LINE, ''];
113
+
114
+ const block = (selector: string, ir: IRDocument, scheme: 'light' | 'dark'): void => {
115
+ lines.push(`${selector} {`, ` color-scheme: ${scheme};`);
116
+ emitVars(ir.groups, [], lines);
117
+ if (rgbCompanions) emitRgbVars(ir.groups, [], lines);
118
+ lines.push('}', '');
119
+ };
120
+
121
+ lines.push(
122
+ `/* Variant: brand=${brand} surface=${surface} dimension=${dimension} modes=light+dark (attribute ${attribute}) */`,
123
+ '',
124
+ );
125
+ block(`:root, html[${attribute}="light"]`, light, 'light');
126
+ block(`html[${attribute}="dark"]`, dark, 'dark');
127
+ lines.push('@media (prefers-color-scheme: dark) {');
128
+ lines.push(` html:not([${attribute}]) {`, ' color-scheme: dark;');
129
+ const inner: string[] = [];
130
+ emitVars(dark.groups, [], inner);
131
+ if (rgbCompanions) emitRgbVars(dark.groups, [], inner);
132
+ for (const line of inner) lines.push(` ${line}`);
133
+ lines.push(' }', '}', '');
134
+
135
+ lines.push('@supports (background: paint(squircle)) or (color: oklch(0% 0 0)) {');
136
+ emitProperties(light.groups, [], lines);
137
+ lines.push('}', '');
138
+
139
+ return lines.join('\n');
140
+ }
@@ -30,7 +30,7 @@
30
30
  import { converter, parse } from 'culori';
31
31
 
32
32
  import type { IRColorValue, IRDocument, IRGroup, IRToken } from '../ir';
33
- import { isToken, kebab } from '../ir';
33
+ import { isToken, kebab, rawValue } from '../ir';
34
34
 
35
35
  const toRgb = converter('rgb');
36
36
 
@@ -91,13 +91,13 @@ interface ColorEntry {
91
91
  }
92
92
 
93
93
  /**
94
- * Colour entries for the preset's `colors` scale: the `bg`/`text`/`tx`
94
+ * Colour entries for the preset's `colors` scale: the `bg`/`text`/`edge`/`tx`
95
95
  * foundation groups, keyed `<group>-<path>` (the CSS var name minus the
96
96
  * `--apollion-` prefix — one mental model across surfaces).
97
97
  */
98
98
  function colorEntries(ir: IRDocument): ColorEntry[] {
99
99
  const out: ColorEntry[] = [];
100
- for (const groupName of ['bg', 'text', 'tx'] as const) {
100
+ for (const groupName of ['bg', 'text', 'edge', 'tx'] as const) {
101
101
  for (const [key, token] of flatTokens(groupAt(ir.groups, groupName), [groupName])) {
102
102
  const color = colorPayload(token);
103
103
  if (!color) continue;
@@ -145,6 +145,17 @@ export function renderTailwindPreset(ir: IRDocument): string {
145
145
  }
146
146
  out.push(' },');
147
147
 
148
+ // `screens` takes LITERAL px, never `var()` — custom properties are invalid
149
+ // inside a `@media` prelude. `xs` (0) is the unprefixed base band, omitted by
150
+ // Tailwind convention. APOLLION-BREAKPOINT-TOKENS.
151
+ pushScale(
152
+ out,
153
+ 'screens',
154
+ [...flatTokens(groupAt(ir.groups, 'breakpoint'))]
155
+ .filter(([key]) => key !== 'xs')
156
+ .map(([key, token]) => [key, rawValue(token.value)] as const),
157
+ );
158
+
148
159
  pushScale(out, 'spacing', varEntries(groupAt(ir.groups, 'spacing'), ['spacing']));
149
160
  pushScale(out, 'borderRadius', varEntries(groupAt(ir.groups, 'border', 'radius'), ['border', 'radius']));
150
161
  pushScale(out, 'borderWidth', varEntries(groupAt(ir.groups, 'border', 'width'), ['border', 'width']));
@@ -32,6 +32,7 @@ import { defaultInputSpacing } from '@apollion-dsi/core/themes/spacing';
32
32
  import { invertForSurface } from '@apollion-dsi/core/themes/surface';
33
33
 
34
34
  import { mountGreyscaleColors, mountNeutralColors, mountSingleColor } from './builders/colors';
35
+ import { resolveSurfaceLadder } from './builders/ladder';
35
36
  import { createSpacing } from './builders/spacing';
36
37
  import type { Variant } from './config-schema';
37
38
 
@@ -45,14 +46,17 @@ export interface VariantThemes {
45
46
  }
46
47
 
47
48
  function composeColors(input: Variant['colors']) {
49
+ const neutral = mountNeutralColors(input);
48
50
  return {
49
51
  baseDark: input.baseDark,
50
52
  baseLight: input.baseLight,
51
53
  deepDark: input.deepDark,
52
54
  deepLight: input.deepLight,
53
55
  transparent: input.transparent ?? 'transparent',
54
- neutral: mountNeutralColors(input),
56
+ neutral,
55
57
  grayscale: mountGreyscaleColors(input),
58
+ // ADR-023 — surface ladder (mirror of core's createColors composition).
59
+ ...resolveSurfaceLadder({ ...input, neutral }, input.ladder),
56
60
  main: mountSingleColor(input.main!, input),
57
61
  opposite: mountSingleColor(input.opposite!, input),
58
62
  complementary: mountSingleColor(input.complementary!, input),
package/src/token-meta.ts CHANGED
@@ -48,6 +48,15 @@ export const TOKEN_META: Readonly<Record<string, TokenMetaEntry>> = {
48
48
  deprecated: 'Use bg.information once the foundation role is renamed (tracked for S6).',
49
49
  },
50
50
 
51
+ // ── Surface ladder (ADR-023 — mode-aware grounds, inks, edges) ───────────
52
+ 'bg.canvas': { description: 'Page ground — the theme canvas (never pure black in dark).' },
53
+ 'bg.paper': { description: 'Component ground (cards, inputs, tables) — lighter than the canvas in both modes.' },
54
+ 'bg.overlay': { description: 'Top of the elevation ladder — modal / dialog panels.' },
55
+ 'text.primary': { description: 'Primary neutral ink — AA + APCA Lc ≥ 75 over bg.paper and bg.canvas.' },
56
+ 'text.muted': { description: 'Muted neutral ink — AA + APCA Lc ≥ 45 over bg.paper.' },
57
+ 'edge.border': { description: 'Default control outline — primary ink at 20 % alpha, legible on any ground.' },
58
+ 'edge.hairline': { description: 'Dividers and card outlines — primary ink at 10 % alpha.' },
59
+
51
60
  // ── Foundation text-on roles ─────────────────────────────────────────────
52
61
  'text.on-primary': { description: 'Text/icon colour with guaranteed contrast over bg.primary.' },
53
62
  'text.on-danger': { description: 'Text/icon colour with guaranteed contrast over bg.danger.' },