@crewlethq/tokens 0.11.0 → 0.13.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/README.md +123 -16
- package/dist/css/themes/abyss.css +323 -0
- package/dist/css/themes/all.css +2869 -0
- package/dist/css/themes/dusk.css +323 -0
- package/dist/css/themes/high-contrast.css +323 -0
- package/dist/css/themes/iris.css +323 -0
- package/dist/css/themes/ledger.css +323 -0
- package/dist/css/themes/nightshade.css +323 -0
- package/dist/css/themes/nocturne.css +323 -0
- package/dist/css/themes/origin.css +323 -0
- package/dist/css/themes/violet.css +323 -0
- package/dist/css/themes.css +9 -0
- package/dist/css/tokens.css +4 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/themes.d.ts +457 -0
- package/dist/themes.js +473 -0
- package/package.json +3 -2
- package/test/color.mjs +6 -0
- package/test/palette.d.mts +26 -1
- package/test/palette.mjs +185 -35
- package/test/palette.test.mjs +107 -2
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
|
-
/**
|
|
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
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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-
|
|
234
|
-
['--color-text-on-
|
|
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
|
-
/**
|
|
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,
|
|
631
|
-
lightMedia: block(LIGHT_MEDIA,
|
|
632
|
-
lightAttribute: block(null,
|
|
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
|
-
/**
|
|
652
|
-
|
|
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
|
|
657
|
-
const
|
|
658
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/test/palette.test.mjs
CHANGED
|
@@ -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-
|
|
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
|
+
});
|