@craft-ts/style-testing 0.7.0-beta.15
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/package.json +37 -0
- package/src/index.d.ts +12 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +12 -0
- package/src/index.js.map +1 -0
- package/src/lib/drivers.d.ts +34 -0
- package/src/lib/drivers.d.ts.map +1 -0
- package/src/lib/drivers.js +137 -0
- package/src/lib/drivers.js.map +1 -0
- package/src/lib/exhaustive.d.ts +30 -0
- package/src/lib/exhaustive.d.ts.map +1 -0
- package/src/lib/exhaustive.js +38 -0
- package/src/lib/exhaustive.js.map +1 -0
- package/src/lib/matrix.d.ts +106 -0
- package/src/lib/matrix.d.ts.map +1 -0
- package/src/lib/matrix.js +175 -0
- package/src/lib/matrix.js.map +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@craft-ts/style-testing",
|
|
3
|
+
"version": "0.7.0-beta.15",
|
|
4
|
+
"description": "Visual scenario matrix and drivers for @craft-ts/style",
|
|
5
|
+
"author": "Romain Geffrault",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://craft-ts.github.io/craft/",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/craft-ts/craft-ts/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/craft-ts/craft-ts.git"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@craft-ts/style": "^0.7.0-beta.0",
|
|
17
|
+
"tslib": "^2.3.0"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20.19.0"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./src/index.js",
|
|
28
|
+
"types": "./src/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
"./package.json": "./package.json",
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./src/index.d.ts",
|
|
33
|
+
"default": "./src/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"module": "./src/index.js"
|
|
37
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@craft-ts/style-testing` — what a component can look like, enumerated.
|
|
3
|
+
*
|
|
4
|
+
* The matrix comes from the metadata the sheets already carry: an axis a class
|
|
5
|
+
* never crosses contributes nothing, and every point it does cross carries the
|
|
6
|
+
* driver that reaches it. Nothing here parses CSS or re-derives the design
|
|
7
|
+
* system; it reads the same registry the emitter reads.
|
|
8
|
+
*/
|
|
9
|
+
export * from './lib/matrix.ts';
|
|
10
|
+
export * from './lib/drivers.ts';
|
|
11
|
+
export * from './lib/exhaustive.ts';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../libs/style-testing/src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC"}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@craft-ts/style-testing` — what a component can look like, enumerated.
|
|
3
|
+
*
|
|
4
|
+
* The matrix comes from the metadata the sheets already carry: an axis a class
|
|
5
|
+
* never crosses contributes nothing, and every point it does cross carries the
|
|
6
|
+
* driver that reaches it. Nothing here parses CSS or re-derives the design
|
|
7
|
+
* system; it reads the same registry the emitter reads.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./lib/matrix.js";
|
|
10
|
+
export * from "./lib/drivers.js";
|
|
11
|
+
export * from "./lib/exhaustive.js";
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
package/src/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../libs/style-testing/src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC","sourcesContent":["/**\n * `@craft-ts/style-testing` — what a component can look like, enumerated.\n *\n * The matrix comes from the metadata the sheets already carry: an axis a class\n * never crosses contributes nothing, and every point it does cross carries the\n * driver that reaches it. Nothing here parses CSS or re-derives the design\n * system; it reads the same registry the emitter reads.\n */\nexport * from './lib/matrix.ts';\nexport * from './lib/drivers.ts';\nexport * from './lib/exhaustive.ts';\n"]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ScenarioDriver, VisualScenario } from './matrix.ts';
|
|
2
|
+
export interface ScenarioPage {
|
|
3
|
+
setViewportSize(size: {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}): Promise<unknown>;
|
|
7
|
+
emulateMedia(options: Readonly<Record<string, string>>): Promise<unknown>;
|
|
8
|
+
evaluate<Argument>(body: (argument: Argument) => unknown, argument: Argument): Promise<unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface ApplyOptions {
|
|
11
|
+
/** Selector of the element the scenario applies to. Defaults to the root. */
|
|
12
|
+
readonly target?: string;
|
|
13
|
+
/** Viewport height to keep while the width follows the breakpoints. */
|
|
14
|
+
readonly height?: number;
|
|
15
|
+
}
|
|
16
|
+
/** `40rem` → 640. The drivers speak CSS; a viewport is a number of pixels. */
|
|
17
|
+
export declare function toPixels(length: string): number;
|
|
18
|
+
/**
|
|
19
|
+
* The one place application order is decided.
|
|
20
|
+
*
|
|
21
|
+
* `visualMatrix` stores the drivers sorted by axis, which is stable and reads
|
|
22
|
+
* well; it is deliberately not the order they must be applied in. Anything that
|
|
23
|
+
* applies a scenario by hand goes through this, so there is a single answer to
|
|
24
|
+
* "in what order" rather than one per caller.
|
|
25
|
+
*/
|
|
26
|
+
export declare const orderedDrivers: (drivers: readonly ScenarioDriver[]) => readonly ScenarioDriver[];
|
|
27
|
+
/**
|
|
28
|
+
* Applies every driver of a scenario, in a deterministic order.
|
|
29
|
+
*
|
|
30
|
+
* A driver that cannot be honoured throws rather than being skipped: a skipped
|
|
31
|
+
* driver produces a capture that looks like the base state and passes forever.
|
|
32
|
+
*/
|
|
33
|
+
export declare function applyScenario(page: ScenarioPage, scenario: VisualScenario, options?: ApplyOptions): Promise<void>;
|
|
34
|
+
//# sourceMappingURL=drivers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drivers.d.ts","sourceRoot":"","sources":["../../../../../libs/style-testing/src/lib/drivers.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElE,MAAM,WAAW,YAAY;IAC3B,eAAe,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3E,YAAY,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1E,QAAQ,CAAC,QAAQ,EACf,IAAI,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,OAAO,EACrC,QAAQ,EAAE,QAAQ,GACjB,OAAO,CAAC,OAAO,CAAC,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAID,8EAA8E;AAC9E,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAS/C;AAWD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GACzB,SAAS,SAAS,cAAc,EAAE,KACjC,SAAS,cAAc,EAKvB,CAAC;AAEJ;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,YAAY,EAClB,QAAQ,EAAE,cAAc,EACxB,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,IAAI,CAAC,CAwCf"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const REM = 16;
|
|
2
|
+
/** `40rem` → 640. The drivers speak CSS; a viewport is a number of pixels. */
|
|
3
|
+
export function toPixels(length) {
|
|
4
|
+
const match = /^(-?[\d.]+)(px|rem|em)$/.exec(length.trim());
|
|
5
|
+
if (!match) {
|
|
6
|
+
throw new Error(`applyScenario: '${length}' is not a length this driver can turn into a viewport width. Breakpoints must be built from px, rem or em.`);
|
|
7
|
+
}
|
|
8
|
+
const amount = Number(match[1]);
|
|
9
|
+
return Math.ceil(match[2] === 'px' ? amount : amount * REM);
|
|
10
|
+
}
|
|
11
|
+
const RANK = {
|
|
12
|
+
emulateMedia: 0,
|
|
13
|
+
resize: 1,
|
|
14
|
+
resizeContainer: 2,
|
|
15
|
+
setAttribute: 3,
|
|
16
|
+
descendantState: 4,
|
|
17
|
+
scroll: 5,
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The one place application order is decided.
|
|
21
|
+
*
|
|
22
|
+
* `visualMatrix` stores the drivers sorted by axis, which is stable and reads
|
|
23
|
+
* well; it is deliberately not the order they must be applied in. Anything that
|
|
24
|
+
* applies a scenario by hand goes through this, so there is a single answer to
|
|
25
|
+
* "in what order" rather than one per caller.
|
|
26
|
+
*/
|
|
27
|
+
export const orderedDrivers = (drivers) => [...drivers].sort((left, right) => RANK[left.driver.kind] - RANK[right.driver.kind] ||
|
|
28
|
+
left.axis.localeCompare(right.axis));
|
|
29
|
+
/**
|
|
30
|
+
* Applies every driver of a scenario, in a deterministic order.
|
|
31
|
+
*
|
|
32
|
+
* A driver that cannot be honoured throws rather than being skipped: a skipped
|
|
33
|
+
* driver produces a capture that looks like the base state and passes forever.
|
|
34
|
+
*/
|
|
35
|
+
export async function applyScenario(page, scenario, options = {}) {
|
|
36
|
+
const target = options.target ?? ':root';
|
|
37
|
+
for (const entry of orderedDrivers(scenario.drivers)) {
|
|
38
|
+
const driver = entry.driver;
|
|
39
|
+
switch (driver.kind) {
|
|
40
|
+
case 'emulateMedia':
|
|
41
|
+
await page.emulateMedia({
|
|
42
|
+
[mediaOption(driver.feature)]: driver.value,
|
|
43
|
+
});
|
|
44
|
+
break;
|
|
45
|
+
case 'resize':
|
|
46
|
+
await page.setViewportSize({
|
|
47
|
+
width: toPixels(driver.minInlineSize),
|
|
48
|
+
height: options.height ?? 900,
|
|
49
|
+
});
|
|
50
|
+
break;
|
|
51
|
+
case 'resizeContainer':
|
|
52
|
+
await page.evaluate(applyContainerWidth, {
|
|
53
|
+
container: driver.container,
|
|
54
|
+
width: toPixels(driver.minInlineSize),
|
|
55
|
+
});
|
|
56
|
+
break;
|
|
57
|
+
case 'setAttribute':
|
|
58
|
+
await page.evaluate(applyAttribute, {
|
|
59
|
+
target,
|
|
60
|
+
name: driver.name,
|
|
61
|
+
value: driver.value,
|
|
62
|
+
});
|
|
63
|
+
break;
|
|
64
|
+
case 'descendantState':
|
|
65
|
+
await page.evaluate(applyDescendantState, {
|
|
66
|
+
target,
|
|
67
|
+
state: driver.state,
|
|
68
|
+
});
|
|
69
|
+
break;
|
|
70
|
+
case 'scroll':
|
|
71
|
+
await page.evaluate(applyScroll, { target, to: driver.to });
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const mediaOption = (feature) => ({
|
|
77
|
+
'prefers-color-scheme': 'colorScheme',
|
|
78
|
+
'prefers-reduced-motion': 'reducedMotion',
|
|
79
|
+
'forced-colors': 'forcedColors',
|
|
80
|
+
'prefers-contrast': 'contrast',
|
|
81
|
+
})[feature];
|
|
82
|
+
// The bodies below run in the page, so they take a single serialisable
|
|
83
|
+
// argument and reach for nothing from this module's scope.
|
|
84
|
+
function applyContainerWidth(input) {
|
|
85
|
+
const element = document.querySelector(`[data-container='${input.container}']`);
|
|
86
|
+
if (!element) {
|
|
87
|
+
throw new Error(`applyScenario: no element carries data-container='${input.container}'. A container axis needs the container it queries to exist on the page.`);
|
|
88
|
+
}
|
|
89
|
+
element.style.inlineSize = `${input.width}px`;
|
|
90
|
+
}
|
|
91
|
+
function applyAttribute(input) {
|
|
92
|
+
const element = document.querySelector(input.target);
|
|
93
|
+
if (!element) {
|
|
94
|
+
throw new Error(`applyScenario: no element matches '${input.target}'.`);
|
|
95
|
+
}
|
|
96
|
+
element.setAttribute(input.name, input.value);
|
|
97
|
+
}
|
|
98
|
+
function applyDescendantState(input) {
|
|
99
|
+
const root = document.querySelector(input.target);
|
|
100
|
+
if (!root) {
|
|
101
|
+
throw new Error(`applyScenario: no element matches '${input.target}'.`);
|
|
102
|
+
}
|
|
103
|
+
if (input.state === 'checked') {
|
|
104
|
+
const box = root.querySelector('input[type=checkbox], input[type=radio]');
|
|
105
|
+
if (!box)
|
|
106
|
+
throw new Error('applyScenario: no checkable descendant.');
|
|
107
|
+
box.checked = true;
|
|
108
|
+
box.dispatchEvent(new Event('change', { bubbles: true }));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (input.state === 'focus-visible') {
|
|
112
|
+
const focusable = root.querySelector('a[href], button, input, select, textarea, [tabindex]');
|
|
113
|
+
if (!focusable)
|
|
114
|
+
throw new Error('applyScenario: no focusable descendant.');
|
|
115
|
+
focusable.focus();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const field = root.querySelector('input, select, textarea');
|
|
119
|
+
if (!field)
|
|
120
|
+
throw new Error('applyScenario: no field descendant.');
|
|
121
|
+
// `:user-invalid` needs the user to have interacted, not just an invalid
|
|
122
|
+
// value — blurring after a change is the shortest honest way there.
|
|
123
|
+
field.value = field.getAttribute('data-invalid-value') ?? '@';
|
|
124
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
125
|
+
field.dispatchEvent(new Event('change', { bubbles: true }));
|
|
126
|
+
field.blur();
|
|
127
|
+
}
|
|
128
|
+
function applyScroll(input) {
|
|
129
|
+
const element = document.querySelector(input.target);
|
|
130
|
+
const scroller = element && element.scrollHeight > element.clientHeight
|
|
131
|
+
? element
|
|
132
|
+
: document.scrollingElement;
|
|
133
|
+
if (!scroller)
|
|
134
|
+
throw new Error('applyScenario: nothing to scroll.');
|
|
135
|
+
scroller.scrollTop = input.to === 'start' ? 0 : scroller.scrollHeight;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=drivers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drivers.js","sourceRoot":"","sources":["../../../../../libs/style-testing/src/lib/drivers.ts"],"names":[],"mappings":"AA+BA,MAAM,GAAG,GAAG,EAAE,CAAC;AAEf,8EAA8E;AAC9E,MAAM,UAAU,QAAQ,CAAC,MAAc;IACrC,MAAM,KAAK,GAAG,yBAAyB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IAC5D,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CACb,mBAAmB,MAAM,6GAA6G,CACvI,CAAC;IACJ,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAChC,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,IAAI,GAA+D;IACvE,YAAY,EAAE,CAAC;IACf,MAAM,EAAE,CAAC;IACT,eAAe,EAAE,CAAC;IAClB,YAAY,EAAE,CAAC;IACf,eAAe,EAAE,CAAC;IAClB,MAAM,EAAE,CAAC;CACV,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAC5B,OAAkC,EACP,EAAE,CAC7B,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,CACf,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CACd,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC;IAChD,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,CACtC,CAAC;AAEJ;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,IAAkB,EAClB,QAAwB,EACxB,UAAwB,EAAE;IAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC;IACzC,KAAK,MAAM,KAAK,IAAI,cAAc,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;QACrD,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;QAC5B,QAAQ,MAAM,CAAC,IAAI,EAAE,CAAC;YACpB,KAAK,cAAc;gBACjB,MAAM,IAAI,CAAC,YAAY,CAAC;oBACtB,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK;iBAC5C,CAAC,CAAC;gBACH,MAAM;YACR,KAAK,QAAQ;gBACX,MAAM,IAAI,CAAC,eAAe,CAAC;oBACzB,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,aAAa,CAAC;oBACrC,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,GAAG;iBAC9B,CAAC,CAAC;gBACH,MAAM;YACR,KAAK,iBAAiB;gBACpB,MAAM,IAAI,CAAC,QAAQ,CAAC,mBAAmB,EAAE;oBACvC,SAAS,EAAE,MAAM,CAAC,SAAS;oBAC3B,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,aAAa,CAAC;iBACtC,CAAC,CAAC;gBACH,MAAM;YACR,KAAK,cAAc;gBACjB,MAAM,IAAI,CAAC,QAAQ,CAAC,cAAc,EAAE;oBAClC,MAAM;oBACN,IAAI,EAAE,MAAM,CAAC,IAAI;oBACjB,KAAK,EAAE,MAAM,CAAC,KAAK;iBACpB,CAAC,CAAC;gBACH,MAAM;YACR,KAAK,iBAAiB;gBACpB,MAAM,IAAI,CAAC,QAAQ,CAAC,oBAAoB,EAAE;oBACxC,MAAM;oBACN,KAAK,EAAE,MAAM,CAAC,KAAK;iBACpB,CAAC,CAAC;gBACH,MAAM;YACR,KAAK,QAAQ;gBACX,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;gBAC5D,MAAM;QACV,CAAC;IACH,CAAC;AACH,CAAC;AAED,MAAM,WAAW,GAAG,CAClB,OAA6E,EACrE,EAAE,CACV,CAAC;IACC,sBAAsB,EAAE,aAAa;IACrC,wBAAwB,EAAE,eAAe;IACzC,eAAe,EAAE,cAAc;IAC/B,kBAAkB,EAAE,UAAU;CAC/B,CAAC,CAAC,OAAO,CAAC,CAAC;AAEd,uEAAuE;AACvE,2DAA2D;AAE3D,SAAS,mBAAmB,CAAC,KAG5B;IACC,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CACpC,oBAAoB,KAAK,CAAC,SAAS,IAAI,CACxC,CAAC;IACF,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACb,qDAAqD,KAAK,CAAC,SAAS,0EAA0E,CAC/I,CAAC;IACJ,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,UAAU,GAAG,GAAG,KAAK,CAAC,KAAK,IAAI,CAAC;AAChD,CAAC;AAED,SAAS,cAAc,CAAC,KAIvB;IACC,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACrD,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CAAC,sCAAsC,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC;IAC1E,CAAC;IACD,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;AAChD,CAAC;AAED,SAAS,oBAAoB,CAAC,KAG7B;IACC,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAClD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CAAC,sCAAsC,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC;IAC1E,CAAC;IACD,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;QAC9B,MAAM,GAAG,GAAG,IAAI,CAAC,aAAa,CAC5B,yCAAyC,CAC1C,CAAC;QACF,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,yCAAyC,CAAC,CAAC;QACrE,GAAG,CAAC,OAAO,GAAG,IAAI,CAAC;QACnB,GAAG,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,QAAQ,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;QAC1D,OAAO;IACT,CAAC;IACD,IAAI,KAAK,CAAC,KAAK,KAAK,eAAe,EAAE,CAAC;QACpC,MAAM,SAAS,GAAG,IAAI,CAAC,aAAa,CAClC,sDAAsD,CACvD,CAAC;QACF,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,yCAAyC,CAAC,CAAC;QAC3E,SAAS,CAAC,KAAK,EAAE,CAAC;QAClB,OAAO;IACT,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,CAAC,aAAa,CAAmB,yBAAyB,CAAC,CAAC;IAC9E,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;IACnE,yEAAyE;IACzE,oEAAoE;IACpE,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,YAAY,CAAC,oBAAoB,CAAC,IAAI,GAAG,CAAC;IAC9D,KAAK,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAC3D,KAAK,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,QAAQ,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAC5D,KAAK,CAAC,IAAI,EAAE,CAAC;AACf,CAAC;AAED,SAAS,WAAW,CAAC,KAGpB;IACC,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACrD,MAAM,QAAQ,GACZ,OAAO,IAAI,OAAO,CAAC,YAAY,GAAG,OAAO,CAAC,YAAY;QACpD,CAAC,CAAC,OAAO;QACT,CAAC,CAAC,QAAQ,CAAC,gBAAgB,CAAC;IAChC,IAAI,CAAC,QAAQ;QAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;IACpE,QAAQ,CAAC,SAAS,GAAG,KAAK,CAAC,EAAE,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,YAAY,CAAC;AACxE,CAAC","sourcesContent":["/**\n * Putting a page into a scenario.\n *\n * The page is described structurally rather than imported from Playwright: the\n * package stays usable from a plain browser harness, and a consumer that does\n * use Playwright passes its `Page` unchanged because the shape matches.\n *\n * Order matters and is fixed here. Emulation and viewport first, because they\n * relayout; then container width; then DOM state; then scrolling, which depends\n * on everything above it. Applying them in registration order instead would\n * make a capture depend on which axis someone declared first.\n */\nimport type { AnyAxisPoint } from '@craft-ts/style';\nimport type { ScenarioDriver, VisualScenario } from './matrix.ts';\n\nexport interface ScenarioPage {\n setViewportSize(size: { width: number; height: number }): Promise<unknown>;\n emulateMedia(options: Readonly<Record<string, string>>): Promise<unknown>;\n evaluate<Argument>(\n body: (argument: Argument) => unknown,\n argument: Argument,\n ): Promise<unknown>;\n}\n\nexport interface ApplyOptions {\n /** Selector of the element the scenario applies to. Defaults to the root. */\n readonly target?: string;\n /** Viewport height to keep while the width follows the breakpoints. */\n readonly height?: number;\n}\n\nconst REM = 16;\n\n/** `40rem` → 640. The drivers speak CSS; a viewport is a number of pixels. */\nexport function toPixels(length: string): number {\n const match = /^(-?[\\d.]+)(px|rem|em)$/.exec(length.trim());\n if (!match) {\n throw new Error(\n `applyScenario: '${length}' is not a length this driver can turn into a viewport width. Breakpoints must be built from px, rem or em.`,\n );\n }\n const amount = Number(match[1]);\n return Math.ceil(match[2] === 'px' ? amount : amount * REM);\n}\n\nconst RANK: Readonly<Record<ScenarioDriver['driver']['kind'], number>> = {\n emulateMedia: 0,\n resize: 1,\n resizeContainer: 2,\n setAttribute: 3,\n descendantState: 4,\n scroll: 5,\n};\n\n/**\n * The one place application order is decided.\n *\n * `visualMatrix` stores the drivers sorted by axis, which is stable and reads\n * well; it is deliberately not the order they must be applied in. Anything that\n * applies a scenario by hand goes through this, so there is a single answer to\n * \"in what order\" rather than one per caller.\n */\nexport const orderedDrivers = (\n drivers: readonly ScenarioDriver[],\n): readonly ScenarioDriver[] =>\n [...drivers].sort(\n (left, right) =>\n RANK[left.driver.kind] - RANK[right.driver.kind] ||\n left.axis.localeCompare(right.axis),\n );\n\n/**\n * Applies every driver of a scenario, in a deterministic order.\n *\n * A driver that cannot be honoured throws rather than being skipped: a skipped\n * driver produces a capture that looks like the base state and passes forever.\n */\nexport async function applyScenario(\n page: ScenarioPage,\n scenario: VisualScenario,\n options: ApplyOptions = {},\n): Promise<void> {\n const target = options.target ?? ':root';\n for (const entry of orderedDrivers(scenario.drivers)) {\n const driver = entry.driver;\n switch (driver.kind) {\n case 'emulateMedia':\n await page.emulateMedia({\n [mediaOption(driver.feature)]: driver.value,\n });\n break;\n case 'resize':\n await page.setViewportSize({\n width: toPixels(driver.minInlineSize),\n height: options.height ?? 900,\n });\n break;\n case 'resizeContainer':\n await page.evaluate(applyContainerWidth, {\n container: driver.container,\n width: toPixels(driver.minInlineSize),\n });\n break;\n case 'setAttribute':\n await page.evaluate(applyAttribute, {\n target,\n name: driver.name,\n value: driver.value,\n });\n break;\n case 'descendantState':\n await page.evaluate(applyDescendantState, {\n target,\n state: driver.state,\n });\n break;\n case 'scroll':\n await page.evaluate(applyScroll, { target, to: driver.to });\n break;\n }\n }\n}\n\nconst mediaOption = (\n feature: Extract<AnyAxisPoint['driver'], { kind: 'emulateMedia' }>['feature'],\n): string =>\n ({\n 'prefers-color-scheme': 'colorScheme',\n 'prefers-reduced-motion': 'reducedMotion',\n 'forced-colors': 'forcedColors',\n 'prefers-contrast': 'contrast',\n })[feature];\n\n// The bodies below run in the page, so they take a single serialisable\n// argument and reach for nothing from this module's scope.\n\nfunction applyContainerWidth(input: {\n container: string;\n width: number;\n}): void {\n const element = document.querySelector<HTMLElement>(\n `[data-container='${input.container}']`,\n );\n if (!element) {\n throw new Error(\n `applyScenario: no element carries data-container='${input.container}'. A container axis needs the container it queries to exist on the page.`,\n );\n }\n element.style.inlineSize = `${input.width}px`;\n}\n\nfunction applyAttribute(input: {\n target: string;\n name: string;\n value: string;\n}): void {\n const element = document.querySelector(input.target);\n if (!element) {\n throw new Error(`applyScenario: no element matches '${input.target}'.`);\n }\n element.setAttribute(input.name, input.value);\n}\n\nfunction applyDescendantState(input: {\n target: string;\n state: 'user-invalid' | 'focus-visible' | 'checked';\n}): void {\n const root = document.querySelector(input.target);\n if (!root) {\n throw new Error(`applyScenario: no element matches '${input.target}'.`);\n }\n if (input.state === 'checked') {\n const box = root.querySelector<HTMLInputElement>(\n 'input[type=checkbox], input[type=radio]',\n );\n if (!box) throw new Error('applyScenario: no checkable descendant.');\n box.checked = true;\n box.dispatchEvent(new Event('change', { bubbles: true }));\n return;\n }\n if (input.state === 'focus-visible') {\n const focusable = root.querySelector<HTMLElement>(\n 'a[href], button, input, select, textarea, [tabindex]',\n );\n if (!focusable) throw new Error('applyScenario: no focusable descendant.');\n focusable.focus();\n return;\n }\n const field = root.querySelector<HTMLInputElement>('input, select, textarea');\n if (!field) throw new Error('applyScenario: no field descendant.');\n // `:user-invalid` needs the user to have interacted, not just an invalid\n // value — blurring after a change is the shortest honest way there.\n field.value = field.getAttribute('data-invalid-value') ?? '@';\n field.dispatchEvent(new Event('input', { bubbles: true }));\n field.dispatchEvent(new Event('change', { bubbles: true }));\n field.blur();\n}\n\nfunction applyScroll(input: {\n target: string;\n to: 'start' | 'end' | 'snap';\n}): void {\n const element = document.querySelector(input.target);\n const scroller =\n element && element.scrollHeight > element.clientHeight\n ? element\n : document.scrollingElement;\n if (!scroller) throw new Error('applyScenario: nothing to scroll.');\n scroller.scrollTop = input.to === 'start' ? 0 : scroller.scrollHeight;\n}\n"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assertion that a matrix is actually covered.
|
|
3
|
+
*
|
|
4
|
+
* **Post-inference, always.** A self-referential constraint on the component's
|
|
5
|
+
* own declaration resolves the union to `never` and the check silently passes —
|
|
6
|
+
* the same shape as `assertExhaustiveRouteExceptions`, and the same reason.
|
|
7
|
+
* The matrix is computed first, the baselines are read second, and the two are
|
|
8
|
+
* compared as values.
|
|
9
|
+
*/
|
|
10
|
+
import type { VisualScenario } from './matrix.ts';
|
|
11
|
+
export interface CoverageReport {
|
|
12
|
+
/** Scenarios the matrix produced and no baseline covers. */
|
|
13
|
+
readonly missing: readonly string[];
|
|
14
|
+
/** Baselines that no scenario produces any more. */
|
|
15
|
+
readonly orphaned: readonly string[];
|
|
16
|
+
readonly total: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const BASELINE_SUFFIX = ".png";
|
|
19
|
+
/** Turns a directory listing into the baseline names a matrix is compared to. */
|
|
20
|
+
export declare const baselinesIn: (files: readonly string[]) => readonly string[];
|
|
21
|
+
export declare function coverageOf(scenarios: readonly VisualScenario[], baselines: readonly string[]): CoverageReport;
|
|
22
|
+
/**
|
|
23
|
+
* Fails when the matrix and the baselines disagree, in either direction.
|
|
24
|
+
*
|
|
25
|
+
* An orphaned baseline matters as much as a missing one: it is a state the
|
|
26
|
+
* component used to have, and a reviewer looking at the folder would still
|
|
27
|
+
* count it as covered.
|
|
28
|
+
*/
|
|
29
|
+
export declare function assertExhaustiveVisualMatrix(scenarios: readonly VisualScenario[], baselines: readonly string[]): void;
|
|
30
|
+
//# sourceMappingURL=exhaustive.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exhaustive.d.ts","sourceRoot":"","sources":["../../../../../libs/style-testing/src/lib/exhaustive.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,MAAM,WAAW,cAAc;IAC7B,4DAA4D;IAC5D,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,oDAAoD;IACpD,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,eAAe,SAAS,CAAC;AAEtC,iFAAiF;AACjF,eAAO,MAAM,WAAW,GAAI,OAAO,SAAS,MAAM,EAAE,KAAG,SAAS,MAAM,EAI3D,CAAC;AAEZ,wBAAgB,UAAU,CACxB,SAAS,EAAE,SAAS,cAAc,EAAE,EACpC,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,cAAc,CAQhB;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,SAAS,cAAc,EAAE,EACpC,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,IAAI,CAoBN"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const BASELINE_SUFFIX = '.png';
|
|
2
|
+
/** Turns a directory listing into the baseline names a matrix is compared to. */
|
|
3
|
+
export const baselinesIn = (files) => files
|
|
4
|
+
.filter((file) => file.endsWith(BASELINE_SUFFIX))
|
|
5
|
+
.map((file) => file.slice(0, -BASELINE_SUFFIX.length))
|
|
6
|
+
.sort();
|
|
7
|
+
export function coverageOf(scenarios, baselines) {
|
|
8
|
+
const produced = new Set(scenarios.map((scenario) => scenario.id));
|
|
9
|
+
const covered = new Set(baselines);
|
|
10
|
+
return {
|
|
11
|
+
missing: [...produced].filter((id) => !covered.has(id)).sort(),
|
|
12
|
+
orphaned: [...covered].filter((id) => !produced.has(id)).sort(),
|
|
13
|
+
total: produced.size,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Fails when the matrix and the baselines disagree, in either direction.
|
|
18
|
+
*
|
|
19
|
+
* An orphaned baseline matters as much as a missing one: it is a state the
|
|
20
|
+
* component used to have, and a reviewer looking at the folder would still
|
|
21
|
+
* count it as covered.
|
|
22
|
+
*/
|
|
23
|
+
export function assertExhaustiveVisualMatrix(scenarios, baselines) {
|
|
24
|
+
const report = coverageOf(scenarios, baselines);
|
|
25
|
+
if (report.missing.length === 0 && report.orphaned.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
const lines = [
|
|
28
|
+
`assertExhaustiveVisualMatrix: the matrix has ${report.total} scenarios and the baselines do not match it.`,
|
|
29
|
+
];
|
|
30
|
+
if (report.missing.length) {
|
|
31
|
+
lines.push(` Never captured (${report.missing.length}): ${report.missing.join(', ')}.`, ' Each one is a way this component can look that nobody has ever looked at.');
|
|
32
|
+
}
|
|
33
|
+
if (report.orphaned.length) {
|
|
34
|
+
lines.push(` No longer produced (${report.orphaned.length}): ${report.orphaned.join(', ')}.`, ' A baseline nothing produces still reads as coverage; delete it or restore the state.');
|
|
35
|
+
}
|
|
36
|
+
throw new Error(lines.join('\n'));
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=exhaustive.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exhaustive.js","sourceRoot":"","sources":["../../../../../libs/style-testing/src/lib/exhaustive.ts"],"names":[],"mappings":"AAmBA,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC;AAEtC,iFAAiF;AACjF,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,KAAwB,EAAqB,EAAE,CACzE,KAAK;KACF,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;KAChD,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;KACrD,IAAI,EAAE,CAAC;AAEZ,MAAM,UAAU,UAAU,CACxB,SAAoC,EACpC,SAA4B;IAE5B,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC;IACnE,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC;IACnC,OAAO;QACL,OAAO,EAAE,CAAC,GAAG,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE;QAC9D,QAAQ,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE;QAC/D,KAAK,EAAE,QAAQ,CAAC,IAAI;KACrB,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,4BAA4B,CAC1C,SAAoC,EACpC,SAA4B;IAE5B,MAAM,MAAM,GAAG,UAAU,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;IAChD,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAExE,MAAM,KAAK,GAAG;QACZ,gDAAgD,MAAM,CAAC,KAAK,+CAA+C;KAC5G,CAAC;IACF,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;QAC1B,KAAK,CAAC,IAAI,CACR,qBAAqB,MAAM,CAAC,OAAO,CAAC,MAAM,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAC5E,6EAA6E,CAC9E,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;QAC3B,KAAK,CAAC,IAAI,CACR,yBAAyB,MAAM,CAAC,QAAQ,CAAC,MAAM,MAAM,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAClF,wFAAwF,CACzF,CAAC;IACJ,CAAC;IACD,MAAM,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;AACpC,CAAC","sourcesContent":["/**\n * The assertion that a matrix is actually covered.\n *\n * **Post-inference, always.** A self-referential constraint on the component's\n * own declaration resolves the union to `never` and the check silently passes —\n * the same shape as `assertExhaustiveRouteExceptions`, and the same reason.\n * The matrix is computed first, the baselines are read second, and the two are\n * compared as values.\n */\nimport type { VisualScenario } from './matrix.ts';\n\nexport interface CoverageReport {\n /** Scenarios the matrix produced and no baseline covers. */\n readonly missing: readonly string[];\n /** Baselines that no scenario produces any more. */\n readonly orphaned: readonly string[];\n readonly total: number;\n}\n\nexport const BASELINE_SUFFIX = '.png';\n\n/** Turns a directory listing into the baseline names a matrix is compared to. */\nexport const baselinesIn = (files: readonly string[]): readonly string[] =>\n files\n .filter((file) => file.endsWith(BASELINE_SUFFIX))\n .map((file) => file.slice(0, -BASELINE_SUFFIX.length))\n .sort();\n\nexport function coverageOf(\n scenarios: readonly VisualScenario[],\n baselines: readonly string[],\n): CoverageReport {\n const produced = new Set(scenarios.map((scenario) => scenario.id));\n const covered = new Set(baselines);\n return {\n missing: [...produced].filter((id) => !covered.has(id)).sort(),\n orphaned: [...covered].filter((id) => !produced.has(id)).sort(),\n total: produced.size,\n };\n}\n\n/**\n * Fails when the matrix and the baselines disagree, in either direction.\n *\n * An orphaned baseline matters as much as a missing one: it is a state the\n * component used to have, and a reviewer looking at the folder would still\n * count it as covered.\n */\nexport function assertExhaustiveVisualMatrix(\n scenarios: readonly VisualScenario[],\n baselines: readonly string[],\n): void {\n const report = coverageOf(scenarios, baselines);\n if (report.missing.length === 0 && report.orphaned.length === 0) return;\n\n const lines = [\n `assertExhaustiveVisualMatrix: the matrix has ${report.total} scenarios and the baselines do not match it.`,\n ];\n if (report.missing.length) {\n lines.push(\n ` Never captured (${report.missing.length}): ${report.missing.join(', ')}.`,\n ' Each one is a way this component can look that nobody has ever looked at.',\n );\n }\n if (report.orphaned.length) {\n lines.push(\n ` No longer produced (${report.orphaned.length}): ${report.orphaned.join(', ')}.`,\n ' A baseline nothing produces still reads as coverage; delete it or restore the state.',\n );\n }\n throw new Error(lines.join('\\n'));\n}\n"]}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The visual matrix: every way a component can look, enumerated.
|
|
3
|
+
*
|
|
4
|
+
* It is computed **at runtime**, from the metadata the style values already
|
|
5
|
+
* carry. Types only check the cardinal and the identifiers (see
|
|
6
|
+
* `exhaustive.ts`) — materialising the cartesian product in the type system is
|
|
7
|
+
* how you meet TS2589.
|
|
8
|
+
*
|
|
9
|
+
* The product is **complete**. The one reduction already banked happens
|
|
10
|
+
* upstream, in the sheet: only the points a class actually crosses are in its
|
|
11
|
+
* contract, so an axis with six breakpoints that a component cuts at one
|
|
12
|
+
* contributes two cells, not seven. No further reduction is applied here — a
|
|
13
|
+
* coverage that claims to be complete without being complete is worse than no
|
|
14
|
+
* coverage at all.
|
|
15
|
+
*/
|
|
16
|
+
import { type AnyAxisPoint } from '@craft-ts/style';
|
|
17
|
+
export interface ScenarioDriver {
|
|
18
|
+
readonly axis: string;
|
|
19
|
+
readonly point: string;
|
|
20
|
+
readonly driver: AnyAxisPoint['driver'];
|
|
21
|
+
}
|
|
22
|
+
export interface VisualScenario {
|
|
23
|
+
/**
|
|
24
|
+
* `scheme=dark+viewport=md` — sorted, and stable under the addition of an
|
|
25
|
+
* unrelated axis.
|
|
26
|
+
*
|
|
27
|
+
* Only the axes away from `base` appear. An identifier that listed every axis
|
|
28
|
+
* would change the moment someone adds one somewhere else in the app, and
|
|
29
|
+
* every baseline in the suite would be invalidated by a change that affects
|
|
30
|
+
* none of them.
|
|
31
|
+
*/
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly axes: Readonly<Record<string, string>>;
|
|
34
|
+
readonly drivers: readonly ScenarioDriver[];
|
|
35
|
+
/** Set when a content case was crossed in — see `contentCases`. */
|
|
36
|
+
readonly content?: string;
|
|
37
|
+
}
|
|
38
|
+
/** What `craftStyles` returns: a record of branded class strings. */
|
|
39
|
+
export type StyleSheetClasses = Readonly<Record<string, string>>;
|
|
40
|
+
export interface MatrixOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Container axes resolved by the component itself.
|
|
43
|
+
*
|
|
44
|
+
* A container axis answers "how wide is my box", which nobody above the
|
|
45
|
+
* container can change. Naming the container here closes the axis at this
|
|
46
|
+
* component instead of handing it to every ancestor.
|
|
47
|
+
*/
|
|
48
|
+
readonly resolves?: readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* Axis points the component's own structure makes unreachable — a scroll
|
|
51
|
+
* state under a `scrollPort.none`, for instance. Pruned rather than captured.
|
|
52
|
+
*/
|
|
53
|
+
readonly unreachable?: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
declare const BRANCH: unique symbol;
|
|
56
|
+
/**
|
|
57
|
+
* Two sets of sheets that are never on screen at the same time.
|
|
58
|
+
*
|
|
59
|
+
* An `ifNode` renders one side or the other, so the states of the two branches
|
|
60
|
+
* **add up** — they do not multiply. Told nothing, the matrix would cross a
|
|
61
|
+
* footer's three tones with a header's two breakpoints and ask for six captures
|
|
62
|
+
* of pages that cannot exist.
|
|
63
|
+
*
|
|
64
|
+
* This is the single highest-leverage reduction in the whole system, and the
|
|
65
|
+
* only one that is exactly true rather than probably true: the branches are
|
|
66
|
+
* mutually exclusive by construction, not by analysis.
|
|
67
|
+
*
|
|
68
|
+
* The plan infers it from the `ifNode` node's own type. It is declared here,
|
|
69
|
+
* for the same reason the matrix takes sheets rather than components — and the
|
|
70
|
+
* cost of that choice is stated plainly: a branch nobody declares is counted as
|
|
71
|
+
* co-present, which over-captures rather than under-captures.
|
|
72
|
+
*/
|
|
73
|
+
export interface Branch {
|
|
74
|
+
readonly [BRANCH]: true;
|
|
75
|
+
readonly name: string;
|
|
76
|
+
readonly whenTrue: MatrixInput;
|
|
77
|
+
readonly whenFalse: MatrixInput;
|
|
78
|
+
}
|
|
79
|
+
export declare const branch: (name: string, whenTrue: MatrixInput, whenFalse?: MatrixInput) => Branch;
|
|
80
|
+
export type MatrixInput = string | StyleSheetClasses | Branch | readonly MatrixInput[];
|
|
81
|
+
/**
|
|
82
|
+
* Every visual state of the given sheets.
|
|
83
|
+
*
|
|
84
|
+
* Takes what a component actually uses: one sheet, several, or the class
|
|
85
|
+
* strings themselves. There is deliberately no `visualMatrix(Component)` — a
|
|
86
|
+
* component's classes are only knowable by rendering it, and a matrix that
|
|
87
|
+
* silently missed a child's sheet would be the worst possible outcome. Naming
|
|
88
|
+
* the sheets is explicit and checkable.
|
|
89
|
+
*/
|
|
90
|
+
export declare function visualMatrix(input: MatrixInput, options?: MatrixOptions): readonly VisualScenario[];
|
|
91
|
+
export declare function identify(axes: Readonly<Record<string, string>>, content?: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* Data cases, crossed into the matrix.
|
|
94
|
+
*
|
|
95
|
+
* The matrix covers *conditions*, not *data* — and the eighty-character title,
|
|
96
|
+
* the empty list and the seven-figure price are what break layouts most often.
|
|
97
|
+
* No type can derive them, so they are declared.
|
|
98
|
+
*
|
|
99
|
+
* The crossing rule: a content case is rendered at **one** point of each axis,
|
|
100
|
+
* except on the axes that change the space available — viewport and container —
|
|
101
|
+
* where the crossing is complete. A long title behaves differently at two
|
|
102
|
+
* widths; it does not behave differently in two colour schemes.
|
|
103
|
+
*/
|
|
104
|
+
export declare function contentCases(scenarios: readonly VisualScenario[], cases: Readonly<Record<string, unknown>>): readonly VisualScenario[];
|
|
105
|
+
export {};
|
|
106
|
+
//# sourceMappingURL=matrix.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"matrix.d.ts","sourceRoot":"","sources":["../../../../../libs/style-testing/src/lib/matrix.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAGL,KAAK,YAAY,EAElB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC,QAAQ,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,QAAQ,CAAC,OAAO,EAAE,SAAS,cAAc,EAAE,CAAC;IAC5C,mEAAmE;IACnE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,qEAAqE;AACrE,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAEjE,MAAM,WAAW,aAAa;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAkBD,OAAO,CAAC,MAAM,MAAM,EAAE,OAAO,MAAM,CAAC;AAEpC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC;CACjC;AAED,eAAO,MAAM,MAAM,GACjB,MAAM,MAAM,EACZ,UAAU,WAAW,EACrB,YAAW,WAAgB,KAC1B,MAAmD,CAAC;AAKvD,MAAM,MAAM,WAAW,GACnB,MAAM,GACN,iBAAiB,GACjB,MAAM,GACN,SAAS,WAAW,EAAE,CAAC;AA8B3B;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,WAAW,EAClB,OAAO,GAAE,aAAkB,GAC1B,SAAS,cAAc,EAAE,CAuB3B;AAyED,wBAAgB,QAAQ,CACtB,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EACtC,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,CAOR;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,SAAS,cAAc,EAAE,EACpC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACvC,SAAS,cAAc,EAAE,CAyB3B"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The visual matrix: every way a component can look, enumerated.
|
|
3
|
+
*
|
|
4
|
+
* It is computed **at runtime**, from the metadata the style values already
|
|
5
|
+
* carry. Types only check the cardinal and the identifiers (see
|
|
6
|
+
* `exhaustive.ts`) — materialising the cartesian product in the type system is
|
|
7
|
+
* how you meet TS2589.
|
|
8
|
+
*
|
|
9
|
+
* The product is **complete**. The one reduction already banked happens
|
|
10
|
+
* upstream, in the sheet: only the points a class actually crosses are in its
|
|
11
|
+
* contract, so an axis with six breakpoints that a component cuts at one
|
|
12
|
+
* contributes two cells, not seven. No further reduction is applied here — a
|
|
13
|
+
* coverage that claims to be complete without being complete is worse than no
|
|
14
|
+
* coverage at all.
|
|
15
|
+
*/
|
|
16
|
+
import { classKeyOf, registeredClasses, } from '@craft-ts/style';
|
|
17
|
+
const BASE = 'base';
|
|
18
|
+
const keysOf = (input) => {
|
|
19
|
+
if (typeof input === 'string')
|
|
20
|
+
return [classKeyOf(input) ?? input];
|
|
21
|
+
if (Array.isArray(input))
|
|
22
|
+
return input.flatMap((entry) => keysOf(entry));
|
|
23
|
+
if (isBranch(input))
|
|
24
|
+
return [];
|
|
25
|
+
return Object.values(input).flatMap((className) => classKeyOf(className) ?? []);
|
|
26
|
+
};
|
|
27
|
+
const branchesOf = (input) => {
|
|
28
|
+
if (Array.isArray(input))
|
|
29
|
+
return input.flatMap((entry) => branchesOf(entry));
|
|
30
|
+
return isBranch(input) ? [input] : [];
|
|
31
|
+
};
|
|
32
|
+
export const branch = (name, whenTrue, whenFalse = []) => ({ name, whenTrue, whenFalse });
|
|
33
|
+
const isBranch = (input) => typeof input === 'object' && input !== null && 'whenTrue' in input;
|
|
34
|
+
const pointsByAxis = (classes) => {
|
|
35
|
+
const axes = new Map();
|
|
36
|
+
for (const registered of classes) {
|
|
37
|
+
for (const [axis, points] of Object.entries(registered.axes)) {
|
|
38
|
+
const known = axes.get(axis) ?? new Set();
|
|
39
|
+
points.forEach((point) => known.add(point));
|
|
40
|
+
axes.set(axis, known);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return axes;
|
|
44
|
+
};
|
|
45
|
+
const driverIndex = (classes) => {
|
|
46
|
+
const index = new Map();
|
|
47
|
+
for (const registered of classes) {
|
|
48
|
+
for (const rule of registered.rules) {
|
|
49
|
+
for (const point of rule.conditions) {
|
|
50
|
+
index.set(`${point.axis}:${point.point}`, point);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return index;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Every visual state of the given sheets.
|
|
58
|
+
*
|
|
59
|
+
* Takes what a component actually uses: one sheet, several, or the class
|
|
60
|
+
* strings themselves. There is deliberately no `visualMatrix(Component)` — a
|
|
61
|
+
* component's classes are only knowable by rendering it, and a matrix that
|
|
62
|
+
* silently missed a child's sheet would be the worst possible outcome. Naming
|
|
63
|
+
* the sheets is explicit and checkable.
|
|
64
|
+
*/
|
|
65
|
+
export function visualMatrix(input, options = {}) {
|
|
66
|
+
const branches = branchesOf(input);
|
|
67
|
+
if (branches.length) {
|
|
68
|
+
// Each branch is a sum, not a factor: the shared sheets are crossed with
|
|
69
|
+
// *one* side at a time, and the two sides are concatenated. Nesting two
|
|
70
|
+
// branches therefore gives a sum of sums, which is what keeps an if-node
|
|
71
|
+
// from doubling the bill of everything above it.
|
|
72
|
+
const [first, ...rest] = branches;
|
|
73
|
+
// Every branch is stripped from the shared part, then `rest` is put back
|
|
74
|
+
// once. Stripping only `first` would leave the others in twice — inside
|
|
75
|
+
// the shared input and in `rest` — and the count would explode instead of
|
|
76
|
+
// shrinking.
|
|
77
|
+
const shared = stripBranches(input);
|
|
78
|
+
return [
|
|
79
|
+
...visualMatrix([shared, first.whenTrue, ...rest], options).map((scenario) => tagBranch(scenario, first.name, true)),
|
|
80
|
+
...visualMatrix([shared, first.whenFalse, ...rest], options).map((scenario) => tagBranch(scenario, first.name, false)),
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
return unfold(input, options);
|
|
84
|
+
}
|
|
85
|
+
const stripBranches = (input) => {
|
|
86
|
+
if (Array.isArray(input))
|
|
87
|
+
return input.map(stripBranches);
|
|
88
|
+
return isBranch(input) ? [] : input;
|
|
89
|
+
};
|
|
90
|
+
const tagBranch = (scenario, name, taken) => {
|
|
91
|
+
const axes = { ...scenario.axes, [name]: taken ? 'true' : 'false' };
|
|
92
|
+
return { ...scenario, axes, id: identify(axes, scenario.content) };
|
|
93
|
+
};
|
|
94
|
+
function unfold(input, options) {
|
|
95
|
+
const wanted = new Set(keysOf(input));
|
|
96
|
+
const classes = registeredClasses().filter((registered) => wanted.has(registered.key));
|
|
97
|
+
const points = driverIndex(classes);
|
|
98
|
+
const unreachable = new Set(options.unreachable ?? []);
|
|
99
|
+
const resolves = new Set(options.resolves ?? []);
|
|
100
|
+
const axes = [...pointsByAxis(classes)]
|
|
101
|
+
.filter(([axis]) => !unreachable.has(axis))
|
|
102
|
+
// A container axis belongs to the component that declares the container,
|
|
103
|
+
// and to nobody above it: an ancestor cannot change how wide that box is,
|
|
104
|
+
// so handing it those scenarios would ask for captures it cannot affect.
|
|
105
|
+
// Naming the container is how a component claims the axis; every other
|
|
106
|
+
// caller drops it.
|
|
107
|
+
.filter(([axis]) => !axis.startsWith('container.') ||
|
|
108
|
+
resolves.has(axis.slice('container.'.length)))
|
|
109
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
110
|
+
let combinations = [{}];
|
|
111
|
+
for (const [axis, used] of axes) {
|
|
112
|
+
const values = [
|
|
113
|
+
BASE,
|
|
114
|
+
...[...used]
|
|
115
|
+
.filter((point) => !unreachable.has(`${axis}:${point}`))
|
|
116
|
+
.sort(),
|
|
117
|
+
];
|
|
118
|
+
combinations = combinations.flatMap((combination) => values.map((value) => ({ ...combination, [axis]: value })));
|
|
119
|
+
}
|
|
120
|
+
return combinations.map((combination) => ({
|
|
121
|
+
id: identify(combination),
|
|
122
|
+
axes: combination,
|
|
123
|
+
drivers: Object.entries(combination)
|
|
124
|
+
.filter(([, point]) => point !== BASE)
|
|
125
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
126
|
+
.flatMap(([axis, point]) => {
|
|
127
|
+
const known = points.get(`${axis}:${point}`);
|
|
128
|
+
if (!known) {
|
|
129
|
+
throw new Error(`visualMatrix: '${axis}: ${point}' has no driver, so nothing can put a page in that state. An axis whose points cannot be reached would add captures that all look the same — false coverage. Give the axis a driver, or do not use it.`);
|
|
130
|
+
}
|
|
131
|
+
return [{ axis, point, driver: known.driver }];
|
|
132
|
+
}),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
export function identify(axes, content) {
|
|
136
|
+
const parts = Object.entries(axes)
|
|
137
|
+
.filter(([, point]) => point !== BASE)
|
|
138
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
139
|
+
.map(([axis, point]) => `${axis}=${point}`);
|
|
140
|
+
const id = parts.length ? parts.join('+') : BASE;
|
|
141
|
+
return content ? `${id}#${content}` : id;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Data cases, crossed into the matrix.
|
|
145
|
+
*
|
|
146
|
+
* The matrix covers *conditions*, not *data* — and the eighty-character title,
|
|
147
|
+
* the empty list and the seven-figure price are what break layouts most often.
|
|
148
|
+
* No type can derive them, so they are declared.
|
|
149
|
+
*
|
|
150
|
+
* The crossing rule: a content case is rendered at **one** point of each axis,
|
|
151
|
+
* except on the axes that change the space available — viewport and container —
|
|
152
|
+
* where the crossing is complete. A long title behaves differently at two
|
|
153
|
+
* widths; it does not behave differently in two colour schemes.
|
|
154
|
+
*/
|
|
155
|
+
export function contentCases(scenarios, cases) {
|
|
156
|
+
const names = Object.keys(cases);
|
|
157
|
+
if (names.length === 0)
|
|
158
|
+
return scenarios;
|
|
159
|
+
const spatial = (scenario) => Object.entries(scenario.axes).some(([axis, point]) => point !== BASE &&
|
|
160
|
+
(axis === 'viewport' || axis.startsWith('container.')));
|
|
161
|
+
return scenarios.flatMap((scenario) => {
|
|
162
|
+
const relevant = spatial(scenario) ||
|
|
163
|
+
Object.values(scenario.axes).every((point) => point === BASE);
|
|
164
|
+
const applicable = relevant ? names : [];
|
|
165
|
+
return [
|
|
166
|
+
scenario,
|
|
167
|
+
...applicable.map((name) => ({
|
|
168
|
+
...scenario,
|
|
169
|
+
id: identify(scenario.axes, name),
|
|
170
|
+
content: name,
|
|
171
|
+
})),
|
|
172
|
+
];
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=matrix.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"matrix.js","sourceRoot":"","sources":["../../../../../libs/style-testing/src/lib/matrix.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The visual matrix: every way a component can look, enumerated.\n *\n * It is computed **at runtime**, from the metadata the style values already\n * carry. Types only check the cardinal and the identifiers (see\n * `exhaustive.ts`) — materialising the cartesian product in the type system is\n * how you meet TS2589.\n *\n * The product is **complete**. The one reduction already banked happens\n * upstream, in the sheet: only the points a class actually crosses are in its\n * contract, so an axis with six breakpoints that a component cuts at one\n * contributes two cells, not seven. No further reduction is applied here — a\n * coverage that claims to be complete without being complete is worse than no\n * coverage at all.\n */\nimport {\n classKeyOf,\n registeredClasses,\n type AnyAxisPoint,\n type RegisteredClass,\n} from '@craft-ts/style';\n\nexport interface ScenarioDriver {\n readonly axis: string;\n readonly point: string;\n readonly driver: AnyAxisPoint['driver'];\n}\n\nexport interface VisualScenario {\n /**\n * `scheme=dark+viewport=md` — sorted, and stable under the addition of an\n * unrelated axis.\n *\n * Only the axes away from `base` appear. An identifier that listed every axis\n * would change the moment someone adds one somewhere else in the app, and\n * every baseline in the suite would be invalidated by a change that affects\n * none of them.\n */\n readonly id: string;\n readonly axes: Readonly<Record<string, string>>;\n readonly drivers: readonly ScenarioDriver[];\n /** Set when a content case was crossed in — see `contentCases`. */\n readonly content?: string;\n}\n\n/** What `craftStyles` returns: a record of branded class strings. */\nexport type StyleSheetClasses = Readonly<Record<string, string>>;\n\nexport interface MatrixOptions {\n /**\n * Container axes resolved by the component itself.\n *\n * A container axis answers \"how wide is my box\", which nobody above the\n * container can change. Naming the container here closes the axis at this\n * component instead of handing it to every ancestor.\n */\n readonly resolves?: readonly string[];\n /**\n * Axis points the component's own structure makes unreachable — a scroll\n * state under a `scrollPort.none`, for instance. Pruned rather than captured.\n */\n readonly unreachable?: readonly string[];\n}\n\nconst BASE = 'base';\n\nconst keysOf = (input: MatrixInput): readonly string[] => {\n if (typeof input === 'string') return [classKeyOf(input) ?? input];\n if (Array.isArray(input)) return input.flatMap((entry) => keysOf(entry));\n if (isBranch(input)) return [];\n return Object.values(input as StyleSheetClasses).flatMap(\n (className) => classKeyOf(className) ?? [],\n );\n};\n\nconst branchesOf = (input: MatrixInput): readonly Branch[] => {\n if (Array.isArray(input)) return input.flatMap((entry) => branchesOf(entry));\n return isBranch(input) ? [input] : [];\n};\n\ndeclare const BRANCH: unique symbol;\n\n/**\n * Two sets of sheets that are never on screen at the same time.\n *\n * An `ifNode` renders one side or the other, so the states of the two branches\n * **add up** — they do not multiply. Told nothing, the matrix would cross a\n * footer's three tones with a header's two breakpoints and ask for six captures\n * of pages that cannot exist.\n *\n * This is the single highest-leverage reduction in the whole system, and the\n * only one that is exactly true rather than probably true: the branches are\n * mutually exclusive by construction, not by analysis.\n *\n * The plan infers it from the `ifNode` node's own type. It is declared here,\n * for the same reason the matrix takes sheets rather than components — and the\n * cost of that choice is stated plainly: a branch nobody declares is counted as\n * co-present, which over-captures rather than under-captures.\n */\nexport interface Branch {\n readonly [BRANCH]: true;\n readonly name: string;\n readonly whenTrue: MatrixInput;\n readonly whenFalse: MatrixInput;\n}\n\nexport const branch = (\n name: string,\n whenTrue: MatrixInput,\n whenFalse: MatrixInput = [],\n): Branch => ({ name, whenTrue, whenFalse }) as Branch;\n\nconst isBranch = (input: MatrixInput | Branch): input is Branch =>\n typeof input === 'object' && input !== null && 'whenTrue' in input;\n\nexport type MatrixInput =\n | string\n | StyleSheetClasses\n | Branch\n | readonly MatrixInput[];\n\nconst pointsByAxis = (\n classes: readonly RegisteredClass[],\n): Map<string, Set<string>> => {\n const axes = new Map<string, Set<string>>();\n for (const registered of classes) {\n for (const [axis, points] of Object.entries(registered.axes)) {\n const known = axes.get(axis) ?? new Set<string>();\n points.forEach((point) => known.add(point));\n axes.set(axis, known);\n }\n }\n return axes;\n};\n\nconst driverIndex = (\n classes: readonly RegisteredClass[],\n): Map<string, AnyAxisPoint> => {\n const index = new Map<string, AnyAxisPoint>();\n for (const registered of classes) {\n for (const rule of registered.rules) {\n for (const point of rule.conditions) {\n index.set(`${point.axis}:${point.point}`, point);\n }\n }\n }\n return index;\n};\n\n/**\n * Every visual state of the given sheets.\n *\n * Takes what a component actually uses: one sheet, several, or the class\n * strings themselves. There is deliberately no `visualMatrix(Component)` — a\n * component's classes are only knowable by rendering it, and a matrix that\n * silently missed a child's sheet would be the worst possible outcome. Naming\n * the sheets is explicit and checkable.\n */\nexport function visualMatrix(\n input: MatrixInput,\n options: MatrixOptions = {},\n): readonly VisualScenario[] {\n const branches = branchesOf(input);\n if (branches.length) {\n // Each branch is a sum, not a factor: the shared sheets are crossed with\n // *one* side at a time, and the two sides are concatenated. Nesting two\n // branches therefore gives a sum of sums, which is what keeps an if-node\n // from doubling the bill of everything above it.\n const [first, ...rest] = branches;\n // Every branch is stripped from the shared part, then `rest` is put back\n // once. Stripping only `first` would leave the others in twice — inside\n // the shared input and in `rest` — and the count would explode instead of\n // shrinking.\n const shared = stripBranches(input);\n return [\n ...visualMatrix([shared, first.whenTrue, ...rest], options).map(\n (scenario) => tagBranch(scenario, first.name, true),\n ),\n ...visualMatrix([shared, first.whenFalse, ...rest], options).map(\n (scenario) => tagBranch(scenario, first.name, false),\n ),\n ];\n }\n return unfold(input, options);\n}\n\nconst stripBranches = (input: MatrixInput): MatrixInput => {\n if (Array.isArray(input)) return input.map(stripBranches);\n return isBranch(input) ? [] : input;\n};\n\nconst tagBranch = (\n scenario: VisualScenario,\n name: string,\n taken: boolean,\n): VisualScenario => {\n const axes = { ...scenario.axes, [name]: taken ? 'true' : 'false' };\n return { ...scenario, axes, id: identify(axes, scenario.content) };\n};\n\nfunction unfold(\n input: MatrixInput,\n options: MatrixOptions,\n): readonly VisualScenario[] {\n const wanted = new Set(keysOf(input));\n const classes = registeredClasses().filter((registered) =>\n wanted.has(registered.key),\n );\n const points = driverIndex(classes);\n const unreachable = new Set(options.unreachable ?? []);\n\n const resolves = new Set(options.resolves ?? []);\n const axes = [...pointsByAxis(classes)]\n .filter(([axis]) => !unreachable.has(axis))\n // A container axis belongs to the component that declares the container,\n // and to nobody above it: an ancestor cannot change how wide that box is,\n // so handing it those scenarios would ask for captures it cannot affect.\n // Naming the container is how a component claims the axis; every other\n // caller drops it.\n .filter(\n ([axis]) =>\n !axis.startsWith('container.') ||\n resolves.has(axis.slice('container.'.length)),\n )\n .sort(([left], [right]) => left.localeCompare(right));\n\n let combinations: Record<string, string>[] = [{}];\n for (const [axis, used] of axes) {\n const values = [\n BASE,\n ...[...used]\n .filter((point) => !unreachable.has(`${axis}:${point}`))\n .sort(),\n ];\n combinations = combinations.flatMap((combination) =>\n values.map((value) => ({ ...combination, [axis]: value })),\n );\n }\n\n return combinations.map((combination) => ({\n id: identify(combination),\n axes: combination,\n drivers: Object.entries(combination)\n .filter(([, point]) => point !== BASE)\n .sort(([left], [right]) => left.localeCompare(right))\n .flatMap(([axis, point]) => {\n const known = points.get(`${axis}:${point}`);\n if (!known) {\n throw new Error(\n `visualMatrix: '${axis}: ${point}' has no driver, so nothing can put a page in that state. An axis whose points cannot be reached would add captures that all look the same — false coverage. Give the axis a driver, or do not use it.`,\n );\n }\n return [{ axis, point, driver: known.driver }];\n }),\n }));\n}\n\nexport function identify(\n axes: Readonly<Record<string, string>>,\n content?: string,\n): string {\n const parts = Object.entries(axes)\n .filter(([, point]) => point !== BASE)\n .sort(([left], [right]) => left.localeCompare(right))\n .map(([axis, point]) => `${axis}=${point}`);\n const id = parts.length ? parts.join('+') : BASE;\n return content ? `${id}#${content}` : id;\n}\n\n/**\n * Data cases, crossed into the matrix.\n *\n * The matrix covers *conditions*, not *data* — and the eighty-character title,\n * the empty list and the seven-figure price are what break layouts most often.\n * No type can derive them, so they are declared.\n *\n * The crossing rule: a content case is rendered at **one** point of each axis,\n * except on the axes that change the space available — viewport and container —\n * where the crossing is complete. A long title behaves differently at two\n * widths; it does not behave differently in two colour schemes.\n */\nexport function contentCases(\n scenarios: readonly VisualScenario[],\n cases: Readonly<Record<string, unknown>>,\n): readonly VisualScenario[] {\n const names = Object.keys(cases);\n if (names.length === 0) return scenarios;\n\n const spatial = (scenario: VisualScenario) =>\n Object.entries(scenario.axes).some(\n ([axis, point]) =>\n point !== BASE &&\n (axis === 'viewport' || axis.startsWith('container.')),\n );\n\n return scenarios.flatMap((scenario) => {\n const relevant =\n spatial(scenario) ||\n Object.values(scenario.axes).every((point) => point === BASE);\n const applicable = relevant ? names : [];\n return [\n scenario,\n ...applicable.map((name) => ({\n ...scenario,\n id: identify(scenario.axes, name),\n content: name,\n })),\n ];\n });\n}\n"]}
|