@oxy.so/bloom 6.2.0 → 6.2.1
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/docs/adoption-matrix.mdx +2 -2
- package/docs/bottom-sheet.mdx +1 -1
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js +33 -49
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/commonjs/dialog/DialogBottomSheet.js +1 -1
- package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
- package/lib/commonjs/surface/SurfacePaint.js +17 -4
- package/lib/commonjs/surface/SurfacePaint.js.map +1 -1
- package/lib/module/bottom-sheet/BottomSheetBase.js +33 -49
- package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/module/dialog/DialogBottomSheet.js +1 -1
- package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
- package/lib/module/surface/SurfacePaint.js +18 -5
- package/lib/module/surface/SurfacePaint.js.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/types.d.ts +1 -0
- package/lib/typescript/commonjs/bottom-sheet/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/surface/SurfacePaint.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/types.d.ts +1 -0
- package/lib/typescript/module/bottom-sheet/types.d.ts.map +1 -1
- package/lib/typescript/module/surface/SurfacePaint.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/support/adoption-matrix.ts +683 -0
- package/src/__tests__/support/card-surface.ts +25 -0
- package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
- package/src/__tests__/support/commerce-harness.tsx +97 -0
- package/src/__tests__/support/composite.ts +68 -0
- package/src/__tests__/support/constructed-style-sheets.ts +68 -0
- package/src/__tests__/support/messages-in.ts +10 -0
- package/src/__tests__/support/press-host.ts +30 -0
- package/src/__tests__/support/rendered-style.ts +99 -0
- package/src/__tests__/support/unread-hook-fixture.ts +33 -0
- package/src/__tests__/support/worklet-capture-fixture.tsx +28 -0
- package/src/bottom-sheet/BottomSheetBase.tsx +24 -34
- package/src/bottom-sheet/types.ts +1 -0
- package/src/dialog/DialogBottomSheet.tsx +1 -1
- package/src/surface/SurfacePaint.tsx +14 -4
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +9346 -0
- package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +69 -0
- package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ADOPTION MATRIX: every published family, against every composition
|
|
3
|
+
* contract that applies to it.
|
|
4
|
+
*
|
|
5
|
+
* ## Why it is derived and not written
|
|
6
|
+
*
|
|
7
|
+
* A matrix maintained by hand answers the question "what did someone last write
|
|
8
|
+
* down", and it is wrong the first time a family changes without its row being
|
|
9
|
+
* updated — silently, because a stale row reads exactly like a fresh one. Every
|
|
10
|
+
* fact here is read out of the source instead:
|
|
11
|
+
*
|
|
12
|
+
* - the published surface, from `package.json#exports` and the root barrel
|
|
13
|
+
* - the platform split, from the presence of an `index.web.ts`
|
|
14
|
+
* - docs, stories and suites, from the files that exist and what they import
|
|
15
|
+
* - which contracts a family READS, from its import graph
|
|
16
|
+
* - which contracts APPLY to it, from what it renders
|
|
17
|
+
*
|
|
18
|
+
* The output is `docs/adoption-matrix.mdx`, asserted byte-identical by
|
|
19
|
+
* `src/__tests__/adoption-matrix.test.ts` — the same arrangement as
|
|
20
|
+
* `design-tokens/tokens.json`. Regenerate with:
|
|
21
|
+
*
|
|
22
|
+
* bun run generate:adoption-matrix
|
|
23
|
+
*
|
|
24
|
+
* ## APPLICABILITY IS THE HARD PART, and it is derived from what a family RENDERS
|
|
25
|
+
*
|
|
26
|
+
* "Does the field contract apply to `Search`?" cannot be answered by its name.
|
|
27
|
+
* `Search` renders no control of its own — it composes `TextFieldInput`, which
|
|
28
|
+
* reads the contract — so it is not a subject at all, and "fixing" it would mean
|
|
29
|
+
* giving it a second implementation of what it already gets from the primitive:
|
|
30
|
+
* the duplication #148 asks to detect, arrived at by trying to be thorough.
|
|
31
|
+
*
|
|
32
|
+
* So a contract applies to a family when that family writes the node the
|
|
33
|
+
* contract governs — a raw `<TextInput`, a `role="radiogroup"` of its own, a
|
|
34
|
+
* size prop whose vocabulary IS the density pair, its own `asChild`, its own
|
|
35
|
+
* `default*`/controlled prop pair. An earlier version of this file resolved
|
|
36
|
+
* conformance transitively instead ("it imports something that reads it"), and
|
|
37
|
+
* that was worse than no answer: nearly every family reaches `text-field`
|
|
38
|
+
* eventually, so 17 families rendering raw inputs came out green.
|
|
39
|
+
*
|
|
40
|
+
* ## Every applicable family that does NOT read the contract is classified HERE
|
|
41
|
+
*
|
|
42
|
+
* With a verdict and a reason, in {@link CLASSIFICATION}, and the gate asserts
|
|
43
|
+
* that map's keys EQUAL the derived set — so a new one cannot be quiet (it
|
|
44
|
+
* renders as `unclassified` and fails), and a stale one cannot linger either.
|
|
45
|
+
* Three verdicts, because "not read" is three different situations:
|
|
46
|
+
*
|
|
47
|
+
* `delegated` it passes the decision to a child that reads the contract
|
|
48
|
+
* `does-not-apply` the derivation matched something that is not the contract's
|
|
49
|
+
* subject after reading the code — a menu row, a scrubber
|
|
50
|
+
* `adaptation` a real remaining gap, named rather than hidden
|
|
51
|
+
*/
|
|
52
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
53
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The repo root, found by walking UP from the working directory.
|
|
57
|
+
*
|
|
58
|
+
* Not `import.meta.url`: this module is imported by a jest suite as well as by
|
|
59
|
+
* the generator, and ts-jest compiles to CommonJS, where `import.meta` is a
|
|
60
|
+
* syntax error. Not `__dirname` either, for the mirror reason under bun. Walking
|
|
61
|
+
* up for Bloom's own `package.json` works under both and fails loudly rather
|
|
62
|
+
* than deriving a plausible wrong root.
|
|
63
|
+
*/
|
|
64
|
+
function findRepoRoot(): string {
|
|
65
|
+
let dir = process.cwd();
|
|
66
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
67
|
+
const candidate = join(dir, 'package.json');
|
|
68
|
+
if (existsSync(candidate)) {
|
|
69
|
+
const pkg = JSON.parse(readFileSync(candidate, 'utf8')) as { name?: string };
|
|
70
|
+
if (pkg.name === '@oxy.so/bloom') return dir;
|
|
71
|
+
}
|
|
72
|
+
const parent = dirname(dir);
|
|
73
|
+
if (parent === dir) break;
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
throw new Error(
|
|
77
|
+
"adoption-matrix: could not find Bloom's package.json above the working directory — run this from inside the repo.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const REPO_ROOT = findRepoRoot();
|
|
82
|
+
const SRC = join(REPO_ROOT, 'src');
|
|
83
|
+
const DOCS = join(REPO_ROOT, 'docs');
|
|
84
|
+
|
|
85
|
+
export const MATRIX_PATH = join(DOCS, 'adoption-matrix.mdx');
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// The tree
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function directories(dir: string): string[] {
|
|
92
|
+
return readdirSync(dir)
|
|
93
|
+
.filter((name) => !name.startsWith('.') && statSync(join(dir, name)).isDirectory())
|
|
94
|
+
.sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function walk(dir: string, match: RegExp, out: string[] = []): string[] {
|
|
98
|
+
for (const name of readdirSync(dir)) {
|
|
99
|
+
if (name.startsWith('.')) continue;
|
|
100
|
+
const full = join(dir, name);
|
|
101
|
+
if (statSync(full).isDirectory()) walk(full, match, out);
|
|
102
|
+
else if (match.test(name)) out.push(full);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const families = (): string[] => directories(SRC).filter((name) => name !== '__tests__');
|
|
108
|
+
|
|
109
|
+
/** Every source file of a family, minus its stories and its own tests. */
|
|
110
|
+
function sourceFiles(family: string): string[] {
|
|
111
|
+
return walk(join(SRC, family), /\.(ts|tsx)$/).filter(
|
|
112
|
+
(file) => !/\.(stories|test|spec)\.tsx?$/.test(file),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const readAll = (files: string[]): string =>
|
|
117
|
+
files
|
|
118
|
+
.map((file) => readFileSync(file, 'utf8'))
|
|
119
|
+
.join('\n')
|
|
120
|
+
// Comments are prose, and prose NAMES contracts it does not use — the
|
|
121
|
+
// derivation would read every doc comment mentioning `TextInput` as a
|
|
122
|
+
// control being rendered.
|
|
123
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
124
|
+
.replace(/^\s*\/\/.*$/gm, '');
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// The published surface
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
interface Pkg {
|
|
131
|
+
exports: Record<string, Record<string, unknown> | string>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function subpaths(): Map<string, string> {
|
|
135
|
+
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as Pkg;
|
|
136
|
+
const out = new Map<string, string>();
|
|
137
|
+
for (const [subpath, entry] of Object.entries(pkg.exports)) {
|
|
138
|
+
if (typeof entry === 'string') continue;
|
|
139
|
+
const rn = entry['react-native'];
|
|
140
|
+
const source = typeof rn === 'object' && rn !== null ? (rn as { default?: unknown }).default : rn;
|
|
141
|
+
if (typeof source !== 'string') continue;
|
|
142
|
+
const rel = source.replace(/^\.\/src\//, '');
|
|
143
|
+
if (!rel.includes('/')) continue;
|
|
144
|
+
const family = rel.slice(0, rel.indexOf('/'));
|
|
145
|
+
// A family with several subpaths (`icons`, `theme`) is listed at its own.
|
|
146
|
+
if (!out.has(family) || subpath.length < out.get(family)!.length) out.set(family, subpath);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function rootBarrelFamilies(): Set<string> {
|
|
152
|
+
const barrel = readAll([join(SRC, 'index.ts')]);
|
|
153
|
+
const out = new Set<string>();
|
|
154
|
+
for (const match of barrel.matchAll(/from\s+['"]\.\/([^'"]+)['"]/g)) {
|
|
155
|
+
const first = match[1]?.split('/')[0];
|
|
156
|
+
if (first !== undefined) out.add(first);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Docs, stories, suites
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A family documented under a name a reader would actually look for. The same
|
|
167
|
+
* aliases `family-coverage.test.ts` holds, kept here rather than imported
|
|
168
|
+
* because that file is a jest suite.
|
|
169
|
+
*/
|
|
170
|
+
const DOC_ALIASES: Readonly<Record<string, string>> = {
|
|
171
|
+
surfaces: 'alert',
|
|
172
|
+
'appearance': 'composition',
|
|
173
|
+
field: 'field',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
function docStem(family: string): string | null {
|
|
177
|
+
const alias = DOC_ALIASES[family];
|
|
178
|
+
if (alias !== undefined && existsSync(join(DOCS, `${alias}.mdx`))) return alias;
|
|
179
|
+
return existsSync(join(DOCS, `${family}.mdx`)) ? family : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveLocal(fromFile: string, spec: string): string | null {
|
|
183
|
+
const base = resolve(dirname(fromFile), spec);
|
|
184
|
+
for (const candidate of [
|
|
185
|
+
`${base}.tsx`,
|
|
186
|
+
`${base}.ts`,
|
|
187
|
+
base,
|
|
188
|
+
join(base, 'index.tsx'),
|
|
189
|
+
join(base, 'index.ts'),
|
|
190
|
+
join(base, 'index.web.ts'),
|
|
191
|
+
]) {
|
|
192
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const SPEC_RE =
|
|
198
|
+
/from\s+['"](\.[^'"]+)['"]|require\(\s*['"](\.[^'"]+)['"]\s*\)|import\(\s*['"](\.[^'"]+)['"]\s*\)|jest\.mock\(\s*['"](\.[^'"]+)['"]/g;
|
|
199
|
+
|
|
200
|
+
function specifiersOf(file: string, text: string): string[] {
|
|
201
|
+
const out: string[] = [];
|
|
202
|
+
for (const match of text.matchAll(SPEC_RE)) {
|
|
203
|
+
const spec = match[1] ?? match[2] ?? match[3] ?? match[4];
|
|
204
|
+
if (spec !== undefined) out.push(spec);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const familyOf = (file: string): string => relative(SRC, file).split('/')[0] ?? '';
|
|
210
|
+
|
|
211
|
+
/** Family → how many suites reach into it, by RESOLVING a relative import. */
|
|
212
|
+
function suiteCounts(): Map<string, number> {
|
|
213
|
+
const out = new Map<string, number>();
|
|
214
|
+
for (const file of walk(SRC, /\.(test|spec)\.tsx?$/)) {
|
|
215
|
+
const text = readAll([file]);
|
|
216
|
+
const reached = new Set<string>();
|
|
217
|
+
for (const spec of specifiersOf(file, text)) {
|
|
218
|
+
const resolved = resolveLocal(file, spec);
|
|
219
|
+
if (resolved === null || !resolved.startsWith(SRC)) continue;
|
|
220
|
+
reached.add(familyOf(resolved));
|
|
221
|
+
}
|
|
222
|
+
for (const family of reached) out.set(family, (out.get(family) ?? 0) + 1);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// The contracts
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* What a family renders when the FIELD contract applies to it: a raw platform
|
|
234
|
+
* text input, or a form role it wrote itself.
|
|
235
|
+
*
|
|
236
|
+
* `<TextInput` must be followed by a prop, a newline or a `/` — `useRef<TextInput
|
|
237
|
+
* | null>` and `forwardRef<TextInput, P>` are TYPE references and were both
|
|
238
|
+
* counted as rendered controls by the first version of this regex.
|
|
239
|
+
*/
|
|
240
|
+
const FORM_CONTROL_RE =
|
|
241
|
+
/<TextInput\n|<TextInput\s+[A-Za-z{/]|accessibilityRole=(['"])(checkbox|radio|switch|adjustable)\1|\brole=(['"])(checkbox|radio|switch|slider|radiogroup|textbox|combobox|spinbutton)\3/;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A size prop whose vocabulary IS `ControlDensity`. The union has to END there:
|
|
245
|
+
* `'small' | 'medium' | 'large'` is a THREE-rung scale of a family's own, and a
|
|
246
|
+
* container that set `density` could not say which of its rungs it meant.
|
|
247
|
+
*/
|
|
248
|
+
const DENSITY_PROP_RE =
|
|
249
|
+
/(size|density)\??:\s*(BloomSize|ButtonSize|TextFieldSize|ButtonGroupSize|TimeFieldSize|CheckboxSize|SelectSize)\s*;|(size|density)\??:\s*'(medium|small)'\s*\|\s*'(small|medium)'\s*;/;
|
|
250
|
+
|
|
251
|
+
/** Both halves of a controlled pair, declared as props by this family. */
|
|
252
|
+
function declaresControlledPair(text: string): boolean {
|
|
253
|
+
for (const match of text.matchAll(/\bdefault([A-Z]\w*)\?:/g)) {
|
|
254
|
+
const base = match[1]![0]!.toLowerCase() + match[1]!.slice(1);
|
|
255
|
+
if (base === 'props') continue;
|
|
256
|
+
if (new RegExp(`\\b${base}\\?:`).test(text)) return true;
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface Contract {
|
|
262
|
+
key: string;
|
|
263
|
+
title: string;
|
|
264
|
+
source: string;
|
|
265
|
+
blurb: string;
|
|
266
|
+
applies: (text: string) => boolean;
|
|
267
|
+
reads: (text: string) => boolean;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const CONTRACTS: Contract[] = [
|
|
271
|
+
{
|
|
272
|
+
key: 'field',
|
|
273
|
+
title: 'Field membership',
|
|
274
|
+
source: 'field/context.ts, field/membership.ts',
|
|
275
|
+
blurb:
|
|
276
|
+
'The id, accessible name, description, error, invalid, required and disabled state of an enclosing `Field`, applied to the control. Applies to a family that renders a raw `TextInput` or writes a form role of its own',
|
|
277
|
+
applies: (text) => FORM_CONTROL_RE.test(text),
|
|
278
|
+
reads: (text) => /field\/(membership|context)|useFieldMembership|useFieldControl/.test(text),
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
key: 'appearance',
|
|
282
|
+
title: 'Control presentation',
|
|
283
|
+
source: 'appearance/context.ts',
|
|
284
|
+
blurb:
|
|
285
|
+
'The size and tone inherited from the nearest BloomScope. Explicit control props override this visual default; constraints remain separate. Applies to controls using the canonical size vocabulary',
|
|
286
|
+
applies: (text) => DENSITY_PROP_RE.test(text),
|
|
287
|
+
reads: (text) => /appearance\/|useBloomAppearance/.test(text),
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
key: 'trigger',
|
|
291
|
+
title: 'Trigger composition',
|
|
292
|
+
source: 'floating/TriggerSlot.tsx',
|
|
293
|
+
blurb:
|
|
294
|
+
'`asChild`, the composed press handler, the cancellable open, the refused fragment and the two-sided disabled guard. Applies to a family that declares an `asChild` prop of its own',
|
|
295
|
+
applies: (text) => /asChild\??:\s*boolean/.test(text),
|
|
296
|
+
reads: (text) => /TriggerSlot|cloneTrigger/.test(text),
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
key: 'controlled-state',
|
|
300
|
+
title: 'Controlled / uncontrolled state',
|
|
301
|
+
source: 'hooks/use-controllable-state.ts',
|
|
302
|
+
blurb:
|
|
303
|
+
'One reconciliation of "controlled the moment the prop is passed", with the callback firing either way. Applies to a family that declares both halves of a pair (`value` and `defaultValue`, `open` and `defaultOpen`, …)',
|
|
304
|
+
applies: declaresControlledPair,
|
|
305
|
+
reads: (text) => /use-controllable-state|useControllableState/.test(text),
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// The classification of everything the contracts apply to and that does not
|
|
311
|
+
// read them. An EQUALITY, asserted in `src/__tests__/adoption-matrix.test.ts`.
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
export type Verdict = 'reads' | 'delegated' | 'does-not-apply' | 'adaptation' | 'unclassified';
|
|
315
|
+
|
|
316
|
+
export interface Classification {
|
|
317
|
+
verdict: Exclude<Verdict, 'reads' | 'unclassified'>;
|
|
318
|
+
reason: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** One shared reason, for the families that draw a bare input ON PURPOSE. */
|
|
322
|
+
const BARE_INPUT =
|
|
323
|
+
'renders a bare `TextInput` deliberately — a composer or a search box that draws its own chrome and is not a form field a `Field` wraps. Wiring it to `TextFieldInput` would give it the field shell it exists not to draw.';
|
|
324
|
+
|
|
325
|
+
/** One shared reason, for a selection row that is not a form control. */
|
|
326
|
+
const SELECTION_ROW =
|
|
327
|
+
'writes a selection role on a ROW or a chip rather than on a form control — a list the user picks from, inside its own container, never inside a `Field`.';
|
|
328
|
+
|
|
329
|
+
export const CLASSIFICATION: Record<string, Record<string, Classification>> = {
|
|
330
|
+
field: {
|
|
331
|
+
'agent-chat': { verdict: 'does-not-apply', reason: BARE_INPUT },
|
|
332
|
+
'chat-composer': { verdict: 'does-not-apply', reason: BARE_INPUT },
|
|
333
|
+
'chat-list': { verdict: 'does-not-apply', reason: BARE_INPUT },
|
|
334
|
+
command: { verdict: 'does-not-apply', reason: BARE_INPUT },
|
|
335
|
+
'home-search': { verdict: 'does-not-apply', reason: BARE_INPUT },
|
|
336
|
+
'music-library': { verdict: 'does-not-apply', reason: BARE_INPUT },
|
|
337
|
+
sidebar: { verdict: 'does-not-apply', reason: BARE_INPUT },
|
|
338
|
+
'chat-people': { verdict: 'does-not-apply', reason: SELECTION_ROW },
|
|
339
|
+
'stay-filters': { verdict: 'does-not-apply', reason: SELECTION_ROW },
|
|
340
|
+
'map-marker': { verdict: 'does-not-apply', reason: SELECTION_ROW },
|
|
341
|
+
address: { verdict: 'does-not-apply', reason: SELECTION_ROW },
|
|
342
|
+
'composer-panel': {
|
|
343
|
+
verdict: 'does-not-apply',
|
|
344
|
+
reason:
|
|
345
|
+
'its pills and effort slider are a toolbar of a composer, named by the group they sit in; the panel is never a field.',
|
|
346
|
+
},
|
|
347
|
+
floating: {
|
|
348
|
+
verdict: 'does-not-apply',
|
|
349
|
+
reason:
|
|
350
|
+
'a MENU row. `checkbox`/`radio` inside a menu is the menu-item semantic, and the menu owns its own name, state and keyboard model (`floating/menu-rows.tsx`).',
|
|
351
|
+
},
|
|
352
|
+
'media-controls': {
|
|
353
|
+
verdict: 'does-not-apply',
|
|
354
|
+
reason:
|
|
355
|
+
'a transport SCRUBBER (`adjustable`): it is named by the track it plays and lives in a player, not in a field.',
|
|
356
|
+
},
|
|
357
|
+
'message-media': {
|
|
358
|
+
verdict: 'does-not-apply',
|
|
359
|
+
reason: 'a voice message\u2019s scrubber — same as `media-controls`.',
|
|
360
|
+
},
|
|
361
|
+
'theme-toggle': {
|
|
362
|
+
verdict: 'does-not-apply',
|
|
363
|
+
reason:
|
|
364
|
+
'the switch row draws its own label ("Dark mode") as both visible text and accessible name, inside the toggle\u2019s own menu.',
|
|
365
|
+
},
|
|
366
|
+
questionnaire: {
|
|
367
|
+
verdict: 'adaptation',
|
|
368
|
+
reason:
|
|
369
|
+
'a FORM whose multi-select answers are hand-written `role="checkbox"` rows instead of `Checkbox`, so a `Field` can neither name nor disable them. Not closed here: the rows carry their own card chrome and selection model, so it is a redesign of the answer control rather than an import.',
|
|
370
|
+
},
|
|
371
|
+
'listing-editor': {
|
|
372
|
+
verdict: 'adaptation',
|
|
373
|
+
reason:
|
|
374
|
+
'the property-type, offering and address-precision pickers are hand-written `radiogroup`s over cards, for the same reason and with the same cost as `questionnaire`.',
|
|
375
|
+
},
|
|
376
|
+
'listing-actions': {
|
|
377
|
+
verdict: 'adaptation',
|
|
378
|
+
reason:
|
|
379
|
+
'the mortgage calculator\u2019s term picker is a hand-written `radiogroup`; it sits beside `TextFieldInput`s that DO read the contract, so one form has two association models.',
|
|
380
|
+
},
|
|
381
|
+
'note-card': {
|
|
382
|
+
verdict: 'does-not-apply',
|
|
383
|
+
reason:
|
|
384
|
+
'the `checkbox` role is on the CARD (bulk selection in a list) and on read-only checklist PREVIEW rows. Neither is a form control: a note card is never inside a `Field`, and the preview rows take no press at all.',
|
|
385
|
+
},
|
|
386
|
+
'note-editor': {
|
|
387
|
+
verdict: 'does-not-apply',
|
|
388
|
+
reason:
|
|
389
|
+
'the raw `TextInput` is a DOCUMENT TITLE — it draws no box, no label and no hint, because the words are the document rather than a value being collected. A `Field` around it would put a form label above a heading.',
|
|
390
|
+
},
|
|
391
|
+
'vehicle-picker': {
|
|
392
|
+
verdict: 'adaptation',
|
|
393
|
+
reason:
|
|
394
|
+
'a hand-written `radiogroup` over `listing-editor`\u2019s selectable cards \u2014 it REUSES that family\u2019s card rather than writing a third one, so it inherits the same gap for the same reason: a `Field` can neither name nor disable the group.',
|
|
395
|
+
},
|
|
396
|
+
'shipment-request': {
|
|
397
|
+
verdict: 'adaptation',
|
|
398
|
+
reason:
|
|
399
|
+
'the load KIND is the same hand-written `radiogroup` over `listing-editor`\u2019s cards. The rest of the form is worse than that, not better: its `TextField`, `Textarea`, `Switch` and `SegmentedControl` all read the contract, so one form has two association models.',
|
|
400
|
+
},
|
|
401
|
+
'carrier-quote': {
|
|
402
|
+
verdict: 'does-not-apply',
|
|
403
|
+
reason: SELECTION_ROW,
|
|
404
|
+
},
|
|
405
|
+
styles: {
|
|
406
|
+
verdict: 'does-not-apply',
|
|
407
|
+
reason:
|
|
408
|
+
'the roles are CSS attribute selectors in `styles/base-web-css.ts` (`[role="radio"]` and its siblings take no text selection), not a node the family renders — it draws no control at all.',
|
|
409
|
+
},
|
|
410
|
+
'job-board': {
|
|
411
|
+
verdict: 'does-not-apply',
|
|
412
|
+
reason: SELECTION_ROW,
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
'appearance': {
|
|
416
|
+
'phone-input': {
|
|
417
|
+
verdict: 'delegated',
|
|
418
|
+
reason:
|
|
419
|
+
'passes `size` straight through to `TextField`, which inherits density — so a `BloomScope` reaches the phone field without this family reading anything.',
|
|
420
|
+
},
|
|
421
|
+
'chat-people': {
|
|
422
|
+
verdict: 'does-not-apply',
|
|
423
|
+
reason:
|
|
424
|
+
'sizes an avatar row of its own, not a control: there is nothing for a container\u2019s density to mean here, and honouring it would resize a presentation block.',
|
|
425
|
+
},
|
|
426
|
+
'creator-studio': {
|
|
427
|
+
verdict: 'does-not-apply',
|
|
428
|
+
reason:
|
|
429
|
+
'sizes a studio stat block — a presentation part, whose two sizes happen to be spelled with the same two words as the density pair.',
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
trigger: {
|
|
433
|
+
button: {
|
|
434
|
+
verdict: 'does-not-apply',
|
|
435
|
+
reason:
|
|
436
|
+
'`Button`\u2019s own `asChild` renders the button AS an anchor or a router `Link` — it opens nothing and merges no open handler, so it is a different feature with the same name. It FORWARDS the trigger contract\u2019s props when an anchored family clones it, which is the half that matters here.',
|
|
437
|
+
},
|
|
438
|
+
theme: {
|
|
439
|
+
verdict: 'does-not-apply',
|
|
440
|
+
reason:
|
|
441
|
+
'`BloomColorScope`/`SeedScope` clone their child to merge a style instead of adding a layout node. No surface, no press, no open.',
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
'controlled-state': {
|
|
445
|
+
'map-controls': {
|
|
446
|
+
verdict: 'delegated',
|
|
447
|
+
reason:
|
|
448
|
+
'the layer picker declares `open`/`defaultOpen` and hands both straight to `DropdownMenu`, which reconciles them with the shared hook \u2014 so a controlled caller is honoured without this family reconciling anything itself.',
|
|
449
|
+
},
|
|
450
|
+
textarea: {
|
|
451
|
+
verdict: 'does-not-apply',
|
|
452
|
+
reason:
|
|
453
|
+
'the VALUE belongs to React Native\u2019s `TextInput`, which reconciles `value`/`defaultValue` itself; the only local state is the character count, which is why it is kept for the uncontrolled case alone.',
|
|
454
|
+
},
|
|
455
|
+
'input-otp': {
|
|
456
|
+
verdict: 'adaptation',
|
|
457
|
+
reason:
|
|
458
|
+
'reconciles `value`/`defaultValue` by hand (`const controlled = value !== undefined`) because it also CLEANS and pads the string to `length`, which the shared hook does not do.',
|
|
459
|
+
},
|
|
460
|
+
'chat-list': {
|
|
461
|
+
verdict: 'adaptation',
|
|
462
|
+
reason:
|
|
463
|
+
'the folder tabs reconcile `value`/`defaultValue` with their own `useState` seeded from the default, so a controlled caller\u2019s later change to `value` is honoured but the callback and the internal state can disagree.',
|
|
464
|
+
},
|
|
465
|
+
'chart-cards': {
|
|
466
|
+
verdict: 'adaptation',
|
|
467
|
+
reason:
|
|
468
|
+
'a card\u2019s range selector reconciles `range`/`defaultRange` by hand, in three cards, each with its own copy.',
|
|
469
|
+
},
|
|
470
|
+
'creator-studio': {
|
|
471
|
+
verdict: 'adaptation',
|
|
472
|
+
reason:
|
|
473
|
+
'the streams chart\u2019s metric picker keeps its own state seeded from `defaultMetric` instead of the shared hook.',
|
|
474
|
+
},
|
|
475
|
+
'queue-panel': {
|
|
476
|
+
verdict: 'adaptation',
|
|
477
|
+
reason: 'the panel\u2019s tab keeps its own state seeded from `defaultTab` instead of the shared hook.',
|
|
478
|
+
},
|
|
479
|
+
'track-list': {
|
|
480
|
+
verdict: 'adaptation',
|
|
481
|
+
reason:
|
|
482
|
+
'the selection set keeps its own state seeded from `defaultSelectedIds` instead of the shared hook, which matters more here because the value is an ARRAY and identity decides whether a caller\u2019s update lands.',
|
|
483
|
+
},
|
|
484
|
+
questionnaire: {
|
|
485
|
+
verdict: 'adaptation',
|
|
486
|
+
reason:
|
|
487
|
+
'the step and the answers are each reconciled by hand, which is two copies of the rule in one family.',
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// The rows
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
export interface FamilyRow {
|
|
497
|
+
family: string;
|
|
498
|
+
subpath: string | null;
|
|
499
|
+
rootBarrel: boolean;
|
|
500
|
+
webForked: boolean;
|
|
501
|
+
doc: string | null;
|
|
502
|
+
stories: boolean;
|
|
503
|
+
suites: number;
|
|
504
|
+
/** Contract key → verdict. `null` when the contract does not apply at all. */
|
|
505
|
+
verdicts: Record<string, Verdict | null>;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function matrix(): FamilyRow[] {
|
|
509
|
+
const subs = subpaths();
|
|
510
|
+
const barrel = rootBarrelFamilies();
|
|
511
|
+
const suites = suiteCounts();
|
|
512
|
+
const all = families();
|
|
513
|
+
|
|
514
|
+
return all.map((family) => {
|
|
515
|
+
const text = readAll(sourceFiles(family));
|
|
516
|
+
const verdicts: Record<string, Verdict | null> = {};
|
|
517
|
+
for (const contract of CONTRACTS) {
|
|
518
|
+
if (contract.reads(text)) verdicts[contract.key] = 'reads';
|
|
519
|
+
else if (!contract.applies(text)) verdicts[contract.key] = null;
|
|
520
|
+
else verdicts[contract.key] = CLASSIFICATION[contract.key]?.[family]?.verdict ?? 'unclassified';
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
family,
|
|
524
|
+
subpath: subs.get(family) ?? null,
|
|
525
|
+
rootBarrel: barrel.has(family),
|
|
526
|
+
webForked: existsSync(join(SRC, family, 'index.web.ts')),
|
|
527
|
+
doc: docStem(family),
|
|
528
|
+
stories: walk(join(SRC, family), /\.stories\.tsx?$/).length > 0,
|
|
529
|
+
suites: suites.get(family) ?? 0,
|
|
530
|
+
verdicts,
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The families a contract APPLIES to but that do not read it — what must be classified. */
|
|
536
|
+
export function unread(rows: FamilyRow[], key: string): string[] {
|
|
537
|
+
return rows
|
|
538
|
+
.filter((row) => row.verdicts[key] !== null && row.verdicts[key] !== 'reads')
|
|
539
|
+
.map((row) => row.family);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function withVerdict(rows: FamilyRow[], key: string, verdict: Verdict): string[] {
|
|
543
|
+
return rows.filter((row) => row.verdicts[key] === verdict).map((row) => row.family);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// The document
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
const CELL: Record<Verdict, string> = {
|
|
551
|
+
reads: 'reads',
|
|
552
|
+
delegated: 'delegated',
|
|
553
|
+
'does-not-apply': 'n/a¹',
|
|
554
|
+
adaptation: '**adaptation**',
|
|
555
|
+
unclassified: '**UNCLASSIFIED**',
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
export function renderAdoptionMatrix(): string {
|
|
559
|
+
const rows = matrix();
|
|
560
|
+
const published = rows.filter((row) => row.subpath !== null || row.rootBarrel);
|
|
561
|
+
const out: string[] = [];
|
|
562
|
+
|
|
563
|
+
out.push('---');
|
|
564
|
+
out.push('title: Adoption matrix');
|
|
565
|
+
out.push(
|
|
566
|
+
'description: Every family in Bloom against every composition contract that applies to it — derived from the source, with each shortfall named and justified.',
|
|
567
|
+
);
|
|
568
|
+
out.push('order: 3');
|
|
569
|
+
out.push('---');
|
|
570
|
+
out.push('');
|
|
571
|
+
out.push('{/* AUTO-GENERATED by scripts/generate-adoption-matrix.ts — do not edit. */}');
|
|
572
|
+
out.push(
|
|
573
|
+
'{/* `bun run generate:adoption-matrix`; asserted byte-identical by src/__tests__/adoption-matrix.test.ts. */}',
|
|
574
|
+
);
|
|
575
|
+
out.push('');
|
|
576
|
+
out.push('# Adoption matrix');
|
|
577
|
+
out.push('');
|
|
578
|
+
out.push(
|
|
579
|
+
'The contracts in [Composition](/docs/composition) are worth something only where they are read. This page is that measurement, and it is derived from the source on every change rather than written down once — a matrix maintained by hand is wrong the first time a family changes without its row being updated, and a stale row reads exactly like a fresh one.',
|
|
580
|
+
);
|
|
581
|
+
out.push('');
|
|
582
|
+
out.push('## What is measured');
|
|
583
|
+
out.push('');
|
|
584
|
+
for (const contract of CONTRACTS) {
|
|
585
|
+
out.push(`- **${contract.title}** — \`${contract.source}\`. ${contract.blurb}.`);
|
|
586
|
+
}
|
|
587
|
+
out.push('');
|
|
588
|
+
out.push(
|
|
589
|
+
'A contract that a family does not read is not automatically a failure, and this is where a matrix usually starts lying in one direction or the other. `Search` renders no control of its own — it composes `TextFieldInput` — so the field contract is not its to read. A menu row carrying `role="checkbox"` is a menu item, not a form field. Every family a contract applies to and that does not read it is therefore classified, with a reason, in `src/__tests__/support/adoption-matrix.ts`:',
|
|
590
|
+
);
|
|
591
|
+
out.push('');
|
|
592
|
+
out.push('| verdict | means |');
|
|
593
|
+
out.push('| --- | --- |');
|
|
594
|
+
out.push('| `reads` | the family imports the contract and applies it itself |');
|
|
595
|
+
out.push('| `delegated` | it hands the decision to a child that reads it |');
|
|
596
|
+
out.push('| `n/a¹` | the derivation matched something that is not the contract\u2019s subject — the reason is below |');
|
|
597
|
+
out.push('| `**adaptation**` | a real remaining gap, named rather than hidden |');
|
|
598
|
+
out.push('| blank | the contract does not apply: the family renders nothing it governs |');
|
|
599
|
+
out.push('');
|
|
600
|
+
out.push(
|
|
601
|
+
'The classification is an EQUALITY in `src/__tests__/adoption-matrix.test.ts`, not a floor: a family that starts matching a contract and is not classified renders as `**UNCLASSIFIED**` and fails the gate, and a classification whose family no longer matches fails it too.',
|
|
602
|
+
);
|
|
603
|
+
out.push('');
|
|
604
|
+
|
|
605
|
+
out.push('## Totals');
|
|
606
|
+
out.push('');
|
|
607
|
+
out.push(
|
|
608
|
+
`- **${rows.length} families** in \`src/\`, **${published.length}** published (a subpath, the root barrel, or both). **${rows.filter((r) => r.webForked).length}** carry a web fork.`,
|
|
609
|
+
);
|
|
610
|
+
out.push(
|
|
611
|
+
`- Docs, stories and suites are gated as a standard by \`src/__tests__/family-coverage.test.ts\`; the per-family counts are in the table below.`,
|
|
612
|
+
);
|
|
613
|
+
out.push('');
|
|
614
|
+
out.push('| contract | applies to | reads | delegated | n/a¹ | adaptation |');
|
|
615
|
+
out.push('| --- | --- | --- | --- | --- | --- |');
|
|
616
|
+
for (const contract of CONTRACTS) {
|
|
617
|
+
const applies = rows.filter((row) => row.verdicts[contract.key] !== null).length;
|
|
618
|
+
out.push(
|
|
619
|
+
`| ${contract.title} | ${applies} | ${withVerdict(rows, contract.key, 'reads').length} | ${
|
|
620
|
+
withVerdict(rows, contract.key, 'delegated').length
|
|
621
|
+
} | ${withVerdict(rows, contract.key, 'does-not-apply').length} | ${
|
|
622
|
+
withVerdict(rows, contract.key, 'adaptation').length
|
|
623
|
+
} |`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
out.push('');
|
|
627
|
+
|
|
628
|
+
out.push('## What remains — the named adaptations');
|
|
629
|
+
out.push('');
|
|
630
|
+
const anyAdaptation = CONTRACTS.some((c) => withVerdict(rows, c.key, 'adaptation').length > 0);
|
|
631
|
+
if (!anyAdaptation) out.push('None.');
|
|
632
|
+
for (const contract of CONTRACTS) {
|
|
633
|
+
const list = withVerdict(rows, contract.key, 'adaptation');
|
|
634
|
+
if (list.length === 0) continue;
|
|
635
|
+
out.push(`### ${contract.title}`);
|
|
636
|
+
out.push('');
|
|
637
|
+
for (const family of list) {
|
|
638
|
+
out.push(`- \`${family}\` — ${CLASSIFICATION[contract.key]![family]!.reason}`);
|
|
639
|
+
}
|
|
640
|
+
out.push('');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
out.push('## ¹ Why a match is not a subject');
|
|
644
|
+
out.push('');
|
|
645
|
+
for (const contract of CONTRACTS) {
|
|
646
|
+
const list = [
|
|
647
|
+
...withVerdict(rows, contract.key, 'does-not-apply'),
|
|
648
|
+
...withVerdict(rows, contract.key, 'delegated'),
|
|
649
|
+
].sort();
|
|
650
|
+
if (list.length === 0) continue;
|
|
651
|
+
out.push(`### ${contract.title}`);
|
|
652
|
+
out.push('');
|
|
653
|
+
for (const family of list) {
|
|
654
|
+
const entry = CLASSIFICATION[contract.key]![family]!;
|
|
655
|
+
out.push(`- \`${family}\` (${entry.verdict}) — ${entry.reason}`);
|
|
656
|
+
}
|
|
657
|
+
out.push('');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
out.push('## Every family');
|
|
661
|
+
out.push('');
|
|
662
|
+
const header = ['family', 'import', 'platform', 'doc', 'story', 'suites', ...CONTRACTS.map((c) => c.title)];
|
|
663
|
+
out.push(`| ${header.join(' | ')} |`);
|
|
664
|
+
out.push(`| ${header.map(() => '---').join(' | ')} |`);
|
|
665
|
+
for (const row of rows) {
|
|
666
|
+
const importPath = row.subpath
|
|
667
|
+
? `\`@oxy.so/bloom${row.subpath.slice(1)}\`${row.rootBarrel ? ' + barrel' : ''}`
|
|
668
|
+
: row.rootBarrel
|
|
669
|
+
? 'barrel only'
|
|
670
|
+
: 'internal';
|
|
671
|
+
const cells = CONTRACTS.map((c) => {
|
|
672
|
+
const verdict = row.verdicts[c.key];
|
|
673
|
+
return verdict === null || verdict === undefined ? '' : CELL[verdict];
|
|
674
|
+
});
|
|
675
|
+
out.push(
|
|
676
|
+
`| \`${row.family}\` | ${importPath} | ${row.webForked ? 'web fork' : 'universal'} | ${
|
|
677
|
+
row.doc ? `\`${row.doc}.mdx\`` : '—'
|
|
678
|
+
} | ${row.stories ? 'yes' : '—'} | ${row.suites} | ${cells.join(' | ')} |`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
out.push('');
|
|
682
|
+
return out.join('\n');
|
|
683
|
+
}
|