@krapwoo/ds-viewer 0.5.0 → 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 +31 -8
- package/dist/cli/doctor.js +42 -3
- package/dist/cli/explain.js +11 -20
- package/dist/cli/render.js +91 -0
- package/dist/cli/staticPage.js +17 -0
- package/dist/native/catalog/comparison.js +69 -30
- package/dist/native/catalog/tokens.js +8 -6
- package/native/catalog/CatalogButton.tsx +6 -5
- 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/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 +1 -1
- 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/README.md
CHANGED
|
@@ -175,13 +175,18 @@ when the card is too narrow even for two. The specimens use one of four layouts:
|
|
|
175
175
|
- **Preview** — `render()` content. Component previews render at phone width (402px), optionally
|
|
176
176
|
with more widths such as a 320px small phone; token galleries and catalog chrome stay full width.
|
|
177
177
|
|
|
178
|
-
When a page has two blocks, the catalog places them side by side
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
178
|
+
When a page has two blocks, the catalog places them side by side — the row split into two equal
|
|
179
|
+
columns, each block filling its own — if both keep their minimum width there and it makes the page
|
|
180
|
+
at least 120px shorter (e.g. Dropdown); otherwise they stack, each filling the whole row. A block
|
|
181
|
+
with nothing to show is omitted.
|
|
182
|
+
|
|
183
|
+
Every card fills the column it is given, and its columns or cells share that width equally: a
|
|
184
|
+
two-column grid is as wide as a five-column one, and a two-item list is as wide as a six-item one.
|
|
185
|
+
Phone width (402px, 370px of content) caps the specimen inside a cell, not the cell. A page's
|
|
186
|
+
`specimenSize` (`compact` 160px, `regular` 240px, `wide` 402px) sets the minimum column width — a
|
|
187
|
+
card narrower than its minimum scrolls inside itself rather than squeezing its cells. Full-width
|
|
188
|
+
(`itemsFill`) slots default to `wide`. The catalog's own look comes from `native/catalog/tokens.ts`,
|
|
189
|
+
never from the host app's tokens.
|
|
185
190
|
|
|
186
191
|
## Adding a component (the recipe)
|
|
187
192
|
|
|
@@ -201,7 +206,9 @@ actually apply:
|
|
|
201
206
|
combination does not exist. Never multiply `variants` by `states` to fill it. Keep the column axis
|
|
202
207
|
within what fits a 1280px laptop: 5 compact, 3 regular, or 2 wide columns. `states` items whose
|
|
203
208
|
key matches a row or column key are not repeated below the grid.
|
|
204
|
-
- `specimenSize: 'compact' | 'regular' | 'wide'` — the page's specimen width class.
|
|
209
|
+
- `specimenSize: 'compact' | 'regular' | 'wide' | 'page'` — the page's specimen width class. `'page'`
|
|
210
|
+
is for components wider than a phone (a whole screen layout, a catalog page): cells are at least
|
|
211
|
+
512px and share the row, with no 402px cap.
|
|
205
212
|
- `specimenSurface: 'auto' | 'neutral' | 'white' | 'dark' | 'transparent'` — the fill of each
|
|
206
213
|
example's cell (list, grouped and grid cells, and a non-token `render()` preview). Defaults to
|
|
207
214
|
`'auto'`: after the page draws, a cell turns light gray only when its own example would vanish
|
|
@@ -481,6 +488,17 @@ Button
|
|
|
481
488
|
or `x`/`onXChange`) but every example that renders it passes a fixed value with no working
|
|
482
489
|
callback. Add `intentionalStaticPreview: { reason: '...' }` to the page to suppress it when the
|
|
483
490
|
static examples are deliberate.
|
|
491
|
+
- `preview-only-component-page` — a component page's only examples are a free-form `render()`
|
|
492
|
+
preview, so nothing is named and the checks above have no example to read. Give the page
|
|
493
|
+
`variants` (and `states`, where they differ) naming each real configuration. Token galleries
|
|
494
|
+
and a declared `intentionalStaticPreview` are exempt; `render()` stays right for a token
|
|
495
|
+
gallery or a whole-page layout.
|
|
496
|
+
- `internal-prop-unknown` — `internalProps` names something that is not a prop of the component,
|
|
497
|
+
so the exemption it was written for is not in effect. Correct the spelling or delete the entry.
|
|
498
|
+
A page's `internalProps: { size: 'why' }` exempts a named prop from `option-not-covered` — for
|
|
499
|
+
plumbing a parent passes (a layout size, an internal surface kind) rather than a value the
|
|
500
|
+
reader picks. It needs a reason per prop, so the exemption is a visible declaration in the page;
|
|
501
|
+
when the reader really does choose the value, tag an example with `props` instead.
|
|
484
502
|
The analyzer follows supported aliases and resolved exports, but uncertain wrappers or
|
|
485
503
|
dynamically-constructed references remain uncheckable rather than being guessed at. A
|
|
486
504
|
declared `intentionalStaticPreview` is taken as the author's own call, not independently
|
|
@@ -498,6 +516,11 @@ and adds what only a real render shows:
|
|
|
498
516
|
- `render-error` / `render-no-heading` (errors): a console error, or a page that didn't render.
|
|
499
517
|
- `render-layout` (warning): an example wider than its cell (the viewer's own check), with the
|
|
500
518
|
page and file. `render-catalog-warning` (warning): any other `[Catalog]` warning, reported once.
|
|
519
|
+
- `identical-variants` (warning): two examples in the same card whose specimens render identically,
|
|
520
|
+
so the difference their names claim isn't visible — usually a cell too narrow to show it, or a
|
|
521
|
+
prop that was never wired up. The fingerprint reads each specimen's whole subtree (including a
|
|
522
|
+
PhoneFrame's child document and a field's value) but not the caption carrying the variant's own
|
|
523
|
+
name, so two examples differing only in colour are not reported.
|
|
501
524
|
- `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
|
|
502
525
|
|
|
503
526
|
It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
|
package/dist/cli/doctor.js
CHANGED
|
@@ -279,6 +279,22 @@ function findControlledPair(component) {
|
|
|
279
279
|
* - no example directly renders the component at all, or any direct example's value/callback is
|
|
280
280
|
* `'unknown'` — genuinely uncheckable, and "uncheckable" must never be reported as "inert".
|
|
281
281
|
* Never a newly-fatal gate: always a warning, same as every other advisory in this file. */
|
|
282
|
+
/** A component page whose only examples are a free-form `render()` preview: nothing is named, so
|
|
283
|
+
* the reader sees one picture instead of the component's real variants and states, and several
|
|
284
|
+
* other rules here (`missing-working-preview`, the option-coverage rules) are skipped because
|
|
285
|
+
* they have no named example to read. A token gallery is exempt — raw token data has no variants
|
|
286
|
+
* — and so is a page that says `intentionalStaticPreview` with a reason. */
|
|
287
|
+
function checkPreviewOnlyPages(pages) {
|
|
288
|
+
return pages
|
|
289
|
+
.filter((page) => page.component !== undefined && page.checkable && page.hasRender === true && !page.tokenGallery
|
|
290
|
+
&& !page.intentionalStaticPreviewReason
|
|
291
|
+
&& !page.comparison && !page.variantsItems?.length && !page.statesItems?.length && !page.tokenSectionTitles?.length)
|
|
292
|
+
.map((page) => issue({
|
|
293
|
+
id: 'preview-only-component-page', severity: 'warning', ...pageRef(page),
|
|
294
|
+
message: `"${page.component}" is documented by a render() preview alone, with no named variants, states or comparison.`,
|
|
295
|
+
fix: 'Give the page variants (and states, where they differ) naming each real configuration, so every example has a caption and the other checks can read them. Keep render() for token galleries and whole-page layouts.',
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
282
298
|
function checkMissingWorkingPreview(pages, components) {
|
|
283
299
|
const componentsByName = new Map(components.map((c) => [c.name, c]));
|
|
284
300
|
const issues = [];
|
|
@@ -409,8 +425,25 @@ function checkComponentPages(pages, components) {
|
|
|
409
425
|
}));
|
|
410
426
|
}
|
|
411
427
|
}
|
|
428
|
+
// Props the page declares as plumbing the reader never picks (with a reason) are not expected
|
|
429
|
+
// to have an example per option; see `internalProps`.
|
|
430
|
+
const internalProps = new Set(page.internalProps ?? []);
|
|
431
|
+
// A misspelled key silently exempts nothing, so the author sees the warnings it was meant to
|
|
432
|
+
// settle and no reason why — the same reasoning as propNotes' own key check.
|
|
433
|
+
for (const name of internalProps) {
|
|
434
|
+
// `component.props` deliberately omits a node_modules-declared prop (cli/props.ts), so an
|
|
435
|
+
// inherited name such as `autoCapitalize` is expected to be absent rather than misspelled —
|
|
436
|
+
// the same exemption the tagged-item check above makes.
|
|
437
|
+
if (!propsByName.has(name) && component.inheritedFrom.length === 0) {
|
|
438
|
+
issues.push(issue({
|
|
439
|
+
id: 'internal-prop-unknown', severity: 'warning', ...ref,
|
|
440
|
+
message: `internalProps names "${name}", which is not a prop of ${component.name}.`,
|
|
441
|
+
fix: `Correct the spelling, or delete the "${name}" entry — it exempts nothing as written.`,
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
412
445
|
for (const prop of component.props) {
|
|
413
|
-
if (!prop.options)
|
|
446
|
+
if (!prop.options || internalProps.has(prop.name))
|
|
414
447
|
continue;
|
|
415
448
|
const covered = new Set();
|
|
416
449
|
if (page.comparison) {
|
|
@@ -456,10 +489,15 @@ function checkComponentPages(pages, components) {
|
|
|
456
489
|
fix: 'Add a one-sentence whenToUse note disambiguating this component from its closest look-alike(s), or omit it only when there genuinely is none.',
|
|
457
490
|
}));
|
|
458
491
|
}
|
|
459
|
-
|
|
492
|
+
// `hide: { accessibility: true }` is a display choice, not an exemption from documenting the
|
|
493
|
+
// component: hiding the section only removes the visible placeholder, so without this the gap
|
|
494
|
+
// becomes invisible to both the reader and the doctor.
|
|
495
|
+
if (!page.tokenGallery && page.hasA11y === false) {
|
|
460
496
|
issues.push(issue({
|
|
461
497
|
id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
|
|
462
|
-
message:
|
|
498
|
+
message: page.hidesAccessibility
|
|
499
|
+
? 'No "a11y" notes documented, and the page hides the accessibility section.'
|
|
500
|
+
: 'No "a11y" notes documented.',
|
|
463
501
|
fix: 'Add a one-sentence a11y note, grounded in the component\'s real behavior — even "no explicit handling beyond defaults" is better than leaving it blank.',
|
|
464
502
|
}));
|
|
465
503
|
}
|
|
@@ -480,6 +518,7 @@ export function collectIssues(pages, components) {
|
|
|
480
518
|
...checkComponentPages(pages, components),
|
|
481
519
|
...checkMissingComposition(pages, components),
|
|
482
520
|
...checkMissingWorkingPreview(pages, components),
|
|
521
|
+
...checkPreviewOnlyPages(pages),
|
|
483
522
|
];
|
|
484
523
|
}
|
|
485
524
|
/** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
|
package/dist/cli/explain.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { readStaticPages } from "./staticPage.js";
|
|
3
3
|
import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
|
|
4
4
|
import { resolveGlob } from "./glob.js";
|
|
5
|
-
import { axisItems, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
|
|
5
|
+
import { axisItems, blockMinWidth, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
|
|
6
6
|
import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
|
|
7
7
|
import { sectionColumns } from "../native/catalog/tokenLayout.js";
|
|
8
8
|
function placeholderItem(item) {
|
|
@@ -113,23 +113,14 @@ function explainBlock(block) {
|
|
|
113
113
|
}
|
|
114
114
|
return { kind: 'empty', title: block.title, reason: block.message };
|
|
115
115
|
}
|
|
116
|
-
/** Mirrors `native/catalog/SectionBlock.tsx`'s own `
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
kind: 'list',
|
|
125
|
-
height,
|
|
126
|
-
itemCount: block.items.length,
|
|
127
|
-
size: block.size,
|
|
128
|
-
maxColumns: block.maxColumns,
|
|
129
|
-
width: listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
return { kind: block.kind, height };
|
|
116
|
+
/** Mirrors `native/catalog/SectionBlock.tsx`'s own `placementBlock` helper: side by side splits the
|
|
117
|
+
* row in two and each block fills its half, so the decision needs each block's narrowest width
|
|
118
|
+
* plus, for a list, the geometry that predicts its height in half a row. */
|
|
119
|
+
function toPlacementBlock(block, height) {
|
|
120
|
+
const base = { kind: block.kind, height, minWidth: blockMinWidth(block) };
|
|
121
|
+
return block.kind === 'list'
|
|
122
|
+
? { ...base, itemCount: block.items.length, size: block.size, maxColumns: block.maxColumns }
|
|
123
|
+
: base;
|
|
133
124
|
}
|
|
134
125
|
/** Finds `pageId` among every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, builds
|
|
135
126
|
* its throwaway `SectionDef`, and asks the viewer's own pure layout functions for each block's
|
|
@@ -163,8 +154,8 @@ export function explainPage(config, pageId, options = {}) {
|
|
|
163
154
|
const decision = choosePlacement({
|
|
164
155
|
available: MATRIX_LAYOUT.laptopContentWidth,
|
|
165
156
|
gap: CATALOG_LAYOUT.blockGap,
|
|
166
|
-
first: toPlacementBlock(blocks[0], firstHeight
|
|
167
|
-
second: toPlacementBlock(blocks[1], secondHeight
|
|
157
|
+
first: toPlacementBlock(blocks[0], firstHeight),
|
|
158
|
+
second: toPlacementBlock(blocks[1], secondHeight),
|
|
168
159
|
});
|
|
169
160
|
placement = {
|
|
170
161
|
decision,
|
package/dist/cli/render.js
CHANGED
|
@@ -40,6 +40,85 @@ export function issuesFromPage(page, messages, hasHeading, seen = new Set()) {
|
|
|
40
40
|
}
|
|
41
41
|
return issues;
|
|
42
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
|
+
}
|
|
43
122
|
/** The project's own puppeteer, or `undefined` when it isn't installed. */
|
|
44
123
|
export function loadProjectPuppeteer(projectRoot) {
|
|
45
124
|
try {
|
|
@@ -221,6 +300,18 @@ export async function renderCheck(projectRoot, options = {}) {
|
|
|
221
300
|
return Boolean(doc.querySelector('[role="heading"]')?.textContent?.trim());
|
|
222
301
|
});
|
|
223
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));
|
|
224
315
|
await page.close();
|
|
225
316
|
}
|
|
226
317
|
catch (error) {
|
package/dist/cli/staticPage.js
CHANGED
|
@@ -170,6 +170,23 @@ function readStaticPage(file) {
|
|
|
170
170
|
page.intentionalStaticPreviewReason = stringLiteral(resolve(reasonProp.initializer, consts, new Set()));
|
|
171
171
|
}
|
|
172
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
|
+
}
|
|
173
190
|
return page;
|
|
174
191
|
}
|
|
175
192
|
/** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
|
|
@@ -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.',
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import React, { forwardRef, useState } from 'react';
|
|
2
2
|
import { Pressable, StyleSheet, Text, type View } from 'react-native';
|
|
3
|
-
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_TYPE } from './tokens';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
4
|
|
|
5
|
-
/** The viewer's own button, for catalog chrome and demo triggers (not an app component)
|
|
6
|
-
*
|
|
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
7
|
export const CatalogButton = forwardRef<View, {
|
|
8
8
|
label: string;
|
|
9
9
|
onPress: () => void;
|
|
@@ -33,8 +33,9 @@ export const CatalogButton = forwardRef<View, {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
const styles = StyleSheet.create({
|
|
36
|
-
|
|
37
|
-
|
|
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 },
|
|
38
39
|
primaryPressed: { opacity: 0.85 },
|
|
39
40
|
secondary: { borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, backgroundColor: CATALOG_COLOR.surface },
|
|
40
41
|
secondaryPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
|