@krapwoo/ds-viewer 0.4.9 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -8
- package/dist/cli/doctor.js +92 -5
- package/dist/cli/explain.js +11 -20
- package/dist/cli/main.js +11 -2
- package/dist/cli/render.js +338 -0
- package/dist/cli/staticPage.js +27 -4
- package/dist/native/catalog/comparison.js +69 -30
- package/dist/native/catalog/tokens.js +8 -6
- package/native/catalog/CatalogButton.tsx +47 -0
- package/native/catalog/CatalogShell.tsx +29 -11
- package/native/catalog/CatalogSidebar.tsx +18 -6
- package/native/catalog/ComparisonGrid.tsx +9 -8
- package/native/catalog/ComparisonGroups.tsx +6 -5
- package/native/catalog/ComparisonList.tsx +12 -3
- package/native/catalog/OverlayDemo.tsx +52 -0
- package/native/catalog/PhoneScreen.tsx +51 -0
- package/native/catalog/SectionBlock.tsx +16 -11
- package/native/catalog/SpecimenSurface.tsx +4 -4
- package/native/catalog/TokenLayouts.tsx +14 -3
- package/native/catalog/UpdatePanel.tsx +4 -4
- package/native/catalog/comparison.ts +77 -33
- package/native/catalog/index.ts +4 -1
- package/native/catalog/overlayDemoState.ts +8 -0
- package/native/catalog/pageApi.ts +3 -0
- package/native/catalog/specimenSurfaceStyle.ts +11 -0
- package/native/catalog/tokens.ts +8 -6
- package/native/catalog/types.ts +10 -3
- package/package.json +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +38 -48
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +16 -16
- package/starter-kit/components/Toast/Toast.catalog.tsx +9 -10
- package/starter-kit/pages/ReportIssue.catalog.tsx +17 -13
- package/starter-kit/pages/SavedTrips.catalog.tsx +114 -119
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// cli/render.ts — `doctor --render`: open every catalog page in a headless browser and report what
|
|
2
|
+
// only a real render shows (console errors, pages that fail to render, the viewer's own [Catalog]
|
|
3
|
+
// layout warnings). The browser is resolved from the project; ds-viewer never installs one.
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
const OVERFLOW = /is \d+px wide in a \d+px cell/;
|
|
9
|
+
/**
|
|
10
|
+
* Turns one page's console output into doctor findings. Console errors and a missing heading are
|
|
11
|
+
* errors. The viewer's own `[Catalog]` warnings are warnings: `render-layout` for an example that
|
|
12
|
+
* overflows its cell, `render-catalog-warning` for anything else. Other console noise (React Native
|
|
13
|
+
* web deprecations, logs) is ignored. `seen` is shared across the whole run, so a warning the
|
|
14
|
+
* catalog logs on every page (a duplicate id, an unknown `composedOf` name) is reported once.
|
|
15
|
+
*/
|
|
16
|
+
export function issuesFromPage(page, messages, hasHeading, seen = new Set()) {
|
|
17
|
+
const ref = { page: page.id, ...(page.file ? { file: page.file } : {}) };
|
|
18
|
+
const issues = [];
|
|
19
|
+
if (!hasHeading) {
|
|
20
|
+
issues.push({ id: 'render-no-heading', severity: 'error', ...ref, message: 'The page rendered without its heading, so it probably failed to render.', fix: 'Open the page in `ds-viewer dev` and check the browser console.' });
|
|
21
|
+
}
|
|
22
|
+
const pageErrors = new Set();
|
|
23
|
+
for (const m of messages) {
|
|
24
|
+
const first = m.text.split('\n')[0];
|
|
25
|
+
if (m.type === 'error' || m.type === 'pageerror') {
|
|
26
|
+
if (pageErrors.has(first))
|
|
27
|
+
continue;
|
|
28
|
+
pageErrors.add(first);
|
|
29
|
+
issues.push({ id: 'render-error', severity: 'error', ...ref, message: `Console error while rendering: ${first}`, fix: 'Open the page in `ds-viewer dev` and fix the error in the browser console.' });
|
|
30
|
+
}
|
|
31
|
+
else if ((m.type === 'warn' || m.type === 'warning') && first.startsWith('[Catalog] ')) {
|
|
32
|
+
const text = first.slice('[Catalog] '.length);
|
|
33
|
+
if (seen.has(text))
|
|
34
|
+
continue;
|
|
35
|
+
seen.add(text);
|
|
36
|
+
issues.push(OVERFLOW.test(text)
|
|
37
|
+
? { id: 'render-layout', severity: 'warning', ...ref, message: text, fix: 'Give the page a wider specimenSize; see the README page guide.' }
|
|
38
|
+
: { id: 'render-catalog-warning', severity: 'warning', ...ref, message: text, fix: 'Follow the message; the static doctor rule of the same name has the details.' });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Runs in the browser: finds any two examples in the same list card whose specimens render
|
|
45
|
+
* identically. A variant's name is a claim that something differs; two cells that render the same
|
|
46
|
+
* teach the reader nothing and usually mean the difference needs a wider cell (a column count in a
|
|
47
|
+
* cell too narrow to show it) or was never wired up.
|
|
48
|
+
*
|
|
49
|
+
* The fingerprint covers each specimen's whole subtree — tag, box size, colours, radius and text —
|
|
50
|
+
* but never the caption strip that carries the variant's own name, which always differs. Colour is
|
|
51
|
+
* part of it, so a "default vs disabled" pair that differs only in tone is not reported.
|
|
52
|
+
*/
|
|
53
|
+
export const IDENTICAL_VARIANTS_SCRIPT = `(() => {
|
|
54
|
+
const doc = document;
|
|
55
|
+
const fingerprint = (root) => {
|
|
56
|
+
const out = [];
|
|
57
|
+
const base = root.getBoundingClientRect();
|
|
58
|
+
const walk = (el) => {
|
|
59
|
+
// PhoneFrame renders its example into a same-origin child document, so without reaching in
|
|
60
|
+
// every phone looks like an identical empty box.
|
|
61
|
+
if (el.tagName === 'IFRAME') {
|
|
62
|
+
try {
|
|
63
|
+
const body = el.contentDocument && el.contentDocument.body;
|
|
64
|
+
if (body) { out.push('IFRAME'); walk(body); return; }
|
|
65
|
+
} catch (error) { out.push('IFRAME|unreadable'); return; }
|
|
66
|
+
}
|
|
67
|
+
const box = el.getBoundingClientRect();
|
|
68
|
+
const style = getComputedStyle(el);
|
|
69
|
+
out.push([
|
|
70
|
+
el.tagName,
|
|
71
|
+
Math.round(box.width), Math.round(box.height),
|
|
72
|
+
Math.round(box.left - base.left), Math.round(box.top - base.top),
|
|
73
|
+
style.color, style.backgroundColor, style.borderColor, style.borderRadius,
|
|
74
|
+
style.fontSize, style.fontWeight, style.opacity, style.visibility,
|
|
75
|
+
// An icon page's variants are same-sized SVGs that differ only in geometry; a card page's
|
|
76
|
+
// in shadow; a text page's in style, decoration or alignment.
|
|
77
|
+
style.fill, style.stroke, style.boxShadow, style.fontStyle, style.textDecorationLine, style.textAlign,
|
|
78
|
+
el.getAttribute && (el.getAttribute('d') || el.getAttribute('points') || ''),
|
|
79
|
+
el.currentSrc || (el.getAttribute && el.getAttribute('src')) || '',
|
|
80
|
+
el.children.length === 0 ? (el.textContent || '') : '',
|
|
81
|
+
// A field's visible text is its value, not its textContent, which is always empty.
|
|
82
|
+
el.value === undefined ? '' : String(el.value),
|
|
83
|
+
el.placeholder || '', el.disabled ? 'disabled' : '',
|
|
84
|
+
].join('|'));
|
|
85
|
+
for (const child of el.children) walk(child);
|
|
86
|
+
};
|
|
87
|
+
walk(root);
|
|
88
|
+
return out.join('\\n');
|
|
89
|
+
};
|
|
90
|
+
const pairs = [];
|
|
91
|
+
for (const card of doc.querySelectorAll('[role=list][aria-label]')) {
|
|
92
|
+
// ComparisonList and ComparisonGroups label their card "<page id>: <block title>". Anything
|
|
93
|
+
// else with role=list is a consumer's own component inside a specimen, not a card of examples.
|
|
94
|
+
if (!/^[^:]+: .+/.test(card.getAttribute('aria-label') || '')) continue;
|
|
95
|
+
const cells = [...card.querySelectorAll('[role=listitem]')].filter((c) => c.closest('[role=list]') === card);
|
|
96
|
+
const byPrint = new Map();
|
|
97
|
+
for (const cell of cells) {
|
|
98
|
+
// children[0] is the caption strip (the variant's own name); the specimen follows it.
|
|
99
|
+
const specimen = cell.children.length > 1 ? cell.children[1] : cell.children[0];
|
|
100
|
+
if (!specimen) continue;
|
|
101
|
+
const name = (cell.children[0] ? cell.children[0].textContent : '') || '';
|
|
102
|
+
const print = fingerprint(specimen);
|
|
103
|
+
if (!print.trim()) continue;
|
|
104
|
+
const first = byPrint.get(print);
|
|
105
|
+
if (first === undefined) byPrint.set(print, name);
|
|
106
|
+
else pairs.push({ card: card.getAttribute('aria-label') || '', names: [first, name] });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return pairs;
|
|
110
|
+
})()`;
|
|
111
|
+
/** Turns the browser's findings into doctor issues, one per identical pair. */
|
|
112
|
+
export function identicalVariantIssues(page, pairs) {
|
|
113
|
+
const ref = { page: page.id, ...(page.file ? { file: page.file } : {}) };
|
|
114
|
+
return pairs.map((pair) => ({
|
|
115
|
+
id: 'identical-variants',
|
|
116
|
+
severity: 'warning',
|
|
117
|
+
...ref,
|
|
118
|
+
message: `"${pair.names[0]}" and "${pair.names[1]}" render identically in ${pair.card || 'the same card'}.`,
|
|
119
|
+
fix: "Make the difference visible — a wider specimenSize when the cell is too narrow to show it, or the prop the name claims — or drop one of the two examples.",
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
/** The project's own puppeteer, or `undefined` when it isn't installed. */
|
|
123
|
+
export function loadProjectPuppeteer(projectRoot) {
|
|
124
|
+
try {
|
|
125
|
+
return createRequire(path.join(path.resolve(projectRoot), 'package.json'))('puppeteer');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export const RENDER_UNAVAILABLE = {
|
|
132
|
+
id: 'render-unavailable',
|
|
133
|
+
severity: 'error',
|
|
134
|
+
message: "`doctor --render` needs a headless browser, and puppeteer isn't installed in this project.",
|
|
135
|
+
fix: 'npm install --save-dev puppeteer (then run doctor --render again).',
|
|
136
|
+
};
|
|
137
|
+
const DEV_START_TIMEOUT_MS = 120_000;
|
|
138
|
+
const FIRST_LOAD_TIMEOUT_MS = 300_000;
|
|
139
|
+
const PAGE_TIMEOUT_MS = 120_000;
|
|
140
|
+
const SETTLE_MS = 900;
|
|
141
|
+
/** Stops `dev` and everything it started (Expo/Metro): the whole process tree on Windows, the
|
|
142
|
+
* whole process group elsewhere (Expo stops through dev's own signal handler). Releases the pipes
|
|
143
|
+
* so this process can exit. `os` is injectable for tests. */
|
|
144
|
+
export function stopDev(child, os = {}) {
|
|
145
|
+
const platform = os.platform ?? process.platform;
|
|
146
|
+
if (child.pid) {
|
|
147
|
+
try {
|
|
148
|
+
if (platform === 'win32')
|
|
149
|
+
(os.spawnSync ?? spawnSync)('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
150
|
+
else
|
|
151
|
+
(os.kill ?? ((pid, signal) => process.kill(pid, signal)))(-child.pid, 'SIGTERM');
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Already exited.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
child.stdout?.destroy();
|
|
158
|
+
child.stderr?.destroy();
|
|
159
|
+
child.unref();
|
|
160
|
+
}
|
|
161
|
+
/** The last few KB of dev's output, so a failed start can say why. */
|
|
162
|
+
function outputTail() {
|
|
163
|
+
let tail = '';
|
|
164
|
+
return {
|
|
165
|
+
push: (data) => {
|
|
166
|
+
tail = (tail + String(data)).slice(-4000);
|
|
167
|
+
},
|
|
168
|
+
text: () => {
|
|
169
|
+
const lines = tail.trim().split('\n').slice(-8).join('\n');
|
|
170
|
+
return lines ? `\n${lines}` : '';
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Starts the project's own `dev` in its own process group. `onSpawn` receives the child at once,
|
|
175
|
+
* so a signal during startup can still stop it. */
|
|
176
|
+
function startDev(projectRoot, onSpawn) {
|
|
177
|
+
const main = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const child = spawn(process.execPath, [main, 'dev'], {
|
|
180
|
+
cwd: projectRoot,
|
|
181
|
+
env: { ...process.env, CI: '1', DS_VIEWER_NO_UPDATE_CHECK: '1' },
|
|
182
|
+
detached: process.platform !== 'win32',
|
|
183
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
184
|
+
});
|
|
185
|
+
onSpawn(child);
|
|
186
|
+
const output = outputTail();
|
|
187
|
+
let done = false;
|
|
188
|
+
const fail = (message) => {
|
|
189
|
+
if (done)
|
|
190
|
+
return;
|
|
191
|
+
done = true;
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
stopDev(child);
|
|
194
|
+
reject(new Error(`${message}${output.text()}`));
|
|
195
|
+
};
|
|
196
|
+
const timer = setTimeout(() => fail('Timed out waiting for ds-viewer dev to start.'), DEV_START_TIMEOUT_MS);
|
|
197
|
+
// Read continuously (so Metro's output never fills the pipe) and keep the tail for errors.
|
|
198
|
+
child.stderr?.on('data', output.push);
|
|
199
|
+
child.stdout?.on('data', (data) => {
|
|
200
|
+
output.push(data);
|
|
201
|
+
const match = String(data).match(/Starting the catalog at http:\/\/localhost:(\d+)/);
|
|
202
|
+
if (match && !done) {
|
|
203
|
+
done = true;
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
resolve({ child, port: Number(match[1]) });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
child.on('exit', (code) => fail(`ds-viewer dev exited early (code ${code}).`));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
|
212
|
+
/** A sidebar href's page id; a malformed escape falls back to the raw text (like `idFromHash`). */
|
|
213
|
+
function idFromHref(href) {
|
|
214
|
+
const raw = href.slice(1);
|
|
215
|
+
try {
|
|
216
|
+
return decodeURIComponent(raw);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return raw;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Opens every sidebar page and returns its findings. `files` maps page ids to project-relative
|
|
223
|
+
* page files, for the findings' `file`. `log` receives progress lines (stderr in the CLI). `deps`
|
|
224
|
+
* replaces the browser, dev process control and exit for tests. */
|
|
225
|
+
export async function renderCheck(projectRoot, options = {}) {
|
|
226
|
+
projectRoot = path.resolve(projectRoot);
|
|
227
|
+
const deps = options.deps ?? {};
|
|
228
|
+
const puppeteer = (deps.puppeteer ?? loadProjectPuppeteer(projectRoot));
|
|
229
|
+
if (!puppeteer)
|
|
230
|
+
return [RENDER_UNAVAILABLE];
|
|
231
|
+
const stop = deps.stopDev ?? ((child) => stopDev(child));
|
|
232
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
233
|
+
const log = options.log ?? (() => { });
|
|
234
|
+
// Set as soon as dev is spawned, before it's ready, so a signal during startup still stops it.
|
|
235
|
+
let devChild;
|
|
236
|
+
let port = 0;
|
|
237
|
+
let browser;
|
|
238
|
+
// Ctrl-C or a CI cancel must still stop Metro: `dev` runs in its own process group, so the
|
|
239
|
+
// terminal's signal never reaches it. Registered before the browser launches, so it runs ahead
|
|
240
|
+
// of puppeteer's own handler.
|
|
241
|
+
const handlers = Object.keys(SIGNAL_EXIT_CODES).map((signal) => {
|
|
242
|
+
const handler = () => {
|
|
243
|
+
if (devChild)
|
|
244
|
+
stop(devChild);
|
|
245
|
+
exit(SIGNAL_EXIT_CODES[signal]);
|
|
246
|
+
};
|
|
247
|
+
process.on(signal, handler);
|
|
248
|
+
return [signal, handler];
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
// The browser first: a missing browser download shouldn't start Metro for nothing.
|
|
252
|
+
try {
|
|
253
|
+
browser = await puppeteer.launch({ headless: true });
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
return [{ ...RENDER_UNAVAILABLE, message: `Couldn't start the headless browser: ${error.message.split('\n')[0]}`, fix: 'npx puppeteer browsers install chrome' }];
|
|
257
|
+
}
|
|
258
|
+
({ port } = await (deps.startDev ?? startDev)(projectRoot, (child) => {
|
|
259
|
+
devChild = child;
|
|
260
|
+
}));
|
|
261
|
+
const home = await browser.newPage();
|
|
262
|
+
const deadline = Date.now() + FIRST_LOAD_TIMEOUT_MS;
|
|
263
|
+
for (;;) {
|
|
264
|
+
try {
|
|
265
|
+
await home.goto(`http://localhost:${port}`, { waitUntil: 'networkidle0', timeout: FIRST_LOAD_TIMEOUT_MS });
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (!String(error).includes('ERR_CONNECTION_REFUSED') || Date.now() > deadline)
|
|
270
|
+
throw error;
|
|
271
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Runs in the browser; `document` is typed loosely because the CLI compiles without DOM types.
|
|
275
|
+
const hrefs = await home.evaluate(() => {
|
|
276
|
+
const doc = globalThis.document;
|
|
277
|
+
return [...new Set(Array.from(doc.querySelectorAll('a[href^="#"]')).map((a) => a.getAttribute('href') ?? ''))];
|
|
278
|
+
});
|
|
279
|
+
await home.close();
|
|
280
|
+
const ids = hrefs.map(idFromHref).filter(Boolean);
|
|
281
|
+
if (ids.length === 0) {
|
|
282
|
+
return [{ id: 'render-failed', severity: 'error', message: 'The catalog rendered no sidebar links, so it probably failed to render at all.', fix: 'Run `ds-viewer dev` and check the browser console.' }];
|
|
283
|
+
}
|
|
284
|
+
log(`Rendering ${ids.length} pages…`);
|
|
285
|
+
const seen = new Set();
|
|
286
|
+
const issues = [];
|
|
287
|
+
for (const id of ids) {
|
|
288
|
+
// One page failing (a navigation timeout) is that page's finding; the others still run.
|
|
289
|
+
let page;
|
|
290
|
+
try {
|
|
291
|
+
page = await browser.newPage();
|
|
292
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
293
|
+
const messages = [];
|
|
294
|
+
page.on('console', (m) => messages.push({ type: m.type(), text: m.text() }));
|
|
295
|
+
page.on('pageerror', (e) => messages.push({ type: 'pageerror', text: String(e) }));
|
|
296
|
+
await page.goto(`http://localhost:${port}/#${encodeURIComponent(id)}`, { waitUntil: 'networkidle0', timeout: PAGE_TIMEOUT_MS });
|
|
297
|
+
await new Promise((r) => setTimeout(r, options.settleMs ?? SETTLE_MS));
|
|
298
|
+
const hasHeading = await page.evaluate(() => {
|
|
299
|
+
const doc = globalThis.document;
|
|
300
|
+
return Boolean(doc.querySelector('[role="heading"]')?.textContent?.trim());
|
|
301
|
+
});
|
|
302
|
+
issues.push(...issuesFromPage({ id, file: options.files?.get(id) }, messages, hasHeading, seen));
|
|
303
|
+
// Advisory: a script failure (a detached child document mid-navigation, an element whose
|
|
304
|
+
// `value` getter throws) must not become this page's render-error, so it has its own catch
|
|
305
|
+
// rather than falling into the one below.
|
|
306
|
+
let identical;
|
|
307
|
+
try {
|
|
308
|
+
identical = (await page.evaluate(IDENTICAL_VARIANTS_SCRIPT));
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
identical = undefined;
|
|
312
|
+
}
|
|
313
|
+
if (Array.isArray(identical))
|
|
314
|
+
issues.push(...identicalVariantIssues({ id, file: options.files?.get(id) }, identical));
|
|
315
|
+
await page.close();
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
// A stuck page's tab is closed, so it doesn't keep loading next to the remaining pages.
|
|
319
|
+
await page?.close().catch(() => { });
|
|
320
|
+
const file = options.files?.get(id);
|
|
321
|
+
issues.push({ id: 'render-error', severity: 'error', page: id, ...(file ? { file } : {}), message: `Couldn't open the page: ${error.message.split('\n')[0]}`, fix: 'Open the page in `ds-viewer dev`; it may be very slow to load or stuck.' });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
log(`Checked ${ids.length} pages.`);
|
|
325
|
+
return issues;
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
return [{ id: 'render-failed', severity: 'error', message: `Couldn't render the catalog: ${error.message}`, fix: "Run `ds-viewer dev` to see why the catalog doesn't start." }];
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
for (const [signal, handler] of handlers)
|
|
332
|
+
process.off(signal, handler);
|
|
333
|
+
if (browser)
|
|
334
|
+
await browser.close().catch(() => { });
|
|
335
|
+
if (devChild)
|
|
336
|
+
stop(devChild);
|
|
337
|
+
}
|
|
338
|
+
}
|
package/dist/cli/staticPage.js
CHANGED
|
@@ -42,6 +42,7 @@ function readStaticPage(file) {
|
|
|
42
42
|
}
|
|
43
43
|
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
|
|
44
44
|
const imports = importedNames(sourceFile, '@krapwoo/ds-viewer');
|
|
45
|
+
const viewerImports = [...new Set(imports.values())];
|
|
45
46
|
const defineCatalogPageNames = localNamesFor(imports, 'defineCatalogPage');
|
|
46
47
|
const gridNames = localNamesFor(imports, 'grid');
|
|
47
48
|
const call = defaultExportCall(sourceFile);
|
|
@@ -49,13 +50,13 @@ function readStaticPage(file) {
|
|
|
49
50
|
// Not a recognized `defineCatalogPage(...)` default export — e.g. a hand-written object, or a
|
|
50
51
|
// shadowed/renamed import this reader can't trace. Not a parse error: the file is valid
|
|
51
52
|
// TypeScript, just not literal-checkable.
|
|
52
|
-
return { file, checkable: false };
|
|
53
|
+
return { file, checkable: false, viewerImports };
|
|
53
54
|
}
|
|
54
55
|
const consts = topLevelConsts(sourceFile);
|
|
55
56
|
const pageObject = resolve(call.arguments[0], consts, new Set());
|
|
56
57
|
if (!ts.isObjectLiteralExpression(pageObject))
|
|
57
|
-
return { file, checkable: false };
|
|
58
|
-
const page = { file, checkable: true };
|
|
58
|
+
return { file, checkable: false, viewerImports };
|
|
59
|
+
const page = { file, checkable: true, viewerImports };
|
|
59
60
|
const idProp = findProp(pageObject, 'id');
|
|
60
61
|
const componentProp = findProp(pageObject, 'component');
|
|
61
62
|
const groupProp = findProp(pageObject, 'group');
|
|
@@ -169,6 +170,23 @@ function readStaticPage(file) {
|
|
|
169
170
|
page.intentionalStaticPreviewReason = stringLiteral(resolve(reasonProp.initializer, consts, new Set()));
|
|
170
171
|
}
|
|
171
172
|
}
|
|
173
|
+
const internalPropsProp = findProp(pageObject, 'internalProps');
|
|
174
|
+
if (internalPropsProp) {
|
|
175
|
+
const resolved = resolve(internalPropsProp.initializer, consts, new Set());
|
|
176
|
+
if (ts.isObjectLiteralExpression(resolved)) {
|
|
177
|
+
const names = [];
|
|
178
|
+
for (const prop of resolved.properties) {
|
|
179
|
+
if (!ts.isPropertyAssignment(prop))
|
|
180
|
+
continue;
|
|
181
|
+
const name = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : undefined;
|
|
182
|
+
const reason = stringLiteral(resolve(prop.initializer, consts, new Set()));
|
|
183
|
+
if (name && reason)
|
|
184
|
+
names.push(name);
|
|
185
|
+
}
|
|
186
|
+
if (names.length > 0)
|
|
187
|
+
page.internalProps = names;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
172
190
|
return page;
|
|
173
191
|
}
|
|
174
192
|
/** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
|
|
@@ -193,16 +211,21 @@ function readComposedOf(node, consts) {
|
|
|
193
211
|
return entries;
|
|
194
212
|
}
|
|
195
213
|
/** Local name -> imported name, for every named import from `moduleSpecifier`. */
|
|
196
|
-
function importedNames(sourceFile, moduleSpecifier) {
|
|
214
|
+
export function importedNames(sourceFile, moduleSpecifier) {
|
|
197
215
|
const names = new Map();
|
|
198
216
|
for (const statement of sourceFile.statements) {
|
|
199
217
|
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== moduleSpecifier) {
|
|
200
218
|
continue;
|
|
201
219
|
}
|
|
220
|
+
// Type-only imports bring no runtime value (`import type { X }`, `import { type X }`).
|
|
221
|
+
if (statement.importClause?.isTypeOnly)
|
|
222
|
+
continue;
|
|
202
223
|
const bindings = statement.importClause?.namedBindings;
|
|
203
224
|
if (!bindings || !ts.isNamedImports(bindings))
|
|
204
225
|
continue;
|
|
205
226
|
for (const element of bindings.elements) {
|
|
227
|
+
if (element.isTypeOnly)
|
|
228
|
+
continue;
|
|
206
229
|
names.set(element.name.text, element.propertyName ? element.propertyName.text : element.name.text);
|
|
207
230
|
}
|
|
208
231
|
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* content width is a 1280px viewport minus the 264px sidebar and 32px side padding. */
|
|
3
3
|
export const MATRIX_LAYOUT = {
|
|
4
4
|
rowHeaderWidth: 120,
|
|
5
|
-
columnMaxWidth: 402,
|
|
6
5
|
cellPadding: 16,
|
|
7
6
|
rowMinHeight: 150,
|
|
8
7
|
laptopContentWidth: 952,
|
|
@@ -21,8 +20,9 @@ export const REFERENCE_MIN_COLUMN_WIDTH = 280;
|
|
|
21
20
|
/** Gap between reference columns (CATALOG_SPACE['2xl'], the same token the card's columns already
|
|
22
21
|
* use). */
|
|
23
22
|
export const REFERENCE_COLUMN_GAP = 32;
|
|
24
|
-
/** Smallest column or cell width per specimen size.
|
|
25
|
-
|
|
23
|
+
/** Smallest column or cell width per specimen size. `wide` is phone width as a minimum, not a
|
|
24
|
+
* maximum: cells share their container equally above it. */
|
|
25
|
+
export const COLUMN_MIN_WIDTH = { compact: 160, regular: 240, wide: 402, page: 512 };
|
|
26
26
|
/** `Array.isArray`'s built-in type guard narrows to a mutable `any[]`, which a `readonly T[]`
|
|
27
27
|
* union member is never assignable to — so the `else` branch of `Array.isArray(axis) ? ... :
|
|
28
28
|
* ...` keeps `axis`'s full union type instead of narrowing away the array member. This explicit
|
|
@@ -71,15 +71,15 @@ export function grid(rowLabel, columnLabel, rows, columns, cell, size) {
|
|
|
71
71
|
export function gridColumnLimit(size) {
|
|
72
72
|
return Math.floor((MATRIX_LAYOUT.laptopContentWidth - MATRIX_LAYOUT.rowHeaderWidth) / COLUMN_MIN_WIDTH[size]);
|
|
73
73
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
};
|
|
74
|
+
/** The narrowest a grid card may be: the row header plus every column at its minimum. A card wider
|
|
75
|
+
* than its container scrolls inside itself; above this width the columns share the container
|
|
76
|
+
* equally, because the phone-width cap belongs to the specimen inside a cell, not to the column. */
|
|
77
|
+
export function gridMinWidth(columnCount, size) {
|
|
78
|
+
return MATRIX_LAYOUT.rowHeaderWidth + Math.max(0, columnCount) * COLUMN_MIN_WIDTH[size];
|
|
80
79
|
}
|
|
81
80
|
/** Balanced wrapping for a one-axis list inside a single bordered card (1px outer border). Each
|
|
82
|
-
* cell's 1px divider sits inside its own width
|
|
81
|
+
* cell's 1px divider sits inside its own width. Cells share the container equally — phone width
|
|
82
|
+
* caps the specimen inside a cell, not the cell. Uses as few
|
|
83
83
|
* rows as fit, then spreads items evenly across them so a short last row leaves as little empty
|
|
84
84
|
* space as possible. `fillers` blank cells complete the last row. `columnCap` (from
|
|
85
85
|
* `VariantSlot.maxColumns`) only ever lowers the natural, width-based column count — a narrow
|
|
@@ -96,7 +96,7 @@ export function listGeometry(itemCount, availableWidth, size, columnCap) {
|
|
|
96
96
|
const rows = Math.ceil(itemCount / maxColumns);
|
|
97
97
|
const columns = Math.ceil(itemCount / rows);
|
|
98
98
|
const fitted = Math.floor(inner / columns);
|
|
99
|
-
const cellWidth =
|
|
99
|
+
const cellWidth = Math.max(min, fitted);
|
|
100
100
|
return {
|
|
101
101
|
columns,
|
|
102
102
|
rows,
|
|
@@ -198,10 +198,11 @@ function slotColumnCap(slot) {
|
|
|
198
198
|
return { maxColumns: slot.maxColumns };
|
|
199
199
|
return slot.itemsFill ? { maxColumns: FILLED_SLOT_MAX_COLUMNS } : {};
|
|
200
200
|
}
|
|
201
|
-
function slotItems(slot, from) {
|
|
201
|
+
function slotItems(slot, from, fillByDefault = false) {
|
|
202
202
|
// Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
|
|
203
203
|
// `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
|
|
204
|
-
|
|
204
|
+
// Page-width examples (layouts wider than a phone) fill their cell unless they say otherwise.
|
|
205
|
+
return slot.items.map((item) => ({ key: item.key, slot: from, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill ?? (fillByDefault || undefined), surface: item.surface, align: item.align }));
|
|
205
206
|
}
|
|
206
207
|
/** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
|
|
207
208
|
function previewWidths(requested, fallback) {
|
|
@@ -269,12 +270,12 @@ export function presentationBlocks(def, options = {}) {
|
|
|
269
270
|
kind: 'list',
|
|
270
271
|
title: 'Variants',
|
|
271
272
|
size: slotSize(def, def.variants),
|
|
272
|
-
items: slotItems(def.variants, 'variants'),
|
|
273
|
+
items: slotItems(def.variants, 'variants', def.specimenSize === 'page'),
|
|
273
274
|
...slotColumnCap(def.variants),
|
|
274
275
|
});
|
|
275
276
|
}
|
|
276
277
|
else if (def.render) {
|
|
277
|
-
blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
|
|
278
|
+
blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths), ...(def.previewLayout ? { layout: def.previewLayout } : {}) });
|
|
278
279
|
}
|
|
279
280
|
}
|
|
280
281
|
if (states) {
|
|
@@ -283,7 +284,7 @@ export function presentationBlocks(def, options = {}) {
|
|
|
283
284
|
kind: 'list',
|
|
284
285
|
title: secondary ? 'Other configurations' : 'States / configurations',
|
|
285
286
|
size: slotSize(def, states),
|
|
286
|
-
items: slotItems(states, 'states'),
|
|
287
|
+
items: slotItems(states, 'states', def.specimenSize === 'page'),
|
|
287
288
|
...slotColumnCap(states),
|
|
288
289
|
});
|
|
289
290
|
}
|
|
@@ -292,23 +293,61 @@ export function presentationBlocks(def, options = {}) {
|
|
|
292
293
|
}
|
|
293
294
|
return blocks;
|
|
294
295
|
}
|
|
295
|
-
/**
|
|
296
|
-
*
|
|
297
|
-
*
|
|
296
|
+
/** The narrowest a block renders at. Every card fills the column it is given, so this is a floor,
|
|
297
|
+
* not the width it will take: below it a grid scrolls inside itself and a list would push its
|
|
298
|
+
* cells under their minimum. `Infinity` means the block can only have a whole row. */
|
|
299
|
+
/** What a preview card adds around its frames: `padding` (CATALOG_SPACE.xl) and a 1px border on
|
|
300
|
+
* each side. `blockMinWidth` must add it, or a preview is offered a half-row that fits its frame
|
|
301
|
+
* only after the card has taken its share, and the frame silently renders under phone width.
|
|
302
|
+
* SectionBlock's `previewCard` style is built from these, so the two cannot drift. */
|
|
303
|
+
export const PREVIEW_CARD_CHROME = { padding: 24, border: 1 };
|
|
304
|
+
/** The same for a `previewLayout: 'table'` card: no outer padding, a 1px border, and each cell
|
|
305
|
+
* carries `cellPadding` on both sides of its frame. */
|
|
306
|
+
export const TABLE_PREVIEW_CHROME = { padding: MATRIX_LAYOUT.cellPadding, border: 1 };
|
|
307
|
+
/** Total horizontal chrome for one preview frame in either card. */
|
|
308
|
+
export function previewChrome(layout) {
|
|
309
|
+
const chrome = layout === 'table' ? TABLE_PREVIEW_CHROME : PREVIEW_CARD_CHROME;
|
|
310
|
+
return 2 * chrome.padding + 2 * chrome.border;
|
|
311
|
+
}
|
|
312
|
+
export function blockMinWidth(block) {
|
|
313
|
+
switch (block.kind) {
|
|
314
|
+
case 'grid':
|
|
315
|
+
return gridMinWidth(axisItems(block.comparison.columns).length, block.size);
|
|
316
|
+
case 'grouped':
|
|
317
|
+
return MATRIX_LAYOUT.rowHeaderWidth + Math.max(1, ...block.groups.map((g) => g.items.length)) * COLUMN_MIN_WIDTH[block.size] + 2;
|
|
318
|
+
case 'list':
|
|
319
|
+
// One column: a list always wraps further rather than going below its size's minimum.
|
|
320
|
+
return COLUMN_MIN_WIDTH[block.size] + 2;
|
|
321
|
+
case 'preview':
|
|
322
|
+
// A 'full' preview is the row. Otherwise the *widest* frame sets the minimum: the frames wrap,
|
|
323
|
+
// so each one sits on its own row and `maxWidth: '100%'` would squeeze any frame wider than
|
|
324
|
+
// the card. The card's own chrome counts too — a frame keeps its width only if the row also
|
|
325
|
+
// covers the padding and border around it.
|
|
326
|
+
return block.widths === 'full' ? Infinity : Math.max(...block.widths) + previewChrome(block.layout);
|
|
327
|
+
default:
|
|
328
|
+
return Infinity;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** The height a block takes at `width`, from the height measured while stacked at `available`.
|
|
332
|
+
* Only a list reflows: its rows change with the width, and every row is the same height. */
|
|
333
|
+
function heightAtWidth(block, width, available) {
|
|
334
|
+
if (block.kind !== 'list' || !block.itemCount || !block.size)
|
|
335
|
+
return block.height;
|
|
336
|
+
const stackedRows = listGeometry(block.itemCount, available, block.size, block.maxColumns).rows;
|
|
337
|
+
const rows = listGeometry(block.itemCount, width, block.size, block.maxColumns).rows;
|
|
338
|
+
return (block.height / Math.max(1, stackedRows)) * rows;
|
|
339
|
+
}
|
|
340
|
+
/** Side by side or stacked, for a page with exactly two blocks. Side by side splits the row into
|
|
341
|
+
* two equal columns and each block fills its own, so it is only possible when both blocks keep
|
|
342
|
+
* their minimum width in half a row; it is only taken when it saves at least PLACEMENT_MIN_SAVING
|
|
343
|
+
* against stacking, measuring each block at the width it would actually have. */
|
|
298
344
|
export function choosePlacement({ available, gap, first, second }) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const firstWidth = first.width ?? available;
|
|
302
|
-
if (firstWidth > available * 0.7)
|
|
345
|
+
const half = Math.floor((available - gap) / 2);
|
|
346
|
+
if (half <= 0)
|
|
303
347
|
return 'stacked';
|
|
304
|
-
|
|
305
|
-
const minCell = second.size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : COLUMN_MIN_WIDTH[second.size];
|
|
306
|
-
if (room < minCell + 2)
|
|
348
|
+
if ((first.minWidth ?? Infinity) > half || (second.minWidth ?? Infinity) > half)
|
|
307
349
|
return 'stacked';
|
|
308
|
-
const
|
|
309
|
-
const sideRows = listGeometry(second.itemCount, room, second.size, second.maxColumns).rows;
|
|
310
|
-
const rowHeight = second.height / Math.max(1, stackedRows);
|
|
311
|
-
const sideHeight = Math.max(first.height, sideRows * rowHeight);
|
|
350
|
+
const sideHeight = Math.max(heightAtWidth(first, half, available), heightAtWidth(second, half, available));
|
|
312
351
|
const stackedHeight = first.height + gap + second.height;
|
|
313
352
|
return sideHeight <= stackedHeight - PLACEMENT_MIN_SAVING ? 'side' : 'stacked';
|
|
314
353
|
}
|
|
@@ -10,15 +10,17 @@
|
|
|
10
10
|
* (docs/design/2026-10-06-catalog-studio-matrix-*.html), with one deliberate exception:
|
|
11
11
|
* `textMuted` stays #666666 because the references' #777777 fails WCAG AA contrast.
|
|
12
12
|
*/
|
|
13
|
+
/** Owner decision 2026-10-11: only 10, 12, 14, 16, 18, 24, 28, 32 and 40. Named roles share a
|
|
14
|
+
* step (tableHeader and panelHeading are 12, brand is 18). */
|
|
13
15
|
export const CATALOG_TYPE = {
|
|
14
16
|
xs: 10,
|
|
15
|
-
tableHeader:
|
|
17
|
+
tableHeader: 12,
|
|
16
18
|
sm: 12,
|
|
17
|
-
panelHeading:
|
|
19
|
+
panelHeading: 12,
|
|
18
20
|
md: 14,
|
|
19
21
|
lg: 16,
|
|
20
|
-
brand:
|
|
21
|
-
xl:
|
|
22
|
+
brand: 18,
|
|
23
|
+
xl: 18,
|
|
22
24
|
'2xl': 24,
|
|
23
25
|
pageTitle: 28,
|
|
24
26
|
'3xl': 32,
|
|
@@ -76,8 +78,8 @@ export const CATALOG_TYPE_USE = {
|
|
|
76
78
|
md: 'Default body text: descriptions, nav links, grid row headers, quick-reference rows, the filter field.',
|
|
77
79
|
lg: 'Pager arrows and the filter field\'s clear (×) glyph.',
|
|
78
80
|
brand: 'The app name at the top of the sidebar.',
|
|
79
|
-
xl: '
|
|
80
|
-
'2xl': '
|
|
81
|
+
xl: "The major-update banner's close (×) glyph.",
|
|
82
|
+
'2xl': "The update page's version numbers and the arrow between them.",
|
|
81
83
|
pageTitle: 'The selected page\'s title.',
|
|
82
84
|
'3xl': 'Reserved — no current consumer.',
|
|
83
85
|
'4xl': 'Reserved — no current consumer.',
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React, { forwardRef, useState } from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text, type View } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
|
+
|
|
5
|
+
/** The viewer's own button, for catalog chrome and demo triggers (not an app component): a rounded
|
|
6
|
+
* rectangle, primary in the accent blue, secondary outlined. */
|
|
7
|
+
export const CatalogButton = forwardRef<View, {
|
|
8
|
+
label: string;
|
|
9
|
+
onPress: () => void;
|
|
10
|
+
variant?: 'primary' | 'secondary';
|
|
11
|
+
accessibilityLabel?: string;
|
|
12
|
+
}>(function CatalogButton({ label, onPress, variant = 'primary', accessibilityLabel }, ref) {
|
|
13
|
+
const [focused, setFocused] = useState(false);
|
|
14
|
+
const primary = variant === 'primary';
|
|
15
|
+
return (
|
|
16
|
+
<Pressable
|
|
17
|
+
ref={ref}
|
|
18
|
+
onPress={onPress}
|
|
19
|
+
onFocus={() => setFocused(true)}
|
|
20
|
+
onBlur={() => setFocused(false)}
|
|
21
|
+
accessibilityRole="button"
|
|
22
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
23
|
+
style={({ pressed }) => [
|
|
24
|
+
styles.base,
|
|
25
|
+
primary ? styles.primary : styles.secondary,
|
|
26
|
+
pressed && (primary ? styles.primaryPressed : styles.secondaryPressed),
|
|
27
|
+
focused && styles.focus,
|
|
28
|
+
]}
|
|
29
|
+
>
|
|
30
|
+
<Text style={[styles.label, primary ? styles.labelPrimary : styles.labelSecondary]}>{label}</Text>
|
|
31
|
+
</Pressable>
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
// A rounded rectangle (owner decision 2026-10-11); primary in the catalog's accent blue.
|
|
37
|
+
base: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: CATALOG_RADIUS.control, alignItems: 'center', justifyContent: 'center', alignSelf: 'center' },
|
|
38
|
+
primary: { backgroundColor: CATALOG_COLOR.accent },
|
|
39
|
+
primaryPressed: { opacity: 0.85 },
|
|
40
|
+
secondary: { borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, backgroundColor: CATALOG_COLOR.surface },
|
|
41
|
+
secondaryPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
|
|
42
|
+
// Same ring as the sidebar's: an outline, so focusing never shifts the layout.
|
|
43
|
+
focus: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing } as object,
|
|
44
|
+
label: { fontSize: CATALOG_TYPE.md, fontWeight: '700' },
|
|
45
|
+
labelPrimary: { color: '#ffffff' },
|
|
46
|
+
labelSecondary: { color: CATALOG_COLOR.text },
|
|
47
|
+
});
|