@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.
Files changed (121) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +122 -0
  3. package/TOKENS.md +180 -0
  4. package/dist/breakpoints.d.ts +25 -0
  5. package/dist/breakpoints.js +44 -0
  6. package/dist/build-record.json +77 -0
  7. package/dist/core-contract.json +30 -0
  8. package/dist/lib/bin.d.ts +2 -0
  9. package/dist/lib/bin.js +113 -0
  10. package/dist/lib/builder.d.ts +21 -0
  11. package/dist/lib/builder.js +41 -0
  12. package/dist/lib/collision.d.ts +12 -0
  13. package/dist/lib/collision.js +46 -0
  14. package/dist/lib/composite-value.d.ts +3 -0
  15. package/dist/lib/composite-value.js +152 -0
  16. package/dist/lib/core-version.d.ts +12 -0
  17. package/dist/lib/core-version.js +40 -0
  18. package/dist/lib/dtcg-descriptions.d.ts +2 -0
  19. package/dist/lib/dtcg-descriptions.js +23 -0
  20. package/dist/lib/dtcg-malformed.d.ts +10 -0
  21. package/dist/lib/dtcg-malformed.js +56 -0
  22. package/dist/lib/dtcg-outputs.d.ts +2 -0
  23. package/dist/lib/dtcg-outputs.js +32 -0
  24. package/dist/lib/dtcg-shape.d.ts +13 -0
  25. package/dist/lib/dtcg-shape.js +113 -0
  26. package/dist/lib/errors.d.ts +23 -0
  27. package/dist/lib/errors.js +34 -0
  28. package/dist/lib/facade.d.ts +26 -0
  29. package/dist/lib/facade.js +97 -0
  30. package/dist/lib/formats.d.ts +6 -0
  31. package/dist/lib/formats.js +236 -0
  32. package/dist/lib/json-pointer.d.ts +2 -0
  33. package/dist/lib/json-pointer.js +28 -0
  34. package/dist/lib/overrides.d.ts +2 -0
  35. package/dist/lib/overrides.js +59 -0
  36. package/dist/lib/package-root.d.ts +2 -0
  37. package/dist/lib/package-root.js +31 -0
  38. package/dist/lib/reader.d.ts +8 -0
  39. package/dist/lib/reader.js +136 -0
  40. package/dist/lib/theming/adjacency-source.d.ts +20 -0
  41. package/dist/lib/theming/adjacency-source.js +103 -0
  42. package/dist/lib/theming/adjacency.d.ts +17 -0
  43. package/dist/lib/theming/adjacency.js +133 -0
  44. package/dist/lib/theming/build-record.d.ts +45 -0
  45. package/dist/lib/theming/build-record.js +87 -0
  46. package/dist/lib/theming/build-step.d.ts +14 -0
  47. package/dist/lib/theming/build-step.js +91 -0
  48. package/dist/lib/theming/color-math.d.ts +33 -0
  49. package/dist/lib/theming/color-math.js +125 -0
  50. package/dist/lib/theming/comment-open-tracker.d.ts +1 -0
  51. package/dist/lib/theming/comment-open-tracker.js +49 -0
  52. package/dist/lib/theming/consumer-build.d.ts +18 -0
  53. package/dist/lib/theming/consumer-build.js +29 -0
  54. package/dist/lib/theming/contact-sheet.d.ts +22 -0
  55. package/dist/lib/theming/contact-sheet.js +123 -0
  56. package/dist/lib/theming/contrast.d.ts +31 -0
  57. package/dist/lib/theming/contrast.js +133 -0
  58. package/dist/lib/theming/copy-lint.d.ts +13 -0
  59. package/dist/lib/theming/copy-lint.js +98 -0
  60. package/dist/lib/theming/core-contract.d.ts +29 -0
  61. package/dist/lib/theming/core-contract.js +71 -0
  62. package/dist/lib/theming/core-source.d.ts +2 -0
  63. package/dist/lib/theming/core-source.js +18 -0
  64. package/dist/lib/theming/css-color-4.d.ts +19 -0
  65. package/dist/lib/theming/css-color-4.js +66 -0
  66. package/dist/lib/theming/css-named-colours.d.ts +1 -0
  67. package/dist/lib/theming/css-named-colours.js +151 -0
  68. package/dist/lib/theming/descriptions.d.ts +1 -0
  69. package/dist/lib/theming/descriptions.js +21 -0
  70. package/dist/lib/theming/emit.d.ts +10 -0
  71. package/dist/lib/theming/emit.js +62 -0
  72. package/dist/lib/theming/feedback-signal.d.ts +11 -0
  73. package/dist/lib/theming/feedback-signal.js +23 -0
  74. package/dist/lib/theming/guard-message-probes.d.ts +6 -0
  75. package/dist/lib/theming/guard-message-probes.js +96 -0
  76. package/dist/lib/theming/ladder.d.ts +15 -0
  77. package/dist/lib/theming/ladder.js +81 -0
  78. package/dist/lib/theming/neutral.d.ts +12 -0
  79. package/dist/lib/theming/neutral.js +76 -0
  80. package/dist/lib/theming/on-star.d.ts +11 -0
  81. package/dist/lib/theming/on-star.js +87 -0
  82. package/dist/lib/theming/palette-record.d.ts +2 -0
  83. package/dist/lib/theming/palette-record.js +15 -0
  84. package/dist/lib/theming/pipeline.d.ts +43 -0
  85. package/dist/lib/theming/pipeline.js +104 -0
  86. package/dist/lib/theming/ramp.d.ts +34 -0
  87. package/dist/lib/theming/ramp.js +92 -0
  88. package/dist/lib/theming/seed-channel-split.d.ts +8 -0
  89. package/dist/lib/theming/seed-channel-split.js +99 -0
  90. package/dist/lib/theming/seed-form-parsers-css-color-4.d.ts +8 -0
  91. package/dist/lib/theming/seed-form-parsers-css-color-4.js +85 -0
  92. package/dist/lib/theming/seed-form-parsers.d.ts +6 -0
  93. package/dist/lib/theming/seed-form-parsers.js +155 -0
  94. package/dist/lib/theming/seed-ingest.d.ts +15 -0
  95. package/dist/lib/theming/seed-ingest.js +112 -0
  96. package/dist/lib/theming/seed-input.d.ts +22 -0
  97. package/dist/lib/theming/seed-input.js +1 -0
  98. package/dist/lib/theming/seed-refusal.d.ts +21 -0
  99. package/dist/lib/theming/seed-refusal.js +77 -0
  100. package/dist/lib/theming/semantics.d.ts +19 -0
  101. package/dist/lib/theming/semantics.js +71 -0
  102. package/dist/lib/theming/shipped-seeds.d.ts +2 -0
  103. package/dist/lib/theming/shipped-seeds.js +5 -0
  104. package/dist/lib/theming/step-table.d.ts +5 -0
  105. package/dist/lib/theming/step-table.js +26 -0
  106. package/dist/lib/theming/third-party-provenance.d.ts +9 -0
  107. package/dist/lib/theming/third-party-provenance.js +35 -0
  108. package/dist/lib/theming/tokens-source.d.ts +3 -0
  109. package/dist/lib/theming/tokens-source.js +61 -0
  110. package/dist/lib/token-name.d.ts +2 -0
  111. package/dist/lib/token-name.js +28 -0
  112. package/dist/lib/validate-report.d.ts +19 -0
  113. package/dist/lib/validate-report.js +93 -0
  114. package/dist/lib/validate.d.ts +16 -0
  115. package/dist/lib/validate.js +65 -0
  116. package/dist/palette-record.json +293 -0
  117. package/dist/tokens.css +433 -0
  118. package/dist/tokens.d.ts +90 -0
  119. package/dist/tokens.js +88 -0
  120. package/package.json +85 -0
  121. package/tokens.json +938 -0
@@ -0,0 +1,49 @@
1
+ export function linesThatBeginInsideAnUnclosedComment(lines) {
2
+ const beginningsInsideAnUnclosedComment = new Set();
3
+ let isInsideAnOpenComment = false;
4
+ for (const [index, line] of lines.entries()) {
5
+ if (isInsideAnOpenComment)
6
+ beginningsInsideAnUnclosedComment.add(index);
7
+ isInsideAnOpenComment = isLineStillInsideAnOpenCommentAfterScanning(line, isInsideAnOpenComment);
8
+ }
9
+ return beginningsInsideAnUnclosedComment;
10
+ }
11
+ function isLineStillInsideAnOpenCommentAfterScanning(line, wasInsideAnOpenComment) {
12
+ let isInsideAnOpenComment = wasInsideAnOpenComment;
13
+ let cursor = 0;
14
+ while (cursor < line.length) {
15
+ if (isInsideAnOpenComment) {
16
+ const closerIndex = line.indexOf('*/', cursor);
17
+ if (closerIndex === -1)
18
+ return true;
19
+ isInsideAnOpenComment = false;
20
+ cursor = closerIndex + 2;
21
+ continue;
22
+ }
23
+ const character = line[cursor];
24
+ if (character === '"' || character === "'") {
25
+ cursor = indexAfterAStringLiteral(line, cursor, character);
26
+ continue;
27
+ }
28
+ if (line.startsWith('/*', cursor)) {
29
+ isInsideAnOpenComment = true;
30
+ cursor += 2;
31
+ continue;
32
+ }
33
+ cursor += 1;
34
+ }
35
+ return isInsideAnOpenComment;
36
+ }
37
+ function indexAfterAStringLiteral(line, quoteIndex, quote) {
38
+ let cursor = quoteIndex + 1;
39
+ while (cursor < line.length) {
40
+ if (line[cursor] === '\\') {
41
+ cursor += 2;
42
+ continue;
43
+ }
44
+ if (line[cursor] === quote)
45
+ return cursor + 1;
46
+ cursor += 1;
47
+ }
48
+ return line.length;
49
+ }
@@ -0,0 +1,18 @@
1
+ import type { OutputFile } from '../builder.ts';
2
+ import type { PerStepOverrides, Seeds } from './pipeline.ts';
3
+ import type { SeedInputs } from './seed-input.ts';
4
+ import { type SeedRecord } from './ramp.ts';
5
+ export declare const CONSUMER_LAYER = "tokens.presets";
6
+ export interface ConsumerBuildOptions {
7
+ seeds: Seeds;
8
+ seedInputs?: SeedInputs;
9
+ overrides?: PerStepOverrides;
10
+ env?: ReadonlyMap<number, number>;
11
+ outDir?: string;
12
+ }
13
+ export interface ConsumerBuildComposition {
14
+ css: string;
15
+ files: OutputFile[];
16
+ primaryRecord: SeedRecord;
17
+ }
18
+ export declare function composeConsumerBuild(options: ConsumerBuildOptions): ConsumerBuildComposition;
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+ import { composeBuildRecord } from "./build-record.js";
3
+ import { checkSameStepLint } from "./contrast.js";
4
+ import { emitCss } from "./emit.js";
5
+ import { composePaletteRecord } from "./palette-record.js";
6
+ import { runPipeline } from "./pipeline.js";
7
+ import { DEFAULT_ENV } from "./ramp.js";
8
+ export const CONSUMER_LAYER = 'tokens.presets';
9
+ const json = (value) => JSON.stringify(value, undefined, 2);
10
+ export function composeConsumerBuild(options) {
11
+ const { seeds, seedInputs = {}, overrides = {}, env = DEFAULT_ENV, outDir = 'dist' } = options;
12
+ const result = runPipeline(seeds, overrides, env);
13
+ const emitted = emitCss(result, CONSUMER_LAYER);
14
+ checkSameStepLint(result, 'consumer');
15
+ return {
16
+ css: emitted.css,
17
+ files: [
18
+ {
19
+ destination: path.join(outDir, 'palette-record.json'),
20
+ content: json(composePaletteRecord(result)),
21
+ },
22
+ {
23
+ destination: path.join(outDir, 'build-record.json'),
24
+ content: json(composeBuildRecord(seeds, result, undefined, seedInputs)),
25
+ },
26
+ ],
27
+ primaryRecord: result.records.primary,
28
+ };
29
+ }
@@ -0,0 +1,22 @@
1
+ import type { Oklch } from './color-math.ts';
2
+ import type { PipelineResult } from './pipeline.ts';
3
+ type SheetScale = 'danger' | 'neutral' | 'primary';
4
+ export interface SheetColumn {
5
+ name: string;
6
+ seed: Oklch;
7
+ result: PipelineResult;
8
+ }
9
+ export interface ContactSheetCell {
10
+ seed: string;
11
+ scheme: 'dark' | 'light';
12
+ scale: SheetScale;
13
+ step: number;
14
+ }
15
+ export declare function expectedCells(columns: readonly SheetColumn[]): ContactSheetCell[];
16
+ export declare function assertContactSheetComplete(html: string, expected: readonly ContactSheetCell[]): void;
17
+ export interface ContactSheet {
18
+ html: string;
19
+ cells: readonly ContactSheetCell[];
20
+ }
21
+ export declare function composeContactSheet(columns: readonly SheetColumn[]): ContactSheet;
22
+ export {};
@@ -0,0 +1,123 @@
1
+ import { formatOklch } from "./color-math.js";
2
+ import { resolveNeutralStep } from "./neutral.js";
3
+ import { rampStep } from "./ramp.js";
4
+ import { STEP_TABLE } from "./step-table.js";
5
+ const SHEET_SCALES = ['primary', 'danger', 'neutral'];
6
+ const SCHEMES = ['light', 'dark'];
7
+ const SHEET_SURFACES = [
8
+ 'surface.base',
9
+ 'surface.raised',
10
+ 'surface.overlay',
11
+ 'surface.sunken',
12
+ 'surface.inverse',
13
+ ];
14
+ const cellId = (cell) => `${cell.seed}|${cell.scheme}|${cell.scale}|${cell.step}`;
15
+ export function expectedCells(columns) {
16
+ const cells = [];
17
+ const forScale = (seed, scheme, scale) => {
18
+ for (const { step } of STEP_TABLE)
19
+ cells.push({ seed, scheme, scale, step });
20
+ };
21
+ for (const column of columns) {
22
+ for (const scheme of SCHEMES) {
23
+ for (const scale of SHEET_SCALES)
24
+ forScale(column.name, scheme, scale);
25
+ }
26
+ }
27
+ return cells;
28
+ }
29
+ function stepColour(result, scale, step) {
30
+ if (scale === 'neutral') {
31
+ return resolveNeutralStep(step, result.records.tintHue, result.neutralLiterals);
32
+ }
33
+ return rampStep(scale === 'primary' ? result.primaryRamp : result.dangerRamp, step);
34
+ }
35
+ function slotColour(result, slot, scheme) {
36
+ const found = result.slots.find((s) => s.slot === slot && s.branch === scheme);
37
+ if (!found)
38
+ throw new Error(`The contact sheet cannot resolve ${slot} (${scheme}). Open an issue.`);
39
+ return found.literal;
40
+ }
41
+ const escapeHtml = (text) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
42
+ function renderSwatch(cell, colour) {
43
+ const css = formatOklch(colour);
44
+ return (`<i class="sw" data-cell="${escapeHtml(cellId(cell))}" style="background:${css}" ` +
45
+ `title="${cell.scale} ${cell.step} — ${css}"></i>`);
46
+ }
47
+ function renderSurfaceBlock(column, scheme, surface) {
48
+ const background = formatOklch(slotColour(column.result, surface, scheme));
49
+ const label = formatOklch(slotColour(column.result, surface === 'surface.inverse' ? 'content.inverse' : 'content.primary', scheme));
50
+ const rows = SHEET_SCALES.map((scale) => {
51
+ const swatches = STEP_TABLE.map(({ step }) => renderSwatch({ seed: column.name, scheme, scale, step }, stepColour(column.result, scale, step))).join('');
52
+ return `<div class="row"><span class="lbl">${scale}</span><span class="ramp">${swatches}</span></div>`;
53
+ }).join('');
54
+ return (`<div class="surface" style="background:${background};color:${label}">` +
55
+ `<span class="lbl surf">${surface}</span>${rows}</div>`);
56
+ }
57
+ function renderColumn(column, scheme) {
58
+ const blocks = SHEET_SURFACES.map((surface) => renderSurfaceBlock(column, scheme, surface)).join('');
59
+ const seedCss = formatOklch(column.seed);
60
+ return (`<div class="col"><h3>${escapeHtml(column.name)}</h3>` +
61
+ `<code>${escapeHtml(seedCss)}</code>${blocks}</div>`);
62
+ }
63
+ const SHEET_STYLE = `
64
+ :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
65
+ body { margin: 0; padding: 1.5rem; background: #f6f6f6; color: #111; }
66
+ h1 { font-size: 1.1rem; } h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; }
67
+ h3 { font-size: 0.8rem; margin: 0 0 0.15rem; } code { font-size: 0.65rem; opacity: 0.7; }
68
+ .note { max-width: 60rem; font-size: 0.75rem; line-height: 1.5; }
69
+ .cols { display: flex; gap: 0.75rem; align-items: flex-start; flex-wrap: nowrap; overflow-x: auto; }
70
+ .col { flex: 1 1 0; min-width: 16rem; }
71
+ .surface { padding: 0.4rem 0.5rem; border-radius: 4px; margin-top: 0.35rem; }
72
+ .row { display: flex; align-items: center; gap: 0.35rem; margin-top: 0.15rem; }
73
+ .lbl { font-size: 0.6rem; width: 4.5rem; flex: none; }
74
+ .lbl.surf { display: block; width: auto; opacity: 0.75; }
75
+ .ramp { display: flex; flex: 1 1 auto; }
76
+ .sw { display: block; flex: 1 1 0; height: 1.1rem; }
77
+ `;
78
+ function renderSheet(columns) {
79
+ const sections = SCHEMES.map((scheme) => {
80
+ const cols = columns.map((column) => renderColumn(column, scheme)).join('');
81
+ return `<h2>${scheme} scheme</h2><div class="cols">${cols}</div>`;
82
+ }).join('');
83
+ return `<!doctype html>
84
+ <html lang="en"><head><meta charset="utf-8">
85
+ <title>Nave contact sheet — generated, do not edit</title>
86
+ <style>${SHEET_STYLE}</style></head>
87
+ <body>
88
+ <h1>Nave contact sheet</h1>
89
+ <p class="note">Generated by the token build. Every step of every
90
+ generated scale, in both schemes, drawn on the five shipped surfaces those steps sit on, with one
91
+ column per seed in the build's adversarial seed set alongside the shipped default. This is the
92
+ approval artifact for ramp quality; the numeric envelope is not. Chroma goes to zero at L&nbsp;1.000
93
+ and L&nbsp;0.000 at every hue, so every chromatic column ends in a white and a black swatch — that
94
+ is the arithmetic, not an oversight.</p>
95
+ ${sections}
96
+ </body></html>
97
+ `;
98
+ }
99
+ export function assertContactSheetComplete(html, expected) {
100
+ const rendered = new Set();
101
+ for (const match of html.matchAll(/data-cell="([^"]+)"/g))
102
+ rendered.add(match[1]);
103
+ const missing = expected.filter((cell) => !rendered.has(cellId(cell)));
104
+ if (missing.length > 0) {
105
+ const shown = missing
106
+ .slice(0, 10)
107
+ .map((cell) => cellId(cell))
108
+ .join(', ');
109
+ throw new Error(`The contact sheet is missing ${missing.length} of ${expected.length} cells ` +
110
+ `(seed|scheme|scale|step): ${shown}${missing.length > 10 ? ', ...' : ''}. A step or a ` +
111
+ 'seed missing from the sheet fails the run: the sheet is the only ramp-quality ' +
112
+ 'approval this design has. Open an issue rather than working around this check.');
113
+ }
114
+ }
115
+ export function composeContactSheet(columns) {
116
+ if (columns.length === 0) {
117
+ throw new Error('The contact sheet carries no seed column. Open an issue.');
118
+ }
119
+ const html = renderSheet(columns);
120
+ const cells = expectedCells(columns);
121
+ assertContactSheetComplete(html, cells);
122
+ return { html, cells };
123
+ }
@@ -0,0 +1,31 @@
1
+ import type { PipelineResult } from './pipeline.ts';
2
+ import { type Adjacency } from './adjacency.ts';
3
+ export declare const TEXT_FLOOR = 4.5;
4
+ export declare const NON_TEXT_FLOOR = 3;
5
+ export declare const MARGIN = 1.01;
6
+ export interface FloorConstants {
7
+ textFloor: number;
8
+ nonTextFloor: number;
9
+ margin: number;
10
+ }
11
+ export declare function assertFloorProvenance(floors?: FloorConstants): void;
12
+ export interface ContrastResult {
13
+ pair: Adjacency;
14
+ scheme: 'light' | 'dark';
15
+ ratio: number;
16
+ floor: number;
17
+ threshold: number;
18
+ pass: boolean;
19
+ }
20
+ export declare function runContrastHarness(result: PipelineResult, adjacency?: readonly Adjacency[]): ContrastResult[];
21
+ export declare function assertContrastFloors(results: readonly ContrastResult[]): void;
22
+ export type SameStepSource = 'nave' | 'consumer';
23
+ type SameStepSeverity = 'fail' | 'report';
24
+ export interface SameStepViolation {
25
+ slot: string;
26
+ description: string;
27
+ severity: SameStepSeverity;
28
+ }
29
+ export declare function checkSameStepLint(result: PipelineResult, source?: SameStepSource, adjacency?: readonly Adjacency[]): SameStepViolation[];
30
+ export declare function assertNoSameStepViolations(sameStep: readonly SameStepViolation[]): void;
31
+ export {};
@@ -0,0 +1,133 @@
1
+ import { ADJACENCY } from "./adjacency.js";
2
+ import { contrastRatio } from "./color-math.js";
3
+ export const TEXT_FLOOR = 4.5;
4
+ export const NON_TEXT_FLOOR = 3;
5
+ export const MARGIN = 1.01;
6
+ const FLOOR_PROVENANCE = {
7
+ textFloor: 4.5,
8
+ nonTextFloor: 3,
9
+ margin: 1.01,
10
+ };
11
+ const LIVE_FLOORS = {
12
+ textFloor: TEXT_FLOOR,
13
+ nonTextFloor: NON_TEXT_FLOOR,
14
+ margin: MARGIN,
15
+ };
16
+ export function assertFloorProvenance(floors = LIVE_FLOORS) {
17
+ if (floors.textFloor !== FLOOR_PROVENANCE.textFloor ||
18
+ floors.nonTextFloor !== FLOOR_PROVENANCE.nonTextFloor ||
19
+ floors.margin !== FLOOR_PROVENANCE.margin) {
20
+ throw new Error(`Contrast floors: the floor constants in this file (text ${floors.textFloor}, ` +
21
+ `non-text ${floors.nonTextFloor}, margin ${floors.margin}) no longer match the ` +
22
+ `frozen record they are checked against. These three numbers are not this build's ` +
23
+ `to move: they are the project's contrast policy, and this check exists so a change ` +
24
+ `to them cannot land as a silent divergence. Revert the constant that moved. If you ` +
25
+ `believe the policy itself should change, open an issue and say so there; do not ` +
26
+ `update the frozen record to match the code.`);
27
+ }
28
+ }
29
+ function findSlot(slots, name, branch) {
30
+ const found = slots.find((s) => s.slot === name && s.branch === branch);
31
+ if (!found)
32
+ throw new Error(`Slot not found for contrast check: ${name} (${branch})`);
33
+ return found;
34
+ }
35
+ function harnessSlots(result) {
36
+ return [
37
+ ...result.slots,
38
+ result.actionSecondaryForeground.light,
39
+ result.actionSecondaryForeground.dark,
40
+ result.actionSecondaryBorder.light,
41
+ result.actionSecondaryBorder.dark,
42
+ ];
43
+ }
44
+ export function runContrastHarness(result, adjacency = ADJACENCY) {
45
+ if (adjacency.length === 0) {
46
+ throw new Error('Contrast check: the pair-declaration source is empty, so there are no colour pairs to ' +
47
+ 'measure. This fails the build rather than reporting a clean run, because an untested ' +
48
+ 'palette is not a passing one. Restore the declarations; if you believe an empty set ' +
49
+ 'is correct here, open an issue rather than removing this check.');
50
+ }
51
+ const allSlots = harnessSlots(result);
52
+ const out = [];
53
+ for (const scheme of ['light', 'dark']) {
54
+ for (const pair of adjacency) {
55
+ const subject = findSlot(allSlots, pair.subject, scheme);
56
+ const against = findSlot(allSlots, pair.against, scheme);
57
+ const ratio = contrastRatio(subject.literal, against.literal);
58
+ const floor = pair.class === 'text' ? TEXT_FLOOR : NON_TEXT_FLOOR;
59
+ const threshold = floor * MARGIN;
60
+ out.push({ pair, scheme, ratio, floor, threshold, pass: ratio >= threshold });
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ export function assertContrastFloors(results) {
66
+ const failing = results.filter((r) => !r.pass);
67
+ if (failing.length === 0)
68
+ return;
69
+ const lines = failing.map((r) => `${r.pair.subject} vs ${r.pair.against} (${r.scheme}): computed ${r.ratio}, below the ` +
70
+ `applied threshold ${roundTo(r.threshold, 4)} (the recorded floor ${r.floor} plus a 1 ` +
71
+ 'percent margin; a pair clearing only inside that margin counts as failing)');
72
+ throw new Error(`Contrast check: ${failing.length} colour pair(s) fall below the floor this build ` +
73
+ `applies:\n${lines.join('\n')}\nRe-point the failing slot. If you believe the floor ` +
74
+ 'itself is wrong, open an issue rather than lowering it here.');
75
+ }
76
+ function roundTo(n, d) {
77
+ const f = 10 ** d;
78
+ return Math.round(n * f) / f;
79
+ }
80
+ function groupSlotsByName(slots) {
81
+ const bySlot = new Map();
82
+ for (const slot of slots) {
83
+ const entry = bySlot.get(slot.slot) ?? {};
84
+ entry[slot.branch] = slot;
85
+ bySlot.set(slot.slot, entry);
86
+ }
87
+ return bySlot;
88
+ }
89
+ function findPinnedSlots(slots) {
90
+ const pinned = new Set();
91
+ for (const [name, { light, dark }] of groupSlotsByName(slots)) {
92
+ if (!light || !dark)
93
+ continue;
94
+ const isSamePin = light.resolved.scale === dark.resolved.scale && light.resolved.step === dark.resolved.step;
95
+ if (isSamePin)
96
+ pinned.add(name);
97
+ }
98
+ return pinned;
99
+ }
100
+ export function checkSameStepLint(result, source = 'nave', adjacency = ADJACENCY) {
101
+ if (adjacency.length === 0) {
102
+ throw new Error('Same-step lint: the pair-declaration source is empty. Those declarations are what ' +
103
+ 'mark a pair as text or non-text, which is the only thing this lint reads, so with ' +
104
+ 'nothing declared there is nothing to check. That is not the same as nothing being ' +
105
+ 'wrong, so this fails rather than reporting a clean run. Restore the declarations, ' +
106
+ 'or open an issue.');
107
+ }
108
+ const pinned = findPinnedSlots(harnessSlots(result));
109
+ const severity = source === 'nave' ? 'fail' : 'report';
110
+ const hits = new Map();
111
+ const record = (slot, description) => void hits.set(`${slot}:${description}`, { slot, description, severity });
112
+ for (const pair of adjacency) {
113
+ if (pair.class !== 'text')
114
+ continue;
115
+ if (pinned.has(pair.subject)) {
116
+ record(pair.subject, `${pair.subject} (subject of ${pair.subject}/${pair.against})`);
117
+ }
118
+ if (pinned.has(pair.against)) {
119
+ record(pair.against, `${pair.against} (background of ${pair.subject}/${pair.against})`);
120
+ }
121
+ }
122
+ return hits.values().toArray();
123
+ }
124
+ export function assertNoSameStepViolations(sameStep) {
125
+ const failing = sameStep.filter((violation) => violation.severity === 'fail');
126
+ if (failing.length > 0) {
127
+ const lines = failing.map((violation) => violation.description);
128
+ throw new Error(`${failing.length} slot(s) resolve to the same palette step in both the light and the ` +
129
+ `dark scheme while sitting on one side of a pair marked as text:\n${lines.join('\n')}` +
130
+ '\nRe-point the listed slot(s) so each pair keeps a light/dark step difference, or ' +
131
+ 'open an issue.');
132
+ }
133
+ }
@@ -0,0 +1,13 @@
1
+ export interface LintViolation {
2
+ text: string;
3
+ reason: string;
4
+ }
5
+ export declare function findConformanceFraming(text: string): string | undefined;
6
+ export declare function lintDescriptions(descriptions: ReadonlyMap<string, string>): LintViolation[];
7
+ export declare function assertDescriptionsAreClean(...descriptionMaps: readonly ReadonlyMap<string, string>[]): void;
8
+ export declare const RETHEMING_NOTICE = "Changing this changes everything Nave derives from it. The contrast of the resulting palette follows from what you set, and checking it is yours.";
9
+ export declare function assertNoticeIsClean(notice?: string, noticeLabel?: string): void;
10
+ export declare const FEEDBACK_SHARED_IDENTITY_NOTICE = "warning, success and info, and their -foreground variants, resolve to one shared value here; only danger differs. Colour alone therefore cannot tell those three states apart, so wherever a feedback token carries meaning, give it a text label naming the state and a distinct icon or shape that differs per state.";
11
+ export declare const NO_CONSUMER_CONTRAST_THRESHOLD_INPUT = "0.1.0 ships no consumer contrast-threshold input. No flag of this entry point sets one, and the build reads none. This is the absence of an input, not a limit on the consumer: a consumer may hold whatever contrast target they choose and check their palette against it with their own tooling. Where a check Nave ships runs in a consumer's build over the consumer's values, it reports and does not fail. A consumer-settable target is deliberately out of 0.1.0 rather than overlooked.";
12
+ export declare function assertNoticeIsEmitted(css: string, notice: string, noticeLabel: string): void;
13
+ export declare function assertHarnessFramingIsClean(): void;
@@ -0,0 +1,98 @@
1
+ import { linesThatBeginInsideAnUnclosedComment } from "./comment-open-tracker.js";
2
+ import { assertContrastFloors, runContrastHarness } from "./contrast.js";
3
+ import { ACCESSIBILITY_GUARD_MESSAGE_PROBES } from "./guard-message-probes.js";
4
+ const FORBIDDEN_WORDS = /\b(WCAG|AA|AAA|accessible|compliant|meets|conformant)\b/i;
5
+ const RATIO_PATTERN = /\d+(?:\.\d+)?\s*:\s*1/;
6
+ const SUCCESS_CRITERION_PATTERN = /\bSC\s*\d+\.\d+\.\d+\b/i;
7
+ export function findConformanceFraming(text) {
8
+ const wordMatch = FORBIDDEN_WORDS.exec(text);
9
+ if (wordMatch)
10
+ return `contains the word "${wordMatch[0]}"`;
11
+ const ratioMatch = RATIO_PATTERN.exec(text);
12
+ if (ratioMatch)
13
+ return `contains a contrast ratio ("${ratioMatch[0]}")`;
14
+ const scMatch = SUCCESS_CRITERION_PATTERN.exec(text);
15
+ if (scMatch)
16
+ return `contains a WCAG success-criterion identifier ("${scMatch[0]}")`;
17
+ return undefined;
18
+ }
19
+ export function lintDescriptions(descriptions) {
20
+ const violations = [];
21
+ for (const [token, text] of descriptions) {
22
+ const reason = findConformanceFraming(text);
23
+ if (reason)
24
+ violations.push({ text: token, reason });
25
+ }
26
+ return violations;
27
+ }
28
+ export function assertDescriptionsAreClean(...descriptionMaps) {
29
+ const violations = descriptionMaps.flatMap((descriptions) => lintDescriptions(descriptions));
30
+ if (violations.length === 0)
31
+ return;
32
+ const lines = violations.map((violation) => `${violation.text}: ${violation.reason}`);
33
+ throw new Error(`${violations.length} token description(s) state or imply an accessibility conformance ` +
34
+ `claim, which Nave's shipped copy does not make:\n${lines.join('\n')}\nDescribe what the ` +
35
+ 'token is for and leave the claim out. If you think the description needs it, open an ' +
36
+ 'issue rather than changing this check.');
37
+ }
38
+ export const RETHEMING_NOTICE = 'Changing this changes everything Nave derives from it. The contrast of the resulting palette follows from what you set, and checking it is yours.';
39
+ export function assertNoticeIsClean(notice = RETHEMING_NOTICE, noticeLabel = 'Re-theming notice') {
40
+ const reason = findConformanceFraming(notice);
41
+ if (reason)
42
+ throw new Error(`${noticeLabel}: ${reason}.`);
43
+ if (/\bwarrant(y|ies)\b/i.test(notice)) {
44
+ throw new Error(`${noticeLabel}: it uses warranty wording. This notice states a fact and the act it ` +
45
+ 'leaves to the reader; wording it as a disclaimer changes what it claims. Reword it, ' +
46
+ 'or open an issue if the notice itself should change.');
47
+ }
48
+ }
49
+ export const FEEDBACK_SHARED_IDENTITY_NOTICE = 'warning, success and info, and their -foreground variants, resolve to one shared value here; only danger differs. Colour alone therefore cannot tell those three states apart, so wherever a feedback token carries meaning, give it a text label naming the state and a distinct icon or shape that differs per state.';
50
+ export const NO_CONSUMER_CONTRAST_THRESHOLD_INPUT = "0.1.0 ships no consumer contrast-threshold input. No flag of this entry point sets one, and the build reads none. This is the absence of an input, not a limit on the consumer: a consumer may hold whatever contrast target they choose and check their palette against it with their own tooling. Where a check Nave ships runs in a consumer's build over the consumer's values, it reports and does not fail. A consumer-settable target is deliberately out of 0.1.0 rather than overlooked.";
51
+ export function assertNoticeIsEmitted(css, notice, noticeLabel) {
52
+ if (!css.includes(notice)) {
53
+ throw new Error(`${noticeLabel} violation: the notice is not present, verbatim, in the emitted CSS. Restore it exactly as authored.`);
54
+ }
55
+ const allLines = css.split('\n');
56
+ const carryingLines = allLines
57
+ .map((line, index) => ({ index, line }))
58
+ .filter((candidate) => candidate.line.includes(notice));
59
+ if (carryingLines.length === 0) {
60
+ throw new Error(`${noticeLabel} violation: the notice text spans more than one line, so no single line of the emitted CSS carries it whole. This check reads that CSS one line at a time and refuses a notice it cannot read in full rather than skipping it. The notice text is defined in packages/tokens/src/theming/copy-lint.ts. Put it back on a single line, or open an issue if it genuinely needs more than one.`);
61
+ }
62
+ const linesThatBeginOpen = linesThatBeginInsideAnUnclosedComment(allLines);
63
+ for (const { index, line } of carryingLines) {
64
+ const trimmed = line.trim();
65
+ const rest = trimmed.replace(notice, '');
66
+ const isSelfContainedComment = trimmed.startsWith('/*') && trimmed.endsWith('*/');
67
+ const notSelfContainedMessage = `${noticeLabel} violation: a line of emitted CSS carrying the notice is not a self-contained comment, or sits inside a comment opened on an earlier line, so a reader sees more around the notice than this check can read, and it refuses rather than checking part of what they see. With the notice text itself elided, that line reads "${rest}", and it is built in packages/tokens/src/theming/emit.ts. Put the notice and the rest of its comment on one line, or open an issue if that comment genuinely needs to wrap.`;
68
+ if (!isSelfContainedComment) {
69
+ throw new Error(notSelfContainedMessage);
70
+ }
71
+ const reason = findConformanceFraming(line);
72
+ if (reason) {
73
+ throw new Error(`${noticeLabel} violation: a comment line carrying the notice ${reason}. The notice text itself is present byte for byte, so what needs changing is the rest of that line, which reads "${rest}" with the notice text itself elided and is built in packages/tokens/src/theming/emit.ts. Take the phrase out there, or open an issue if that line should genuinely carry it.`);
74
+ }
75
+ if (linesThatBeginOpen.has(index)) {
76
+ throw new Error(notSelfContainedMessage);
77
+ }
78
+ }
79
+ }
80
+ export function assertHarnessFramingIsClean() {
81
+ for (const name of [runContrastHarness.name, assertContrastFloors.name]) {
82
+ const reason = findConformanceFraming(name);
83
+ if (reason)
84
+ throw new Error(`Copy-check violation: the harness function name "${name}" ${reason}. Take those words out of the name.`);
85
+ }
86
+ for (const probe of ACCESSIBILITY_GUARD_MESSAGE_PROBES) {
87
+ let message;
88
+ try {
89
+ message = probe.capture();
90
+ }
91
+ catch (error) {
92
+ throw new Error(`Copy-check probe "${probe.label}" no longer reproduces its own failing direction: ${error.message}`, { cause: error });
93
+ }
94
+ const reason = findConformanceFraming(message);
95
+ if (reason)
96
+ throw new Error(`Copy-check violation: ${probe.label} ${reason}. Take those words out of the message that produced it.`);
97
+ }
98
+ }
@@ -0,0 +1,29 @@
1
+ export declare function scanCoreContract(fileContents: readonly string[]): string[];
2
+ export declare function scanCoreContractFromDisk(paths: readonly string[]): string[];
3
+ export declare function discoverSourceFiles(dir: string, extensions?: readonly string[]): string[];
4
+ export declare const MANIFEST_FORMAT_VERSION = 1;
5
+ export interface ManifestProducer {
6
+ name: string;
7
+ version: string;
8
+ }
9
+ export interface CoreContractManifest {
10
+ formatVersion: number;
11
+ producer: ManifestProducer;
12
+ tokens: readonly string[];
13
+ }
14
+ export declare function buildManifest(tokens: readonly string[], producer: ManifestProducer): CoreContractManifest;
15
+ export interface ManifestVersionSkew {
16
+ recorded: string;
17
+ installed: string;
18
+ }
19
+ export declare function checkManifestVersionSkew(manifest: CoreContractManifest, installedCoreVersion: string): ManifestVersionSkew | undefined;
20
+ export declare function validateAgainstManifest(manifest: CoreContractManifest, emittedNames: ReadonlySet<string>): string[];
21
+ export declare class StaleContractPreconditionError extends Error {
22
+ constructor(reason: string);
23
+ }
24
+ export declare function assertContractPreconditions(names: readonly string[], rawSourceText: string): void;
25
+ export interface ContractDrift {
26
+ missing: string[];
27
+ added: string[];
28
+ }
29
+ export declare function diffContract(recorded: readonly string[], emitted: ReadonlySet<string>): ContractDrift;
@@ -0,0 +1,71 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const NAVE_VAR_PATTERN = /var\((--nave-[a-z0-9-]+)/g;
4
+ const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
5
+ const CONTRACT_AUDIT_PATTERN = /var\((--nave-[a-z0-9-]+)/g;
6
+ const CONTRACT_AUDIT_BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
7
+ export function scanCoreContract(fileContents) {
8
+ const found = new Set();
9
+ for (const content of fileContents) {
10
+ const stripped = content.replaceAll(BLOCK_COMMENT_RE, '');
11
+ for (const match of stripped.matchAll(NAVE_VAR_PATTERN)) {
12
+ found.add(match[1]);
13
+ }
14
+ }
15
+ return [...found].toSorted((a, b) => a.localeCompare(b));
16
+ }
17
+ export function scanCoreContractFromDisk(paths) {
18
+ return scanCoreContract(paths.map((p) => readFileSync(p, 'utf8')));
19
+ }
20
+ export function discoverSourceFiles(dir, extensions = ['.ts', '.css']) {
21
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
22
+ .filter((entry) => entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext)))
23
+ .map((entry) => path.join(entry.parentPath, entry.name))
24
+ .toSorted((a, b) => a.localeCompare(b));
25
+ }
26
+ export const MANIFEST_FORMAT_VERSION = 1;
27
+ export function buildManifest(tokens, producer) {
28
+ return {
29
+ formatVersion: MANIFEST_FORMAT_VERSION,
30
+ producer,
31
+ tokens: [...tokens].toSorted((a, b) => a.localeCompare(b)),
32
+ };
33
+ }
34
+ export function checkManifestVersionSkew(manifest, installedCoreVersion) {
35
+ if (manifest.producer.version === installedCoreVersion)
36
+ return undefined;
37
+ return { recorded: manifest.producer.version, installed: installedCoreVersion };
38
+ }
39
+ export function validateAgainstManifest(manifest, emittedNames) {
40
+ return manifest.tokens.filter((name) => !emittedNames.has(name));
41
+ }
42
+ export class StaleContractPreconditionError extends Error {
43
+ constructor(reason) {
44
+ super(`Manifest generation refused: ${reason}`);
45
+ this.name = 'StaleContractPreconditionError';
46
+ }
47
+ }
48
+ export function assertContractPreconditions(names, rawSourceText) {
49
+ const unprefixed = names.filter((name) => !name.startsWith('--nave-'));
50
+ if (unprefixed.length > 0) {
51
+ throw new StaleContractPreconditionError(`these scanned names are not yet prefixed --nave-: ${unprefixed.join(', ')}`);
52
+ }
53
+ const stripped = rawSourceText.replaceAll(CONTRACT_AUDIT_BLOCK_COMMENT_RE, '');
54
+ const referenced = new Set(stripped.matchAll(CONTRACT_AUDIT_PATTERN).map((match) => match[1]));
55
+ const scanned = new Set(names);
56
+ const unscanned = referenced.difference(scanned);
57
+ if (unscanned.size > 0) {
58
+ const sorted = [...unscanned].toSorted((a, b) => a.localeCompare(b)).join(', ');
59
+ throw new StaleContractPreconditionError(`referenced in source but missing from the scanned contract, narrower than core's real ` +
60
+ `run-time dependency: ${sorted}`);
61
+ }
62
+ }
63
+ export function diffContract(recorded, emitted) {
64
+ const recordedSet = new Set(recorded);
65
+ return {
66
+ missing: recorded.filter((name) => !emitted.has(name)),
67
+ added: [...emitted]
68
+ .filter((name) => !recordedSet.has(name))
69
+ .toSorted((a, b) => a.localeCompare(b)),
70
+ };
71
+ }
@@ -0,0 +1,2 @@
1
+ import type { CoreContractManifest } from './core-contract.ts';
2
+ export declare function buildCoreContractManifest(): CoreContractManifest;
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { assertContractPreconditions, buildManifest, discoverSourceFiles, scanCoreContractFromDisk, } from "./core-contract.js";
5
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
6
+ const CORE_SRC_DIR = path.resolve(HERE, '../../../core/src');
7
+ const CORE_PACKAGE_JSON_PATH = path.resolve(HERE, '../../../core/package.json');
8
+ export function buildCoreContractManifest() {
9
+ const corePackage = JSON.parse(readFileSync(CORE_PACKAGE_JSON_PATH, 'utf8'));
10
+ const files = discoverSourceFiles(CORE_SRC_DIR);
11
+ const rawSourceText = files.map((p) => readFileSync(p, 'utf8')).join('\n');
12
+ const tokens = scanCoreContractFromDisk(files);
13
+ assertContractPreconditions(tokens, rawSourceText);
14
+ return buildManifest(tokens, {
15
+ name: corePackage.name,
16
+ version: corePackage.version,
17
+ });
18
+ }