@navecss/tokens 0.1.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/LICENSE +55 -0
- package/README.md +122 -0
- package/TOKENS.md +180 -0
- package/dist/breakpoints.d.ts +25 -0
- package/dist/breakpoints.js +44 -0
- package/dist/build-record.json +77 -0
- package/dist/core-contract.json +30 -0
- package/dist/lib/bin.d.ts +2 -0
- package/dist/lib/bin.js +113 -0
- package/dist/lib/builder.d.ts +21 -0
- package/dist/lib/builder.js +41 -0
- package/dist/lib/collision.d.ts +12 -0
- package/dist/lib/collision.js +46 -0
- package/dist/lib/composite-value.d.ts +3 -0
- package/dist/lib/composite-value.js +152 -0
- package/dist/lib/core-version.d.ts +12 -0
- package/dist/lib/core-version.js +40 -0
- package/dist/lib/dtcg-descriptions.d.ts +2 -0
- package/dist/lib/dtcg-descriptions.js +23 -0
- package/dist/lib/dtcg-malformed.d.ts +10 -0
- package/dist/lib/dtcg-malformed.js +56 -0
- package/dist/lib/dtcg-outputs.d.ts +2 -0
- package/dist/lib/dtcg-outputs.js +32 -0
- package/dist/lib/dtcg-shape.d.ts +13 -0
- package/dist/lib/dtcg-shape.js +113 -0
- package/dist/lib/errors.d.ts +23 -0
- package/dist/lib/errors.js +34 -0
- package/dist/lib/facade.d.ts +26 -0
- package/dist/lib/facade.js +97 -0
- package/dist/lib/formats.d.ts +6 -0
- package/dist/lib/formats.js +236 -0
- package/dist/lib/json-pointer.d.ts +2 -0
- package/dist/lib/json-pointer.js +28 -0
- package/dist/lib/overrides.d.ts +2 -0
- package/dist/lib/overrides.js +59 -0
- package/dist/lib/package-root.d.ts +2 -0
- package/dist/lib/package-root.js +31 -0
- package/dist/lib/reader.d.ts +8 -0
- package/dist/lib/reader.js +136 -0
- package/dist/lib/theming/adjacency-source.d.ts +20 -0
- package/dist/lib/theming/adjacency-source.js +103 -0
- package/dist/lib/theming/adjacency.d.ts +17 -0
- package/dist/lib/theming/adjacency.js +133 -0
- package/dist/lib/theming/build-record.d.ts +45 -0
- package/dist/lib/theming/build-record.js +87 -0
- package/dist/lib/theming/build-step.d.ts +14 -0
- package/dist/lib/theming/build-step.js +91 -0
- package/dist/lib/theming/color-math.d.ts +33 -0
- package/dist/lib/theming/color-math.js +125 -0
- package/dist/lib/theming/comment-open-tracker.d.ts +1 -0
- package/dist/lib/theming/comment-open-tracker.js +49 -0
- package/dist/lib/theming/consumer-build.d.ts +18 -0
- package/dist/lib/theming/consumer-build.js +29 -0
- package/dist/lib/theming/contact-sheet.d.ts +22 -0
- package/dist/lib/theming/contact-sheet.js +123 -0
- package/dist/lib/theming/contrast.d.ts +31 -0
- package/dist/lib/theming/contrast.js +133 -0
- package/dist/lib/theming/copy-lint.d.ts +13 -0
- package/dist/lib/theming/copy-lint.js +98 -0
- package/dist/lib/theming/core-contract.d.ts +29 -0
- package/dist/lib/theming/core-contract.js +71 -0
- package/dist/lib/theming/core-source.d.ts +2 -0
- package/dist/lib/theming/core-source.js +18 -0
- package/dist/lib/theming/css-color-4.d.ts +19 -0
- package/dist/lib/theming/css-color-4.js +66 -0
- package/dist/lib/theming/css-named-colours.d.ts +1 -0
- package/dist/lib/theming/css-named-colours.js +151 -0
- package/dist/lib/theming/descriptions.d.ts +1 -0
- package/dist/lib/theming/descriptions.js +21 -0
- package/dist/lib/theming/emit.d.ts +10 -0
- package/dist/lib/theming/emit.js +62 -0
- package/dist/lib/theming/feedback-signal.d.ts +11 -0
- package/dist/lib/theming/feedback-signal.js +23 -0
- package/dist/lib/theming/guard-message-probes.d.ts +6 -0
- package/dist/lib/theming/guard-message-probes.js +96 -0
- package/dist/lib/theming/ladder.d.ts +15 -0
- package/dist/lib/theming/ladder.js +81 -0
- package/dist/lib/theming/neutral.d.ts +12 -0
- package/dist/lib/theming/neutral.js +76 -0
- package/dist/lib/theming/on-star.d.ts +11 -0
- package/dist/lib/theming/on-star.js +87 -0
- package/dist/lib/theming/palette-record.d.ts +2 -0
- package/dist/lib/theming/palette-record.js +15 -0
- package/dist/lib/theming/pipeline.d.ts +43 -0
- package/dist/lib/theming/pipeline.js +104 -0
- package/dist/lib/theming/ramp.d.ts +34 -0
- package/dist/lib/theming/ramp.js +92 -0
- package/dist/lib/theming/seed-channel-split.d.ts +8 -0
- package/dist/lib/theming/seed-channel-split.js +99 -0
- package/dist/lib/theming/seed-form-parsers-css-color-4.d.ts +8 -0
- package/dist/lib/theming/seed-form-parsers-css-color-4.js +85 -0
- package/dist/lib/theming/seed-form-parsers.d.ts +6 -0
- package/dist/lib/theming/seed-form-parsers.js +155 -0
- package/dist/lib/theming/seed-ingest.d.ts +15 -0
- package/dist/lib/theming/seed-ingest.js +112 -0
- package/dist/lib/theming/seed-input.d.ts +22 -0
- package/dist/lib/theming/seed-input.js +1 -0
- package/dist/lib/theming/seed-refusal.d.ts +21 -0
- package/dist/lib/theming/seed-refusal.js +77 -0
- package/dist/lib/theming/semantics.d.ts +19 -0
- package/dist/lib/theming/semantics.js +71 -0
- package/dist/lib/theming/shipped-seeds.d.ts +2 -0
- package/dist/lib/theming/shipped-seeds.js +5 -0
- package/dist/lib/theming/step-table.d.ts +5 -0
- package/dist/lib/theming/step-table.js +26 -0
- package/dist/lib/theming/third-party-provenance.d.ts +9 -0
- package/dist/lib/theming/third-party-provenance.js +35 -0
- package/dist/lib/theming/tokens-source.d.ts +3 -0
- package/dist/lib/theming/tokens-source.js +61 -0
- package/dist/lib/token-name.d.ts +2 -0
- package/dist/lib/token-name.js +28 -0
- package/dist/lib/validate-report.d.ts +19 -0
- package/dist/lib/validate-report.js +93 -0
- package/dist/lib/validate.d.ts +16 -0
- package/dist/lib/validate.js +65 -0
- package/dist/palette-record.json +293 -0
- package/dist/tokens.css +433 -0
- package/dist/tokens.d.ts +90 -0
- package/dist/tokens.js +88 -0
- package/package.json +85 -0
- package/tokens.json +938 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { loadShippedTokensSource } from "./tokens-source.js";
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
function isPairClass(value) {
|
|
6
|
+
return value === 'text' || value === 'non-text';
|
|
7
|
+
}
|
|
8
|
+
function flattenSubjectPartners(subject, partners) {
|
|
9
|
+
if (!Array.isArray(partners)) {
|
|
10
|
+
throw new TypeError(`Adjacency declaration: adjacency["${subject}"] in @navecss/tokens' own bundled tokens.json is not an array of partners.`);
|
|
11
|
+
}
|
|
12
|
+
return partners.map((partner) => {
|
|
13
|
+
if (!isRecord(partner) || typeof partner.against !== 'string' || !isPairClass(partner.class)) {
|
|
14
|
+
throw new TypeError(`Adjacency declaration: adjacency["${subject}"] in @navecss/tokens' own bundled tokens.json carries a malformed partner entry.`);
|
|
15
|
+
}
|
|
16
|
+
return { subject, against: partner.against, class: partner.class };
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function materializeAdjacency(source) {
|
|
20
|
+
if (!isRecord(source)) {
|
|
21
|
+
throw new TypeError("Adjacency declaration: @navecss/tokens' own bundled tokens.json did not parse to an object.");
|
|
22
|
+
}
|
|
23
|
+
const extensions = source.$extensions;
|
|
24
|
+
const theming = isRecord(extensions) ? extensions['dev.navecss.theming'] : undefined;
|
|
25
|
+
const declared = isRecord(theming) ? theming.adjacency : undefined;
|
|
26
|
+
if (!isRecord(declared)) {
|
|
27
|
+
throw new Error("Adjacency declaration: @navecss/tokens' own bundled tokens.json carries no $extensions.dev.navecss.theming.adjacency block.");
|
|
28
|
+
}
|
|
29
|
+
const result = [];
|
|
30
|
+
for (const [subject, partners] of Object.entries(declared)) {
|
|
31
|
+
if (subject === 'comment')
|
|
32
|
+
continue;
|
|
33
|
+
result.push(...flattenSubjectPartners(subject, partners));
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
export const ADJACENCY = materializeAdjacency(loadShippedTokensSource());
|
|
38
|
+
export const ADJACENCY_EXCLUSIONS = {
|
|
39
|
+
'content.disabled': 'disabled text: excluded from the checked pair set, and the exclusion is worded ' +
|
|
40
|
+
'over a component that is INACTIVE. It does not reach an aria-disabled element ' +
|
|
41
|
+
'that is still operable and still fires on activation; that case is not excluded ' +
|
|
42
|
+
'and is not answered here.',
|
|
43
|
+
'border.disabled': 'disabled control: excluded from the checked pair set, and the exclusion is worded ' +
|
|
44
|
+
'over a component that is INACTIVE. It does not reach an aria-disabled element ' +
|
|
45
|
+
'that is still operable and still fires on activation; that case is not excluded ' +
|
|
46
|
+
'and is not answered here.',
|
|
47
|
+
'border.default': 'decorative divider: excluded as a decorative divider or card edge, and the ' +
|
|
48
|
+
'exclusion is void the moment such a border is applied to a control.',
|
|
49
|
+
};
|
|
50
|
+
export const ADJACENCY_OPEN = {
|
|
51
|
+
'border.strong': 'open: whether this border can identify a control, and therefore whether its ' +
|
|
52
|
+
'contrast is checked or it is decorative, is an open question this project has ' +
|
|
53
|
+
'not settled and is not decided here',
|
|
54
|
+
'feedback.warning.foreground': 'open, not yet excluded or covered: whether this family gains a standalone ' +
|
|
55
|
+
'foreground role of its own is unresolved and is not decided here',
|
|
56
|
+
'feedback.success.foreground': 'open — see feedback.warning.foreground',
|
|
57
|
+
'feedback.info.foreground': 'open — see feedback.warning.foreground',
|
|
58
|
+
};
|
|
59
|
+
function assertEscapesAreDisjointFromDeclaration(mentioned, exclusions, open) {
|
|
60
|
+
for (const [record, name] of [
|
|
61
|
+
[exclusions, 'ADJACENCY_EXCLUSIONS'],
|
|
62
|
+
[open, 'ADJACENCY_OPEN'],
|
|
63
|
+
]) {
|
|
64
|
+
const both = Object.keys(record).filter((slot) => mentioned.has(slot));
|
|
65
|
+
if (both.length > 0) {
|
|
66
|
+
const listed = both.map((slot) => `"${slot}"`).join(', ');
|
|
67
|
+
throw new Error(`Adjacency escape-record conflict: ${listed} is declared in the adjacency set AND ` +
|
|
68
|
+
`listed in ${name}. A slot is one or the other: remove the declaration, or remove ` +
|
|
69
|
+
'the listing that the declaration has already answered.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function assertNoOrphanedSemanticSlot(slots, adjacency = ADJACENCY, exclusions = ADJACENCY_EXCLUSIONS, open = ADJACENCY_OPEN) {
|
|
74
|
+
const mentioned = new Set();
|
|
75
|
+
for (const pair of adjacency) {
|
|
76
|
+
mentioned.add(pair.subject);
|
|
77
|
+
mentioned.add(pair.against);
|
|
78
|
+
}
|
|
79
|
+
assertEscapesAreDisjointFromDeclaration(mentioned, exclusions, open);
|
|
80
|
+
const openSlots = [];
|
|
81
|
+
const excludedSlots = [];
|
|
82
|
+
for (const slot of slots) {
|
|
83
|
+
if (mentioned.has(slot))
|
|
84
|
+
continue;
|
|
85
|
+
if (Object.hasOwn(exclusions, slot)) {
|
|
86
|
+
excludedSlots.push(slot);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (Object.hasOwn(open, slot)) {
|
|
90
|
+
openSlots.push(slot);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`Adjacency coverage: the semantic slot "${slot}" carries no adjacency declaration, so ` +
|
|
94
|
+
'no contrast pair in this build touches it at all. Declare its legal partners in ' +
|
|
95
|
+
'tokens.json, under $extensions.dev.navecss.theming.adjacency. The two escape records ' +
|
|
96
|
+
'are not alternatives you may take here: a slot is excluded only where the project has ' +
|
|
97
|
+
'already settled that its contrast is not checked, and it is marked open only where ' +
|
|
98
|
+
'that question is genuinely unresolved and is being counted as open. If you believe ' +
|
|
99
|
+
'this slot belongs in one of those, open an issue rather than adding it to either ' +
|
|
100
|
+
'record.');
|
|
101
|
+
}
|
|
102
|
+
return { open: openSlots, excluded: excludedSlots };
|
|
103
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Adjacency } from './adjacency-source.ts';
|
|
2
|
+
export type { Adjacency, MeasuredOpenSlots, OrphanCheckResult } from './adjacency-source.ts';
|
|
3
|
+
export { ADJACENCY, ADJACENCY_EXCLUSIONS, ADJACENCY_OPEN, assertNoOrphanedSemanticSlot, } from './adjacency-source.ts';
|
|
4
|
+
declare const MINIMUM_COVERAGE_CATEGORIES: readonly ["contentOnSurface", "inversePair", "onActionOnAction", "textOnFeedback", "sunken", "borderControl", "borderFocus", "filledAction", "filledFeedback"];
|
|
5
|
+
export type CoverageCategory = (typeof MINIMUM_COVERAGE_CATEGORIES)[number];
|
|
6
|
+
export declare function coverageCategories(adjacency?: readonly Adjacency[]): Record<CoverageCategory, boolean>;
|
|
7
|
+
export declare function assertCoverageFloor(adjacency?: readonly Adjacency[]): void;
|
|
8
|
+
export declare function shippedSurfaces(slots?: readonly string[]): readonly string[];
|
|
9
|
+
export declare function assertShippedSurfaceCountProvenance(surfaces?: readonly string[]): void;
|
|
10
|
+
export interface ForbiddenAdjacencyRule {
|
|
11
|
+
entry: number;
|
|
12
|
+
description: string;
|
|
13
|
+
forbids: (subject: string, against: string) => boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function findForbiddenAdjacencyRule(subject: string, against: string): ForbiddenAdjacencyRule | undefined;
|
|
16
|
+
export declare function assertNoForbiddenAdjacency(adjacency?: readonly Adjacency[]): void;
|
|
17
|
+
export declare function assertNoFocusableAdjacentToActionFill(adjacency?: readonly Adjacency[]): void;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ADJACENCY } from "./adjacency-source.js";
|
|
2
|
+
import { SEMANTIC_SLOTS } from "./semantics.js";
|
|
3
|
+
export { ADJACENCY, ADJACENCY_EXCLUSIONS, ADJACENCY_OPEN, assertNoOrphanedSemanticSlot, } from "./adjacency-source.js";
|
|
4
|
+
const MINIMUM_COVERAGE_CATEGORIES = [
|
|
5
|
+
'contentOnSurface',
|
|
6
|
+
'inversePair',
|
|
7
|
+
'onActionOnAction',
|
|
8
|
+
'textOnFeedback',
|
|
9
|
+
'sunken',
|
|
10
|
+
'borderControl',
|
|
11
|
+
'borderFocus',
|
|
12
|
+
'filledAction',
|
|
13
|
+
'filledFeedback',
|
|
14
|
+
];
|
|
15
|
+
export function coverageCategories(adjacency = ADJACENCY) {
|
|
16
|
+
return {
|
|
17
|
+
contentOnSurface: adjacency.some((a) => a.subject.startsWith('content.') && a.against.startsWith('surface.')),
|
|
18
|
+
inversePair: adjacency.some((a) => a.subject === 'content.inverse' && a.against === 'surface.inverse'),
|
|
19
|
+
onActionOnAction: adjacency.some((a) => a.subject === 'on-action.primary'),
|
|
20
|
+
textOnFeedback: adjacency.some((a) => a.subject.startsWith('on-feedback.')),
|
|
21
|
+
sunken: adjacency.some((a) => a.against === 'surface.sunken'),
|
|
22
|
+
borderControl: adjacency.some((a) => a.subject === 'border.control'),
|
|
23
|
+
borderFocus: adjacency.some((a) => a.subject === 'border.focus'),
|
|
24
|
+
filledAction: adjacency.some((a) => a.subject === 'action.primary' && a.class === 'non-text'),
|
|
25
|
+
filledFeedback: adjacency.some((a) => a.subject === 'feedback.danger' && a.class === 'non-text'),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const COVERAGE_FLOOR_VIOLATION_REASON = 'missing from the declared adjacency set. This set has a required minimum: every ' +
|
|
29
|
+
'category in it keeps at least one declared pair, so that dropping coverage is a ' +
|
|
30
|
+
'visible decision rather than a quiet one. Restore the declaration. If you believe ' +
|
|
31
|
+
'the category should no longer be required, that is a decision for the maintainers ' +
|
|
32
|
+
'and not a change to make here: open an issue naming the category and why, and ' +
|
|
33
|
+
'leave the removal out of the pull request until that issue is answered.';
|
|
34
|
+
export function assertCoverageFloor(adjacency = ADJACENCY) {
|
|
35
|
+
const coverage = coverageCategories(adjacency);
|
|
36
|
+
for (const category of MINIMUM_COVERAGE_CATEGORIES) {
|
|
37
|
+
if (!coverage[category]) {
|
|
38
|
+
throw new Error(`Adjacency coverage violation: the required coverage category "${category}" is ${COVERAGE_FLOOR_VIOLATION_REASON}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const SHIPPED_SURFACE_PROVENANCE = {
|
|
43
|
+
count: 5,
|
|
44
|
+
citation: 'Two documented contrast requirements quantify over the shipped surfaces: ' +
|
|
45
|
+
'border.control against every surface.* a control sits on, and border.focus ' +
|
|
46
|
+
'against every shipped surface.* and against the action.* fills it can land on. ' +
|
|
47
|
+
'This record pins the surface.* half of both and nothing else: the five surface ' +
|
|
48
|
+
'slots the shipped build declares (surface.base, surface.raised, surface.overlay, ' +
|
|
49
|
+
'surface.sunken, surface.inverse). The border.focus half is unconditioned where ' +
|
|
50
|
+
"border.control's is conditioned, which is deliberate and stricter. The action.* " +
|
|
51
|
+
'half is deliberately never declared, because a focusable slot may not be declared ' +
|
|
52
|
+
'adjacent to an action fill.',
|
|
53
|
+
};
|
|
54
|
+
export function shippedSurfaces(slots = SEMANTIC_SLOTS) {
|
|
55
|
+
return slots.filter((slot) => slot.startsWith('surface.'));
|
|
56
|
+
}
|
|
57
|
+
export function assertShippedSurfaceCountProvenance(surfaces = shippedSurfaces()) {
|
|
58
|
+
if (surfaces.length !== SHIPPED_SURFACE_PROVENANCE.count) {
|
|
59
|
+
throw new Error(`Shipped surface provenance: ${surfaces.length} shipped surface(s) ` +
|
|
60
|
+
`(${surfaces.join(', ')}) no longer match the frozen count ` +
|
|
61
|
+
`${SHIPPED_SURFACE_PROVENANCE.count}. ${SHIPPED_SURFACE_PROVENANCE.citation} ` +
|
|
62
|
+
'Review whether border.control and border.focus need a matching new pair. This ' +
|
|
63
|
+
'coverage list is the minimum set of pairs this project documents a contrast ' +
|
|
64
|
+
'floor for, so narrowing it changes what is documented and is not yours to ' +
|
|
65
|
+
'change here: open an issue. Bumping the count to make the build pass is not ' +
|
|
66
|
+
'the route.');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const isFillSlot = (slot, family) => slot.startsWith(family) && !slot.endsWith('.foreground');
|
|
70
|
+
const FORBIDDEN_ADJACENCY_RULES = [
|
|
71
|
+
{
|
|
72
|
+
entry: 1,
|
|
73
|
+
description: '`content.secondary` adjacent to `surface.inverse`',
|
|
74
|
+
forbids: (subject, against) => subject === 'content.secondary' && against === 'surface.inverse',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
entry: 2,
|
|
78
|
+
description: 'any `content.*` adjacent to a feedback FILL',
|
|
79
|
+
forbids: (subject, against) => subject.startsWith('content.') && isFillSlot(against, 'feedback.'),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
entry: 3,
|
|
83
|
+
description: 'any filled `action.*` or `feedback.*` adjacent to `surface.inverse`',
|
|
84
|
+
forbids: (subject, against) => (isFillSlot(subject, 'action.') || isFillSlot(subject, 'feedback.')) &&
|
|
85
|
+
against === 'surface.inverse',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
entry: 4,
|
|
89
|
+
description: '`content.inverse` adjacent to `action.secondary`',
|
|
90
|
+
forbids: (subject, against) => subject === 'content.inverse' && against === 'action.secondary',
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
export function findForbiddenAdjacencyRule(subject, against) {
|
|
94
|
+
return FORBIDDEN_ADJACENCY_RULES.find((rule) => rule.forbids(subject, against) || rule.forbids(against, subject));
|
|
95
|
+
}
|
|
96
|
+
const FORBIDDEN_ADJACENCY_REASON = 'The rules in this file record decisions about which colour ' +
|
|
97
|
+
'pairs this design system will declare and test; they are not a list to edit to ' +
|
|
98
|
+
'make a build pass. Remove the declaration, or, if you need this pair, open an ' +
|
|
99
|
+
'issue describing it and why, and leave it out of the pull request until that ' +
|
|
100
|
+
'issue is answered.';
|
|
101
|
+
export function assertNoForbiddenAdjacency(adjacency = ADJACENCY) {
|
|
102
|
+
for (const pair of adjacency) {
|
|
103
|
+
const rule = findForbiddenAdjacencyRule(pair.subject, pair.against);
|
|
104
|
+
if (rule) {
|
|
105
|
+
throw new Error(`Adjacency rule violation (rule ${rule.entry}): the pair (${pair.subject}, ` +
|
|
106
|
+
`${pair.against}) is declared, and rule ${rule.entry} forbids it: ` +
|
|
107
|
+
`${rule.description}. ${FORBIDDEN_ADJACENCY_REASON}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const FOCUSABLE_SLOTS = new Set(['border.focus']);
|
|
112
|
+
const isActionFill = (slot) => slot.startsWith('action.');
|
|
113
|
+
const FOCUSABLE_ADJACENT_TO_ACTION_FILL_REASON = 'A focus indicator is drawn offset outside the control ' +
|
|
114
|
+
"it belongs to, so it lands on whatever sits behind that control, not on the control's " +
|
|
115
|
+
'own fill; a declaration putting one against an action fill therefore describes a ' +
|
|
116
|
+
'focusable element sitting ON that fill, which is a composition rather than a colour ' +
|
|
117
|
+
'pairing. This rule records a decision about which colour pairs this design system ' +
|
|
118
|
+
'will declare and test; it is not a check to silence or narrow to make a build pass. ' +
|
|
119
|
+
'Remove the declaration, or, if you need this pair, open an issue describing it and ' +
|
|
120
|
+
'why, and leave it out of the pull request until that issue is answered.';
|
|
121
|
+
export function assertNoFocusableAdjacentToActionFill(adjacency = ADJACENCY) {
|
|
122
|
+
for (const pair of adjacency) {
|
|
123
|
+
const isFocusableSubject = FOCUSABLE_SLOTS.has(pair.subject);
|
|
124
|
+
const isFocusableAgainst = FOCUSABLE_SLOTS.has(pair.against);
|
|
125
|
+
if ((isFocusableSubject && isActionFill(pair.against)) ||
|
|
126
|
+
(isFocusableAgainst && isActionFill(pair.subject))) {
|
|
127
|
+
const focusable = isFocusableSubject ? pair.subject : pair.against;
|
|
128
|
+
const fill = isFocusableSubject ? pair.against : pair.subject;
|
|
129
|
+
throw new Error(`Adjacency rule violation: the focusable slot "${focusable}" is declared adjacent ` +
|
|
130
|
+
`to the action fill "${fill}". ${FOCUSABLE_ADJACENT_TO_ACTION_FILL_REASON}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MeasuredOpenSlots } from './adjacency.ts';
|
|
2
|
+
import type { Oklch } from './color-math.ts';
|
|
3
|
+
import type { PipelineResult, Seeds } from './pipeline.ts';
|
|
4
|
+
import type { SeedInput, SeedInputs } from './seed-input.ts';
|
|
5
|
+
declare const TARGET_GAMUT: {
|
|
6
|
+
readonly gamut: "srgb";
|
|
7
|
+
readonly assumption: string;
|
|
8
|
+
};
|
|
9
|
+
interface SeedBuildRecord {
|
|
10
|
+
input: SeedInput;
|
|
11
|
+
resolved: Oklch;
|
|
12
|
+
intensityScalar: number;
|
|
13
|
+
sLightness: number;
|
|
14
|
+
substitutions: {
|
|
15
|
+
lightnessBandClamped: {
|
|
16
|
+
applied: boolean;
|
|
17
|
+
reason?: string | undefined;
|
|
18
|
+
};
|
|
19
|
+
normalized: {
|
|
20
|
+
applied: boolean;
|
|
21
|
+
reason?: string | undefined;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
achromatic: boolean;
|
|
25
|
+
seedColourAppearsInRamp: boolean;
|
|
26
|
+
seedColourStep?: number | undefined;
|
|
27
|
+
}
|
|
28
|
+
export interface BuildRecord {
|
|
29
|
+
spec: string;
|
|
30
|
+
targetGamut: typeof TARGET_GAMUT;
|
|
31
|
+
seeds: Record<'danger' | 'primary', SeedBuildRecord>;
|
|
32
|
+
achromaticBranch: {
|
|
33
|
+
reason?: string | undefined;
|
|
34
|
+
seed?: Oklch | undefined;
|
|
35
|
+
selected: boolean;
|
|
36
|
+
substitutedSlots: readonly string[];
|
|
37
|
+
};
|
|
38
|
+
tint: {
|
|
39
|
+
hue: number;
|
|
40
|
+
source: 'declared-default' | 'primary-seed';
|
|
41
|
+
};
|
|
42
|
+
openAdjacencySlots?: MeasuredOpenSlots;
|
|
43
|
+
}
|
|
44
|
+
export declare function composeBuildRecord(seeds: Seeds, result: PipelineResult, openAdjacencySlots?: MeasuredOpenSlots, seedInputs?: SeedInputs): BuildRecord;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ACHROMATIC_BRANCH_SLOTS } from "./semantics.js";
|
|
2
|
+
const TARGET_GAMUT = {
|
|
3
|
+
gamut: 'srgb',
|
|
4
|
+
assumption: 'sRGB is the target gamut, stated as an explicit assumption. Every generated ' +
|
|
5
|
+
'value is expressible in sRGB and no wide-gamut colour function is emitted. Wide-gamut ' +
|
|
6
|
+
'(display-p3) output is a deliberate v1 non-goal: a future wide-gamut output regenerates ' +
|
|
7
|
+
'the ramp rather than reusing this one.',
|
|
8
|
+
};
|
|
9
|
+
const NORMALIZED_REASON = 'seed chroma reduced at constant lightness and hue to bring it inside sRGB, once, at ' +
|
|
10
|
+
'ingest; naive per-channel clipping and any lightness-adjusting map are rejected';
|
|
11
|
+
const MAPPED_TO_WHITE_REASON = 'seed lightness is at or above 1, where no sRGB colour has any chroma, so it was mapped ' +
|
|
12
|
+
'to white, once, at ingest, as CSS Color 4 gamut mapping does';
|
|
13
|
+
const MAPPED_TO_BLACK_REASON = 'seed lightness is at or below 0, where no sRGB colour has any chroma, so it was mapped ' +
|
|
14
|
+
'to black, once, at ingest, as CSS Color 4 gamut mapping does';
|
|
15
|
+
const BAND_CLAMP_REASON = 'the lightness used to compute the intensity scalar was clamped into the band [0.20, 0.90]; ' +
|
|
16
|
+
"the seed's own lightness, hue and the emitted ramp lightnesses are untouched";
|
|
17
|
+
const ACHROMATIC_REASON = 'the primary seed carries exactly zero chroma, which is a legal input rather than an error ' +
|
|
18
|
+
'and selects the achromatic branch of the semantic mapping: the slots below are re-pointed ' +
|
|
19
|
+
'at the neutral ramp at the resolve-semantics stage';
|
|
20
|
+
const SAME_COLOUR_TOLERANCE = 1e-4;
|
|
21
|
+
function findSeedColourStep(seed, ramp) {
|
|
22
|
+
const found = ramp.steps.find((step) => Math.abs(step.l - seed.l) < SAME_COLOUR_TOLERANCE &&
|
|
23
|
+
Math.abs(step.c - seed.c) < SAME_COLOUR_TOLERANCE);
|
|
24
|
+
return found?.step;
|
|
25
|
+
}
|
|
26
|
+
function normalizedReason(normalization) {
|
|
27
|
+
switch (normalization) {
|
|
28
|
+
case 'chroma-reduced': {
|
|
29
|
+
return NORMALIZED_REASON;
|
|
30
|
+
}
|
|
31
|
+
case 'mapped-to-black': {
|
|
32
|
+
return MAPPED_TO_BLACK_REASON;
|
|
33
|
+
}
|
|
34
|
+
case 'mapped-to-white': {
|
|
35
|
+
return MAPPED_TO_WHITE_REASON;
|
|
36
|
+
}
|
|
37
|
+
default: {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function seedBuildRecord(input, record, ramp) {
|
|
43
|
+
const seedColourStep = findSeedColourStep(record.usedSeed, ramp);
|
|
44
|
+
return {
|
|
45
|
+
input,
|
|
46
|
+
resolved: record.usedSeed,
|
|
47
|
+
intensityScalar: record.s,
|
|
48
|
+
sLightness: record.sLightness,
|
|
49
|
+
substitutions: {
|
|
50
|
+
lightnessBandClamped: {
|
|
51
|
+
applied: record.bandClamped,
|
|
52
|
+
reason: record.bandClamped ? BAND_CLAMP_REASON : undefined,
|
|
53
|
+
},
|
|
54
|
+
normalized: {
|
|
55
|
+
applied: record.normalized,
|
|
56
|
+
reason: normalizedReason(record.normalization),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
achromatic: record.achromatic,
|
|
60
|
+
seedColourAppearsInRamp: seedColourStep !== undefined,
|
|
61
|
+
seedColourStep,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function composeBuildRecord(seeds, result, openAdjacencySlots, seedInputs = {}) {
|
|
65
|
+
const isBranch = result.records.achromaticBranch;
|
|
66
|
+
const primaryInput = seedInputs.primary ?? { form: 'oklch', value: seeds.primary };
|
|
67
|
+
const dangerInput = seedInputs.danger ?? { form: 'oklch', value: seeds.danger };
|
|
68
|
+
return {
|
|
69
|
+
spec: 'documented in this package\'s README ("Theming")',
|
|
70
|
+
targetGamut: TARGET_GAMUT,
|
|
71
|
+
seeds: {
|
|
72
|
+
primary: seedBuildRecord(primaryInput, result.records.primary, result.primaryRamp),
|
|
73
|
+
danger: seedBuildRecord(dangerInput, result.records.danger, result.dangerRamp),
|
|
74
|
+
},
|
|
75
|
+
achromaticBranch: {
|
|
76
|
+
reason: isBranch ? ACHROMATIC_REASON : undefined,
|
|
77
|
+
seed: isBranch ? result.records.primary.usedSeed : undefined,
|
|
78
|
+
selected: isBranch,
|
|
79
|
+
substitutedSlots: isBranch ? ACHROMATIC_BRANCH_SLOTS : [],
|
|
80
|
+
},
|
|
81
|
+
tint: {
|
|
82
|
+
hue: result.records.tintHue,
|
|
83
|
+
source: isBranch ? 'declared-default' : 'primary-seed',
|
|
84
|
+
},
|
|
85
|
+
...(openAdjacencySlots !== undefined && { openAdjacencySlots }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OutputFile } from '../builder.ts';
|
|
2
|
+
import type { Seeds } from './pipeline.ts';
|
|
3
|
+
export interface ThemingBuildOptions {
|
|
4
|
+
distDir?: string;
|
|
5
|
+
env?: ReadonlyMap<number, number>;
|
|
6
|
+
seeds?: Seeds;
|
|
7
|
+
tokensSourcePaths?: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
export interface ThemingComposition {
|
|
10
|
+
css: string;
|
|
11
|
+
files: OutputFile[];
|
|
12
|
+
}
|
|
13
|
+
export declare function composeThemingOutputs(options?: ThemingBuildOptions): ThemingComposition;
|
|
14
|
+
export declare function withThemingLayer(outputs: readonly OutputFile[], options?: ThemingBuildOptions): OutputFile[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { collectDescriptionsFromSources } from "../dtcg-descriptions.js";
|
|
3
|
+
import { assertCoverageFloor, assertNoFocusableAdjacentToActionFill, assertNoForbiddenAdjacency, assertNoOrphanedSemanticSlot, assertShippedSurfaceCountProvenance, } from "./adjacency.js";
|
|
4
|
+
import { composeBuildRecord } from "./build-record.js";
|
|
5
|
+
import { composeContactSheet } from "./contact-sheet.js";
|
|
6
|
+
import { assertContrastFloors, assertFloorProvenance, assertNoSameStepViolations, checkSameStepLint, runContrastHarness, } from "./contrast.js";
|
|
7
|
+
import { assertDescriptionsAreClean, assertHarnessFramingIsClean, assertNoticeIsClean, assertNoticeIsEmitted, FEEDBACK_SHARED_IDENTITY_NOTICE, RETHEMING_NOTICE, } from "./copy-lint.js";
|
|
8
|
+
import { buildCoreContractManifest } from "./core-source.js";
|
|
9
|
+
import { SLOT_DESCRIPTIONS } from "./descriptions.js";
|
|
10
|
+
import { emitCss } from "./emit.js";
|
|
11
|
+
import { assertLadderOrder } from "./ladder.js";
|
|
12
|
+
import { assertNeutralChromaCeilingWithinMargin } from "./neutral.js";
|
|
13
|
+
import { assertOnStarShape } from "./on-star.js";
|
|
14
|
+
import { composePaletteRecord } from "./palette-record.js";
|
|
15
|
+
import { runPipeline } from "./pipeline.js";
|
|
16
|
+
import { ADVERSARIAL_SEEDS, DEFAULT_ENV } from "./ramp.js";
|
|
17
|
+
import { SEMANTIC_SLOTS } from "./semantics.js";
|
|
18
|
+
import { SHIPPED_SEEDS } from "./shipped-seeds.js";
|
|
19
|
+
import { TOKENS_JSON_PATH } from "./tokens-source.js";
|
|
20
|
+
function runSourceGuards(tokensSourcePaths) {
|
|
21
|
+
assertNoForbiddenAdjacency();
|
|
22
|
+
assertNoFocusableAdjacentToActionFill();
|
|
23
|
+
assertCoverageFloor();
|
|
24
|
+
const orphanCheck = assertNoOrphanedSemanticSlot(SEMANTIC_SLOTS);
|
|
25
|
+
assertOnStarShape();
|
|
26
|
+
assertLadderOrder();
|
|
27
|
+
assertNoticeIsClean();
|
|
28
|
+
assertNoticeIsClean(FEEDBACK_SHARED_IDENTITY_NOTICE, 'Feedback notice');
|
|
29
|
+
assertHarnessFramingIsClean();
|
|
30
|
+
assertFloorProvenance();
|
|
31
|
+
assertNeutralChromaCeilingWithinMargin();
|
|
32
|
+
assertShippedSurfaceCountProvenance();
|
|
33
|
+
assertDescriptionsAreClean(SLOT_DESCRIPTIONS, ...collectDescriptionsFromSources(tokensSourcePaths));
|
|
34
|
+
return orphanCheck;
|
|
35
|
+
}
|
|
36
|
+
function runResultGuards(result) {
|
|
37
|
+
assertContrastFloors(runContrastHarness(result));
|
|
38
|
+
assertNoSameStepViolations(checkSameStepLint(result, 'nave'));
|
|
39
|
+
}
|
|
40
|
+
function contactSheetColumns(seeds, shipped, env) {
|
|
41
|
+
const columns = [
|
|
42
|
+
{ name: 'teal (shipped default)', seed: seeds.primary, result: shipped },
|
|
43
|
+
];
|
|
44
|
+
for (const { name, seed } of ADVERSARIAL_SEEDS) {
|
|
45
|
+
columns.push({ name, seed, result: runPipeline({ ...seeds, primary: seed }, {}, env) });
|
|
46
|
+
}
|
|
47
|
+
return columns;
|
|
48
|
+
}
|
|
49
|
+
const json = (value) => JSON.stringify(value, undefined, 2);
|
|
50
|
+
export function composeThemingOutputs(options = {}) {
|
|
51
|
+
const { distDir = 'dist', env = DEFAULT_ENV, seeds = SHIPPED_SEEDS, tokensSourcePaths = [TOKENS_JSON_PATH], } = options;
|
|
52
|
+
const orphanCheck = runSourceGuards(tokensSourcePaths);
|
|
53
|
+
const result = runPipeline(seeds, {}, env);
|
|
54
|
+
runResultGuards(result);
|
|
55
|
+
const { css } = emitCss(result);
|
|
56
|
+
assertNoticeIsEmitted(css, FEEDBACK_SHARED_IDENTITY_NOTICE, 'Feedback notice');
|
|
57
|
+
assertNoticeIsEmitted(css, RETHEMING_NOTICE, 'Retheming notice');
|
|
58
|
+
return {
|
|
59
|
+
css,
|
|
60
|
+
files: [
|
|
61
|
+
{
|
|
62
|
+
destination: path.join(distDir, 'palette-record.json'),
|
|
63
|
+
content: json(composePaletteRecord(result)),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
destination: path.join(distDir, 'core-contract.json'),
|
|
67
|
+
content: json(buildCoreContractManifest()),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
destination: path.join(distDir, 'build-record.json'),
|
|
71
|
+
content: json(composeBuildRecord(seeds, result, orphanCheck.open)),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
destination: path.join(distDir, 'contact-sheet.html'),
|
|
75
|
+
content: composeContactSheet(contactSheetColumns(seeds, result, env)).html,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function withThemingLayer(outputs, options = {}) {
|
|
81
|
+
const theming = composeThemingOutputs(options);
|
|
82
|
+
const cssName = `${path.sep}tokens.css`;
|
|
83
|
+
const withLayer = outputs.map((file) => file.destination.endsWith(cssName) || file.destination === 'tokens.css'
|
|
84
|
+
? { ...file, content: `${file.content}\n${theming.css}` }
|
|
85
|
+
: file);
|
|
86
|
+
if (withLayer.every((file) => !file.content.includes('/* Nave theming'))) {
|
|
87
|
+
throw new Error('No composed tokens.css to append the semantic colour layer to — the theming layer ' +
|
|
88
|
+
'would be dropped silently. Open an issue rather than working around this check.');
|
|
89
|
+
}
|
|
90
|
+
return [...withLayer, ...theming.files];
|
|
91
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface Oklch {
|
|
2
|
+
l: number;
|
|
3
|
+
c: number;
|
|
4
|
+
h: number;
|
|
5
|
+
}
|
|
6
|
+
export interface LinearRgb {
|
|
7
|
+
r: number;
|
|
8
|
+
g: number;
|
|
9
|
+
b: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Srgb {
|
|
12
|
+
r: number;
|
|
13
|
+
g: number;
|
|
14
|
+
b: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function srgbToLinearRgb(rgb: Srgb): LinearRgb;
|
|
17
|
+
export declare function oklchToSrgb(c: Oklch): Srgb;
|
|
18
|
+
export declare function oklabToOklch(lab: {
|
|
19
|
+
a: number;
|
|
20
|
+
b: number;
|
|
21
|
+
l: number;
|
|
22
|
+
}): Oklch;
|
|
23
|
+
export declare function srgbToOklch(rgb: Srgb): Oklch;
|
|
24
|
+
export declare function isOklchInGamut(c: Oklch): boolean;
|
|
25
|
+
export declare function maxChroma(l: number, h: number, precision?: number): number;
|
|
26
|
+
export type SeedNormalization = 'chroma-reduced' | 'mapped-to-black' | 'mapped-to-white' | 'none';
|
|
27
|
+
export declare function normalizeSeed(seed: Oklch): {
|
|
28
|
+
normalization: SeedNormalization;
|
|
29
|
+
normalized: boolean;
|
|
30
|
+
seed: Oklch;
|
|
31
|
+
};
|
|
32
|
+
export declare function formatOklch(c: Oklch): string;
|
|
33
|
+
export declare function contrastRatio(a: Oklch, b: Oklch): number;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
function linearSrgbToOklab(rgb) {
|
|
2
|
+
const l = 0.4122214708 * rgb.r + 0.5363325363 * rgb.g + 0.0514459929 * rgb.b;
|
|
3
|
+
const m = 0.2119034982 * rgb.r + 0.6806995451 * rgb.g + 0.1073969566 * rgb.b;
|
|
4
|
+
const s = 0.0883024619 * rgb.r + 0.2817188376 * rgb.g + 0.6299787005 * rgb.b;
|
|
5
|
+
const l_ = Math.cbrt(l);
|
|
6
|
+
const m_ = Math.cbrt(m);
|
|
7
|
+
const s_ = Math.cbrt(s);
|
|
8
|
+
return {
|
|
9
|
+
l: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
|
|
10
|
+
a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
|
|
11
|
+
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function oklabToLinearSrgb(lab) {
|
|
15
|
+
const l_ = lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b;
|
|
16
|
+
const m_ = lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b;
|
|
17
|
+
const s_ = lab.l - 0.0894841775 * lab.a - 1.291485548 * lab.b;
|
|
18
|
+
const l = l_ ** 3;
|
|
19
|
+
const m = m_ ** 3;
|
|
20
|
+
const s = s_ ** 3;
|
|
21
|
+
return {
|
|
22
|
+
r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
23
|
+
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
24
|
+
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function linearToGamma(c) {
|
|
28
|
+
const abs = Math.abs(c);
|
|
29
|
+
if (abs <= 0.0031308)
|
|
30
|
+
return 12.92 * c;
|
|
31
|
+
return Math.sign(c) * (1.055 * abs ** (1 / 2.4) - 0.055);
|
|
32
|
+
}
|
|
33
|
+
function gammaToLinear(c) {
|
|
34
|
+
const abs = Math.abs(c);
|
|
35
|
+
if (abs <= 0.04045)
|
|
36
|
+
return c / 12.92;
|
|
37
|
+
return Math.sign(c) * ((abs + 0.055) / 1.055) ** 2.4;
|
|
38
|
+
}
|
|
39
|
+
function linearRgbToSrgb(rgb) {
|
|
40
|
+
return { r: linearToGamma(rgb.r), g: linearToGamma(rgb.g), b: linearToGamma(rgb.b) };
|
|
41
|
+
}
|
|
42
|
+
export function srgbToLinearRgb(rgb) {
|
|
43
|
+
return { r: gammaToLinear(rgb.r), g: gammaToLinear(rgb.g), b: gammaToLinear(rgb.b) };
|
|
44
|
+
}
|
|
45
|
+
function oklchToOklab(c) {
|
|
46
|
+
const hRad = (c.h * Math.PI) / 180;
|
|
47
|
+
return { l: c.l, a: c.c * Math.cos(hRad), b: c.c * Math.sin(hRad) };
|
|
48
|
+
}
|
|
49
|
+
function oklchToLinearSrgb(c) {
|
|
50
|
+
return oklabToLinearSrgb(oklchToOklab(c));
|
|
51
|
+
}
|
|
52
|
+
export function oklchToSrgb(c) {
|
|
53
|
+
return linearRgbToSrgb(oklchToLinearSrgb(c));
|
|
54
|
+
}
|
|
55
|
+
export function oklabToOklch(lab) {
|
|
56
|
+
const c = Math.hypot(lab.a, lab.b);
|
|
57
|
+
const h = c === 0 ? 0 : ((((Math.atan2(lab.b, lab.a) * 180) / Math.PI) % 360) + 360) % 360;
|
|
58
|
+
return { l: lab.l, c, h };
|
|
59
|
+
}
|
|
60
|
+
export function srgbToOklch(rgb) {
|
|
61
|
+
const lab = linearSrgbToOklab(srgbToLinearRgb(rgb));
|
|
62
|
+
if (rgb.r === rgb.g && rgb.g === rgb.b)
|
|
63
|
+
return { c: 0, h: 0, l: lab.l };
|
|
64
|
+
return oklabToOklch(lab);
|
|
65
|
+
}
|
|
66
|
+
const GAMUT_EPSILON = 1e-4;
|
|
67
|
+
function isSrgbInGamut(rgb) {
|
|
68
|
+
return (rgb.r >= -GAMUT_EPSILON &&
|
|
69
|
+
rgb.r <= 1 + GAMUT_EPSILON &&
|
|
70
|
+
rgb.g >= -GAMUT_EPSILON &&
|
|
71
|
+
rgb.g <= 1 + GAMUT_EPSILON &&
|
|
72
|
+
rgb.b >= -GAMUT_EPSILON &&
|
|
73
|
+
rgb.b <= 1 + GAMUT_EPSILON);
|
|
74
|
+
}
|
|
75
|
+
export function isOklchInGamut(c) {
|
|
76
|
+
return isSrgbInGamut(oklchToSrgb(c));
|
|
77
|
+
}
|
|
78
|
+
export function maxChroma(l, h, precision = 1e-5) {
|
|
79
|
+
if (l <= 0 || l >= 1)
|
|
80
|
+
return 0;
|
|
81
|
+
let lo = 0;
|
|
82
|
+
let hi = 0.4;
|
|
83
|
+
while (isOklchInGamut({ l, c: hi, h }) && hi < 2)
|
|
84
|
+
hi *= 2;
|
|
85
|
+
while (hi - lo > precision) {
|
|
86
|
+
const mid = (lo + hi) / 2;
|
|
87
|
+
if (isOklchInGamut({ l, c: mid, h }))
|
|
88
|
+
lo = mid;
|
|
89
|
+
else
|
|
90
|
+
hi = mid;
|
|
91
|
+
}
|
|
92
|
+
return lo;
|
|
93
|
+
}
|
|
94
|
+
export function normalizeSeed(seed) {
|
|
95
|
+
if (isOklchInGamut(seed))
|
|
96
|
+
return { normalization: 'none', normalized: false, seed };
|
|
97
|
+
if (seed.l >= 1)
|
|
98
|
+
return { normalization: 'mapped-to-white', normalized: true, seed: { c: 0, h: 0, l: 1 } };
|
|
99
|
+
if (seed.l <= 0)
|
|
100
|
+
return { normalization: 'mapped-to-black', normalized: true, seed: { c: 0, h: 0, l: 0 } };
|
|
101
|
+
const cap = maxChroma(seed.l, seed.h);
|
|
102
|
+
return {
|
|
103
|
+
normalization: 'chroma-reduced',
|
|
104
|
+
normalized: true,
|
|
105
|
+
seed: { l: seed.l, c: Math.min(seed.c, cap), h: seed.h },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function formatOklch(c) {
|
|
109
|
+
return `oklch(${roundTo(c.l, 4)} ${roundTo(c.c, 4)} ${roundTo(c.h, 2)})`;
|
|
110
|
+
}
|
|
111
|
+
function roundTo(n, d) {
|
|
112
|
+
const f = 10 ** d;
|
|
113
|
+
return Math.round(n * f) / f;
|
|
114
|
+
}
|
|
115
|
+
function relativeLuminance(rgb) {
|
|
116
|
+
const lin = srgbToLinearRgb(rgb);
|
|
117
|
+
return 0.2126 * lin.r + 0.7152 * lin.g + 0.0722 * lin.b;
|
|
118
|
+
}
|
|
119
|
+
export function contrastRatio(a, b) {
|
|
120
|
+
const la = relativeLuminance(oklchToSrgb(a));
|
|
121
|
+
const lb = relativeLuminance(oklchToSrgb(b));
|
|
122
|
+
const lighter = Math.max(la, lb);
|
|
123
|
+
const darker = Math.min(la, lb);
|
|
124
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function linesThatBeginInsideAnUnclosedComment(lines: readonly string[]): ReadonlySet<number>;
|