@crewlethq/tokens 0.12.0 → 0.14.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/test/palette.mjs CHANGED
@@ -30,7 +30,7 @@
30
30
  * selector: themes.css is imported second and is what paints.
31
31
  */
32
32
 
33
- import { cascade, chroma, contrast, deltaE, flatten, parseBlocks, parseHex, separation, VISIONS, withAlpha } from './color.mjs';
33
+ import { cascade, chroma, contrast, deltaE, flatten, hue, parseBlocks, parseHex, separation, VISIONS, withAlpha } from './color.mjs';
34
34
 
35
35
  export * from './color.mjs';
36
36
 
@@ -167,9 +167,14 @@ export const TEXT_STEPS = [
167
167
  */
168
168
  export const DIMMED_TEXT = ['--color-text-primary', '--color-text-tertiary', 2];
169
169
 
170
- /** A hue used as TEXT on a surface. */
170
+ /**
171
+ * A hue used as TEXT on a surface. The quiet link is one: a link inside
172
+ * running text or a row, mixed from the accent ink toward the secondary text
173
+ * so it does not compete with the words around it, and read like any other.
174
+ */
171
175
  export const INK_STEPS = [
172
176
  '--color-brand-accent-ink',
177
+ '--color-text-link-quiet',
173
178
  '--color-feedback-success-ink',
174
179
  '--color-feedback-warning-ink',
175
180
  '--color-feedback-danger-ink',
@@ -222,16 +227,18 @@ export const DATA_ADJACENT_NORMAL_DE = 15;
222
227
 
223
228
  /**
224
229
  * A fill with the label a component paints on it: the primary action at rest,
225
- * under the pointer and pressed, the danger fill, and the inverse ground. The
226
- * label on every fill but the last is on-accent, which is white in every
227
- * palette, and each fill is measured over every ground it can sit on.
230
+ * under the pointer and pressed, the danger fill at rest and under the
231
+ * pointer, and the inverse ground. The accent carries on-accent, which a theme
232
+ * with a light accent makes dark; the danger fill carries on-danger, which is
233
+ * white in every palette; and each fill is measured over every ground it can
234
+ * sit on.
228
235
  */
229
236
  export const LABEL_ON_FILL = [
230
237
  ['--color-text-on-accent', '--color-brand-accent'],
231
238
  ['--color-text-on-accent', '--color-brand-accent-hover'],
232
239
  ['--color-text-on-accent', '--color-brand-accent-active'],
233
- ['--color-text-on-accent', '--color-feedback-danger'],
234
- ['--color-text-on-accent', '--color-feedback-danger-hover'],
240
+ ['--color-text-on-danger', '--color-feedback-danger'],
241
+ ['--color-text-on-danger', '--color-feedback-danger-hover'],
235
242
  ['--color-text-inverse', '--color-surface-inverse'],
236
243
  ];
237
244
 
@@ -289,8 +296,13 @@ export const ACTION_STEPS = [
289
296
  ],
290
297
  ];
291
298
  export const ACTION_LABEL = '--color-text-on-accent';
299
+ /** The destructive action's label, which a step of the danger fill moves away from. */
300
+ export const DESTRUCTIVE_LABEL = '--color-text-on-danger';
292
301
  export const ACTION_DE = 3;
293
302
 
303
+ /** The label a step is measured under: the danger fill's own, or the accent's. */
304
+ const labelOf = (step) => (step.startsWith('--color-feedback-danger') ? DESTRUCTIVE_LABEL : ACTION_LABEL);
305
+
294
306
  /** The ten seeded avatar grounds, each carrying white initials. */
295
307
  export const AVATAR_TINTS = Array.from({ length: 10 }, (_, i) => `--color-avatar-tint-${i}`);
296
308
 
@@ -398,6 +410,13 @@ export const RAIL_PAINTS = [
398
410
  */
399
411
  export const SCRIM_GROUNDS = [...AVATAR_TINTS];
400
412
 
413
+ /**
414
+ * The label drawn on the scrim, and on a seeded tint: white in every palette,
415
+ * so a theme whose accent carries a dark label leaves an upload overlay and a
416
+ * seeded badge's initials as they are.
417
+ */
418
+ export const SCRIM_LABEL = '--color-text-on-scrim';
419
+
401
420
  // The controls
402
421
  // ---------------------------------------------------------------------------
403
422
 
@@ -598,6 +617,45 @@ const ACCENT_DICHROMAT = 8;
598
617
  const DANGER_NORMAL = 14;
599
618
  const DANGER_DICHROMAT = 8;
600
619
 
620
+ // ---------------------------------------------------------------------------
621
+ // A theme's own profile
622
+ // ---------------------------------------------------------------------------
623
+
624
+ /**
625
+ * The chroma under which a ground reads as neutral: the cap an untinted
626
+ * theme's four rungs keep. It is what stops a ground drifting into a tint of
627
+ * the accent, which leaves the accent nothing to separate itself from.
628
+ */
629
+ export const NEUTRAL_CHROMA = 2.2;
630
+
631
+ /**
632
+ * A TINTED theme declares its tint (tokens/themes/<id>/theme.json, and the
633
+ * registry's `tint`): the OKLCh hue of its grounds and the most chroma a rung
634
+ * may carry. Its rungs are held to the declaration rather than to the neutral
635
+ * cap: none carries more than the declared chroma, and one tinted past the
636
+ * neutral cap sits within TINT_HUE_RANGE degrees of the declared hue, so a
637
+ * ground can be deliberately violet without wandering anywhere else.
638
+ */
639
+ export const TINT_HUE_RANGE = 20;
640
+
641
+ /**
642
+ * What the neutral cap protected, kept for a tinted theme as well: the accent
643
+ * carries at least this many times the chroma of the most tinted rung, so it
644
+ * reads as the colour on the page rather than as more of the ground. A
645
+ * neutral theme clears it by an order of magnitude.
646
+ */
647
+ export const ACCENT_OVER_TINT = 2;
648
+
649
+ /**
650
+ * The floors a HIGH-CONTRAST theme (`contrast: 'high'`) is held to on top of
651
+ * the table: every text step and every ink at 7:1, the WCAG AAA floor for body
652
+ * text; the muted step READABLE at 4.5:1 rather than decorative, because a
653
+ * reader who asked for more contrast cannot rely on a glyph they cannot see;
654
+ * and the plain hairline at 3:1 against the card and the sheet, so a card is
655
+ * found by a line that clears the mark floor rather than by a step of fill.
656
+ */
657
+ export const HIGH_CONTRAST = { text: 7, ink: 7, muted: 4.5, hairline: 3 };
658
+
601
659
  // ---------------------------------------------------------------------------
602
660
  // Theme states
603
661
  // ---------------------------------------------------------------------------
@@ -612,6 +670,19 @@ const LIGHT_MEDIA = '@media (prefers-color-scheme: light)';
612
670
  const LIGHT_MEDIA_SELECTOR = ':root:not([data-theme="dark"])';
613
671
  const LIGHT_ATTRIBUTE_SELECTOR = ':root[data-theme="light"]';
614
672
 
673
+ /**
674
+ * A NAMED theme's three blocks, the same contract under its own attribute:
675
+ * `data-palette="<id>"` on the root element, beside the data-theme that
676
+ * chooses light or dark. A null palette is the default, on the bare root.
677
+ */
678
+ export function paletteSelectors(palette) {
679
+ if (palette === null || palette === undefined) {
680
+ return { dark: THEME_ROOT, lightMedia: LIGHT_MEDIA_SELECTOR, lightAttribute: LIGHT_ATTRIBUTE_SELECTOR };
681
+ }
682
+ const root = `:root[data-palette="${palette}"]`;
683
+ return { dark: root, lightMedia: `${root}:not([data-theme="dark"])`, lightAttribute: `${root}[data-theme="light"]` };
684
+ }
685
+
615
686
  /**
616
687
  * The two sheets, each parsed ONCE, as sources cascade() reads. A measurement
617
688
  * reads four states and three theme blocks out of them, and parsing both
@@ -623,13 +694,14 @@ const sheetsOf = ({ tokens, themes }) => ({
623
694
  themes: { name: 'themes.css', blocks: parseBlocks(themes) },
624
695
  });
625
696
 
626
- /** The theme file's three blocks, each on its own, as the file declares it. */
627
- function themeBlocks(themesSource) {
697
+ /** A theme's three blocks, each on its own, as the theme sheet declares it. */
698
+ function themeBlocks(themesSource, palette = null) {
699
+ const selectors = paletteSelectors(palette);
628
700
  const block = (atRule, selector) => cascade([themesSource], (b) => b.atRule === atRule && b.selector === selector);
629
701
  return {
630
- dark: block(null, THEME_ROOT),
631
- lightMedia: block(LIGHT_MEDIA, LIGHT_MEDIA_SELECTOR),
632
- lightAttribute: block(null, LIGHT_ATTRIBUTE_SELECTOR),
702
+ dark: block(null, selectors.dark),
703
+ lightMedia: block(LIGHT_MEDIA, selectors.lightMedia),
704
+ lightAttribute: block(null, selectors.lightAttribute),
633
705
  };
634
706
  }
635
707
 
@@ -645,22 +717,28 @@ function themeBlocks(themesSource) {
645
717
  * different routes and either can drift from the other.
646
718
  */
647
719
  export function paletteStates(sources) {
648
- return statesOf(sheetsOf(sources));
720
+ return statesOf(sheetsOf(sources), sources.palette ?? null);
649
721
  }
650
722
 
651
- /** paletteStates over sheets already parsed, which runPalette shares with the structural rules. */
652
- function statesOf(sheets) {
723
+ /**
724
+ * paletteStates over sheets already parsed, which runPalette shares with the
725
+ * structural rules. A named palette is painted over the default exactly as a
726
+ * browser paints it: the default's block, then the theme's own on top.
727
+ */
728
+ function statesOf(sheets, palette = null) {
653
729
  const tokensOnly = [sheets.tokens];
654
730
  const both = [...tokensOnly, sheets.themes];
655
731
  const bare = (block) => block.atRule === null && block.selector === THEME_ROOT;
656
- const dark = cascade(both, bare);
657
- const withLight = (selector, atRule) =>
658
- new Map([...dark, ...cascade(both, (block) => block.atRule === atRule && block.selector === selector)]);
732
+ const own = themeBlocks(sheets.themes, palette);
733
+ const named = palette === null ? { dark: new Map(), lightMedia: new Map(), lightAttribute: new Map() } : own;
734
+ const dark = new Map([...cascade(both, bare), ...named.dark]);
735
+ const withLight = (selector, atRule, theme) =>
736
+ new Map([...dark, ...cascade(both, (block) => block.atRule === atRule && block.selector === selector), ...theme]);
659
737
  return {
660
738
  base: cascade(tokensOnly, bare),
661
739
  dark,
662
- 'light (media query)': withLight(LIGHT_MEDIA_SELECTOR, LIGHT_MEDIA),
663
- 'light (attribute)': withLight(LIGHT_ATTRIBUTE_SELECTOR, null),
740
+ 'light (media query)': withLight(LIGHT_MEDIA_SELECTOR, LIGHT_MEDIA, named.lightMedia),
741
+ 'light (attribute)': withLight(LIGHT_ATTRIBUTE_SELECTOR, null, named.lightAttribute),
664
742
  };
665
743
  }
666
744
 
@@ -694,9 +772,11 @@ function resolve(values, name, ground) {
694
772
  * `profile` is 'full' for a theme, or 'base' for the marketing root, which is
695
773
  * exempt from the separation, rail, overlay and rung rules: its one pure black
696
774
  * ground and its single brand hue are a deliberate marketing look rather than
697
- * a product palette.
775
+ * a product palette. `theme` is what the theme declares about itself: its
776
+ * tint, and whether it is held to the high-contrast floors.
698
777
  */
699
- function checkState(state, values, profile, push) {
778
+ function checkState(state, values, profile, push, theme = { tint: null, contrast: 'standard' }) {
779
+ const high = theme.contrast === 'high';
700
780
  const { ground, opaque, all } = surfacesOf(values);
701
781
  const colour = (name) => resolve(values, name, ground);
702
782
  const worst = (rgb, list) =>
@@ -706,7 +786,8 @@ function checkState(state, values, profile, push) {
706
786
  }, { ratio: Infinity, name: '' });
707
787
  const say = (rule, ok, subject, value, detail) => push({ state, rule, ok, subject, value, detail });
708
788
 
709
- for (const [name, floor] of TEXT_STEPS) {
789
+ for (const [name, standard] of TEXT_STEPS) {
790
+ const floor = high ? Math.max(standard, HIGH_CONTRAST.text) : standard;
710
791
  const low = worst(colour(name), all);
711
792
  say('text step clears its floor', low.ratio >= floor, name, low.ratio, `${low.ratio.toFixed(2)}:1 >= ${floor} (worst on ${low.name})`);
712
793
  }
@@ -727,13 +808,21 @@ function checkState(state, values, profile, push) {
727
808
  // it is a chip's or a well's fill, and what a chip carries is its label.
728
809
  for (const [name, surface] of opaque.filter(([rung]) => rung !== '--color-surface-elevated')) {
729
810
  const ratio = contrast(colour('--color-text-muted'), surface);
730
- say('text-muted is decoration', ratio > 2.8 && ratio < 4.5, '--color-text-muted', ratio, `2.8 < ${ratio.toFixed(2)}:1 < 4.5 on ${name}`);
811
+ if (high) {
812
+ // A reader who asked for more contrast reads the glyph as well. See HIGH_CONTRAST.
813
+ say('text-muted reads in high contrast', ratio >= HIGH_CONTRAST.muted, '--color-text-muted', ratio, `${ratio.toFixed(2)}:1 >= ${HIGH_CONTRAST.muted} on ${name}`);
814
+ } else {
815
+ say('text-muted is decoration', ratio > 2.8 && ratio < 4.5, '--color-text-muted', ratio, `2.8 < ${ratio.toFixed(2)}:1 < 4.5 on ${name}`);
816
+ }
731
817
  }
732
818
  }
733
819
 
734
820
  for (const name of INK_STEPS) {
735
821
  const low = worst(colour(name), all);
736
822
  say('ink step clears 4.5:1 as text', low.ratio >= 4.5, name, low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
823
+ if (high) {
824
+ say('ink step clears 7:1 in high contrast', low.ratio >= HIGH_CONTRAST.ink, name, low.ratio, `${low.ratio.toFixed(2)}:1 >= ${HIGH_CONTRAST.ink} (worst on ${low.name})`);
825
+ }
737
826
  }
738
827
 
739
828
  // An ink on its OWN soft tint: a badge, a callout, a diff line. The tint is
@@ -783,7 +872,8 @@ function checkState(state, values, profile, push) {
783
872
 
784
873
  // THE PRIMARY ACTION'S STEPS. See ACTION_STEPS.
785
874
  for (const [rule, step, from] of ACTION_STEPS) {
786
- const label = colour(ACTION_LABEL);
875
+ const name = labelOf(step);
876
+ const label = colour(name);
787
877
  const [was, now] = [colour(from), colour(step)];
788
878
  const measured = deltaE(now, was);
789
879
  const gain = contrast(label, now) - contrast(label, was);
@@ -792,12 +882,14 @@ function checkState(state, values, profile, push) {
792
882
  measured >= ACTION_DE && gain > 0,
793
883
  `${step} after ${from}`,
794
884
  measured,
795
- `dE ${measured.toFixed(2)} >= ${ACTION_DE}, and ${ACTION_LABEL} ${contrast(label, was).toFixed(2)}:1 -> ${contrast(label, now).toFixed(2)}:1 rises`,
885
+ `dE ${measured.toFixed(2)} >= ${ACTION_DE}, and ${name} ${contrast(label, was).toFixed(2)}:1 -> ${contrast(label, now).toFixed(2)}:1 rises`,
796
886
  );
797
887
  }
798
888
 
889
+ // The initials a seeded badge draws are the scrim's label: the tints are
890
+ // the grounds the scrim lands on, and both are white in every palette.
799
891
  for (const name of AVATAR_TINTS) {
800
- const ratio = contrast(parseHex('#ffffff'), colour(name));
892
+ const ratio = contrast(colour(SCRIM_LABEL), colour(name));
801
893
  say('an avatar tint clears 4.5:1 under white initials', ratio >= 4.5, name, ratio, `${ratio.toFixed(2)}:1`);
802
894
  }
803
895
 
@@ -807,11 +899,11 @@ function checkState(state, values, profile, push) {
807
899
  // from a token: an upload overlay's label sits on the scrim over an
808
900
  // identity badge, and the badge is a seeded tint or an initials tile on
809
901
  // whichever surface the form is on.
810
- const label = colour('--color-text-on-accent');
902
+ const label = colour(SCRIM_LABEL);
811
903
  const scrim = values.get('--color-surface-scrim');
812
904
  const over = [...opaque, ...SCRIM_GROUNDS.map((name) => [name, colour(name)])];
813
905
  const low = worst(label, over.map(([name, rgb]) => [`the scrim over ${name}`, flatten(scrim, rgb)]));
814
- say('a label on the scrim clears 4.5:1', low.ratio >= 4.5, '--color-text-on-accent', low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
906
+ say('a label on the scrim clears 4.5:1', low.ratio >= 4.5, SCRIM_LABEL, low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
815
907
  }
816
908
 
817
909
  {
@@ -923,6 +1015,15 @@ function checkState(state, values, profile, push) {
923
1015
 
924
1016
  if (profile !== 'full') return;
925
1017
 
1018
+ if (high) {
1019
+ // The card's line, at the mark floor. See HIGH_CONTRAST.
1020
+ const [line, card, sheet] = CARD_HAIRLINE;
1021
+ for (const side of [card, sheet]) {
1022
+ const ratio = contrast(colour(line), colour(side));
1023
+ say('a hairline clears 3:1 in high contrast', ratio >= HIGH_CONTRAST.hairline, `${line} on ${side}`, ratio, `${ratio.toFixed(2)}:1 >= ${HIGH_CONTRAST.hairline}`);
1024
+ }
1025
+ }
1026
+
926
1027
  {
927
1028
  // THE RAIL, on its own ground and on its own row tints. See RAIL_GROUND.
928
1029
  const rail = flatten(values.get(RAIL_GROUND), ground);
@@ -1052,10 +1153,37 @@ function checkState(state, values, profile, push) {
1052
1153
  }
1053
1154
 
1054
1155
  // The chroma cap is what stops the ground drifting into a tint of the
1055
- // accent, which leaves the accent nothing to separate itself from.
1156
+ // accent, which leaves the accent nothing to separate itself from. A theme
1157
+ // that tints its grounds on purpose declares the tint, and is held to it.
1158
+ // See NEUTRAL_CHROMA and TINT_HUE_RANGE.
1056
1159
  for (const [name, rgb] of opaque) {
1057
1160
  const measured = chroma(rgb);
1058
- say('the neutral ramp stays neutral', measured <= 2.2, name, measured, `chroma ${measured.toFixed(2)} <= 2.2`);
1161
+ if (theme.tint === null || theme.tint === undefined) {
1162
+ say('the neutral ramp stays neutral', measured <= NEUTRAL_CHROMA, name, measured, `chroma ${measured.toFixed(2)} <= ${NEUTRAL_CHROMA}`);
1163
+ continue;
1164
+ }
1165
+ const off = Math.abs(((((hue(rgb) - theme.tint.hue) % 360) + 540) % 360) - 180);
1166
+ const inside = measured <= theme.tint.chroma && (measured <= NEUTRAL_CHROMA || off <= TINT_HUE_RANGE);
1167
+ say(
1168
+ 'a tinted ramp stays inside its declared tint',
1169
+ inside,
1170
+ name,
1171
+ measured,
1172
+ `chroma ${measured.toFixed(2)} <= ${theme.tint.chroma}${measured > NEUTRAL_CHROMA ? `, hue ${off.toFixed(1)} degrees from ${theme.tint.hue} <= ${TINT_HUE_RANGE}` : ''}`,
1173
+ );
1174
+ }
1175
+ {
1176
+ // What the cap protected, measured for every theme. See ACCENT_OVER_TINT.
1177
+ const ground = Math.max(...opaque.map(([, rgb]) => chroma(rgb)));
1178
+ const accent = chroma(colour('--color-brand-accent'));
1179
+ const ratio = accent / Math.max(ground, 1e-6);
1180
+ say(
1181
+ 'the accent carries more colour than the ground',
1182
+ ratio >= ACCENT_OVER_TINT,
1183
+ '--color-brand-accent',
1184
+ ratio,
1185
+ `chroma ${accent.toFixed(2)} is ${ratio.toFixed(1)} times the most tinted rung's ${ground.toFixed(2)} >= ${ACCENT_OVER_TINT}`,
1186
+ );
1059
1187
  }
1060
1188
 
1061
1189
  // THE OVERLAY STEPS. See OVERLAY_STEPS.
@@ -1126,7 +1254,24 @@ const describe = (measured) => measured.map((value, i) => `${VISIONS[i].slice(0,
1126
1254
  function checkStructure(sources, states, push) {
1127
1255
  const say = (rule, ok, subject, detail) => push({ state: 'the token files', rule, ok, subject, value: null, detail });
1128
1256
 
1129
- const { dark: darkBlock, lightMedia, lightAttribute: lightBlock } = themeBlocks(sources.themesSource);
1257
+ const palette = sources.palette ?? null;
1258
+ const { dark: darkBlock, lightMedia, lightAttribute: lightBlock } = themeBlocks(sources.themesSource, palette);
1259
+
1260
+ if (palette !== null) {
1261
+ // A named theme paints over the default, so a slot it does not declare
1262
+ // shows the default's value through it, and nothing measured would say
1263
+ // which theme a colour came from. So it declares exactly what the default
1264
+ // declares: no slot missing, and none the default does not have.
1265
+ const defaults = themeBlocks(sources.themesSource, null).dark;
1266
+ const missing = [...defaults.keys()].filter((name) => !darkBlock.has(name));
1267
+ const extra = [...darkBlock.keys()].filter((name) => !defaults.has(name));
1268
+ say(
1269
+ 'a theme declares exactly the slots the default declares',
1270
+ darkBlock.size > 0 && missing.length === 0 && extra.length === 0,
1271
+ `the ${palette} theme`,
1272
+ darkBlock.size === 0 ? 'no block declares the theme' : missing.length === 0 && extra.length === 0 ? 'the two key sets match' : `missing: ${missing.join(', ') || 'none'}; extra: ${extra.join(', ') || 'none'}`,
1273
+ );
1274
+ }
1130
1275
 
1131
1276
  // A colour whose only definition is inside the theme layer is a colour that
1132
1277
  // is missing for an application that imports the token layer alone. Read
@@ -1200,13 +1345,18 @@ function checkStructure(sources, states, push) {
1200
1345
  */
1201
1346
  export function runPalette(sources) {
1202
1347
  const sheets = sheetsOf(sources);
1203
- const states = statesOf(sheets);
1348
+ const palette = sources.palette ?? null;
1349
+ const theme = { tint: sources.tint ?? null, contrast: sources.contrast ?? 'standard' };
1350
+ const states = statesOf(sheets, palette);
1204
1351
  const checks = [];
1205
1352
  const push = (check) => checks.push(check);
1206
1353
  for (const [name, values] of Object.entries(states)) {
1207
- checkState(name, values, name === 'base' ? 'base' : 'full', push);
1354
+ // The marketing root is the token layer's, not a theme's: it is measured
1355
+ // once, with the default, rather than again under every named theme.
1356
+ if (name === 'base' && palette !== null) continue;
1357
+ checkState(name, values, name === 'base' ? 'base' : 'full', push, theme);
1208
1358
  }
1209
- checkStructure({ themesSource: sheets.themes }, states, push);
1359
+ checkStructure({ themesSource: sheets.themes, palette }, states, push);
1210
1360
  return { states, checks, failures: checks.filter((check) => !check.ok) };
1211
1361
  }
1212
1362
 
@@ -28,6 +28,7 @@ import {
28
28
  MARK_ON_FILL,
29
29
  oklabToLinear,
30
30
  OPAQUE_SURFACES,
31
+ paletteSelectors,
31
32
  OVERLAY_STEPS,
32
33
  paletteStates,
33
34
  parseHex,
@@ -40,7 +41,7 @@ import {
40
41
  VEIL_ALPHA,
41
42
  withAlpha,
42
43
  } from './palette.mjs';
43
- import { color, themes as typed } from '../dist/index.js';
44
+ import { color, DEFAULT_THEME_ID, themeRegistry, themes as typed } from '../dist/index.js';
44
45
 
45
46
  const read = (name) => readFileSync(fileURLToPath(new URL(`../dist/css/${name}`, import.meta.url)), 'utf8');
46
47
  // tokens.css first, themes.css second: they share the :root selector, neither
@@ -484,7 +485,7 @@ describe('the state and chart hues', () => {
484
485
  // (deuteranopia 4.9).
485
486
  const failing = checksAfter('dark', '--color-feedback-danger', '#f0506e').map(summary);
486
487
  assert.deepEqual(failing, [
487
- `${LABEL_RULE}: --color-text-on-accent on --color-feedback-danger: 3.46:1 (worst on --color-feedback-danger on --color-surface-frame)`,
488
+ `${LABEL_RULE}: --color-text-on-danger on --color-feedback-danger: 3.46:1 (worst on --color-feedback-danger on --color-surface-frame)`,
488
489
  `${STATUS_RULE}: --color-feedback-success vs --color-feedback-danger: dE n34.5/p18.6/d5.9 >= 10`,
489
490
  `${DANGER_RULE}: --color-data-2: dE n11.4/p9.9/d6.8 >= 14 normal, 8 dichromat`,
490
491
  `${DANGER_RULE}: --color-data-3: dE n32.5/p12.7/d4.9 >= 14 normal, 8 dichromat`,
@@ -557,3 +558,107 @@ describe('the colour maths', () => {
557
558
  }
558
559
  });
559
560
  });
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // The named themes
564
+ // ---------------------------------------------------------------------------
565
+
566
+ /**
567
+ * A named theme's sources, as a browser paints it: the default's blocks, then
568
+ * the theme's own sheet under its data-palette attribute, measured under the
569
+ * profile the registry declares for it. `edit` rewrites the theme's own sheet.
570
+ */
571
+ function namedSources(theme, edit = (css) => css) {
572
+ return {
573
+ ...sources,
574
+ themes: `${sources.themes}\n${edit(read(`themes/${theme.id}.css`))}`,
575
+ palette: theme.id,
576
+ tint: theme.tint,
577
+ contrast: theme.contrast,
578
+ };
579
+ }
580
+ const named = themeRegistry.filter((theme) => theme.id !== DEFAULT_THEME_ID);
581
+ const byId = (id) => themeRegistry.find((theme) => theme.id === id);
582
+
583
+ /** The rules a named theme fails once ONE declaration of its dark block is edited, each once. */
584
+ function namedFailuresAfter(id, name, value) {
585
+ const theme = byId(id);
586
+ const opening = `${paletteSelectors(id).dark} {`;
587
+ const failures = runPalette(
588
+ namedSources(theme, (css) => editBlock(css, opening, (body) => body.replace(new RegExp(`${name}: [^;]+;`), `${name}: ${value};`))),
589
+ ).failures.filter((check) => check.state === 'dark' || check.state === 'the token files');
590
+ return [...new Set(failures.map((check) => check.rule))];
591
+ }
592
+
593
+ describe('the named themes', () => {
594
+ test('the registry lists the default first and every named theme after it', () => {
595
+ assert.equal(themeRegistry[0].id, DEFAULT_THEME_ID);
596
+ assert.ok(named.length >= 8, `${named.length} named themes`);
597
+ for (const theme of themeRegistry) {
598
+ for (const mode of ['dark', 'light']) {
599
+ for (const [key, value] of Object.entries(theme.swatch[mode])) assert.ok(parseHex(value), `${theme.id} ${mode} swatch ${key} is "${value}"`);
600
+ }
601
+ }
602
+ });
603
+
604
+ for (const theme of named) {
605
+ test(`${theme.id} passes every rule in every theme state, under its own profile`, () => {
606
+ const { checks, failures } = runPalette(namedSources(theme));
607
+ assert.deepEqual(failures.map(describeFailure), []);
608
+ for (const state of THEME_STATES) assert.ok(checks.some((check) => check.state === state), `${theme.id}: nothing measured in ${state}`);
609
+ assert.ok(!checks.some((check) => check.state === 'base'), `${theme.id}: the token layer is measured with the default, not again here`);
610
+ });
611
+ }
612
+
613
+ test('all.css paints every theme exactly as its own sheet does', () => {
614
+ const all = read('themes/all.css');
615
+ for (const theme of named) {
616
+ const own = paletteStates(namedSources(theme));
617
+ const together = paletteStates({ ...namedSources(theme), themes: `${sources.themes}\n${all}` });
618
+ for (const state of THEME_STATES) assert.deepEqual([...together[state]], [...own[state]], `${theme.id} ${state}`);
619
+ }
620
+ });
621
+
622
+ test('a theme paints every slot itself, so nothing of the default shows through', () => {
623
+ const states = paletteStates(namedSources(byId('abyss')));
624
+ const defaults = paletteStates(sources);
625
+ assert.notEqual(states.dark.get('--color-surface-frame'), defaults.dark.get('--color-surface-frame'));
626
+ const opening = `${paletteSelectors('abyss').dark} {`;
627
+ const failures = runPalette(namedSources(byId('abyss'), (css) => editBlock(css, opening, (body) => body.replace(/\s*--color-text-muted: [^;]+;/, ''))))
628
+ .failures.filter((check) => check.state === 'the token files')
629
+ .map((check) => check.rule);
630
+ assert.ok(failures.includes('a theme declares exactly the slots the default declares'), failures.join(', '));
631
+ });
632
+
633
+ test('a tinted rung past its declared chroma, or turned off its hue, is caught', () => {
634
+ assert.ok(namedFailuresAfter('abyss', '--color-surface-subtle', '#0b2414').includes('a tinted ramp stays inside its declared tint'));
635
+ assert.ok(byId('violet').tint === null, 'violet declares no tint');
636
+ assert.ok(namedFailuresAfter('violet', '--color-surface-subtle', '#1a1030').includes('the neutral ramp stays neutral'));
637
+ });
638
+
639
+ test('an accent no more colourful than its tinted ground is caught', () => {
640
+ assert.ok(namedFailuresAfter('abyss', '--color-brand-accent', '#77708c').includes('the accent carries more colour than the ground'));
641
+ });
642
+
643
+ test('the high-contrast profile holds text at 7:1 and the muted step readable', () => {
644
+ const theme = byId('high-contrast');
645
+ assert.equal(theme.contrast, 'high');
646
+ const failures = namedFailuresAfter('high-contrast', '--color-text-tertiary', '#8c8c99');
647
+ assert.ok(failures.includes('text step clears its floor'), failures.join(', '));
648
+ assert.ok(namedFailuresAfter('high-contrast', '--color-text-muted', '#4d4d58').includes('text-muted reads in high contrast'));
649
+ const checks = runPalette(namedSources(theme)).checks;
650
+ for (const rule of ['ink step clears 7:1 in high contrast', 'a hairline clears 3:1 in high contrast', 'text-muted reads in high contrast']) {
651
+ assert.ok(checks.some((check) => check.rule === rule), `${rule} is measured`);
652
+ }
653
+ assert.ok(!checks.some((check) => check.rule === 'text-muted is decoration'), 'the decoration band is replaced, not added to');
654
+ });
655
+
656
+ test('a light accent carries a dark label, while the danger fill and the scrim keep white', () => {
657
+ const states = paletteStates(namedSources(byId('iris')));
658
+ assert.notEqual(states.dark.get('--color-text-on-accent'), '#ffffff');
659
+ assert.equal(states.dark.get('--color-text-on-danger'), '#ffffff');
660
+ assert.equal(states.dark.get('--color-text-on-scrim'), '#ffffff');
661
+ const dark = states.dark.get('--color-text-on-accent');
662
+ assert.ok(namedFailuresAfter('iris', '--color-text-on-scrim', dark).includes('a label on the scrim clears 4.5:1'));
663
+ });
664
+ });