@yschimke/compose-design-map 1.18.0 → 1.20.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 +23 -0
- package/design-map.mjs +128 -17
- package/emit-design-map.mjs +37 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,6 +137,29 @@ Several modes with **no light among them** is the one case that stays unmapped.
|
|
|
137
137
|
guessing which of `Dark` and `Coral` the kit drew, so those components are reported
|
|
138
138
|
(`diagnostics.ambiguousMode`, and a `--strict` failure) rather than paired at random.
|
|
139
139
|
|
|
140
|
+
**A breakpoint fan-out is a size axis, not a mode.** A multipreview that draws one composable at
|
|
141
|
+
several screen sizes — the Wear round breakpoints are the live case — publishes several captures of
|
|
142
|
+
it, told apart by the *same* id segment a themed pair uses. Read as modes they are unresolvable
|
|
143
|
+
(`Light` is nowhere among `wearos_small_round` / `wearos_large_round`), so a full-screen component
|
|
144
|
+
used to drop out of the map entirely the moment it gained a second size.
|
|
145
|
+
|
|
146
|
+
They are told apart by a fact the id does not carry: each capture names a `device`, and the devices
|
|
147
|
+
have **different widths**. A palette does not change the frame's width, so captures whose modes map
|
|
148
|
+
one-to-one onto distinct device widths are a size axis and one of them can be picked on the merits:
|
|
149
|
+
|
|
150
|
+
- the **narrowest** is the base by default, because that is the size a kit draws — a kit publishes
|
|
151
|
+
its screen artwork at one size and leaves adaptation to the implementation, and the narrowest is
|
|
152
|
+
the one every larger screen is an adaptation *of*;
|
|
153
|
+
- `--base-breakpoint <dp>` moves it, for a kit that draws somewhere else. A named base a given
|
|
154
|
+
composable does not render falls back to the narrowest rather than dropping it — rendering a
|
|
155
|
+
subset of the catalog's breakpoints is a legitimate thing for one screen to do;
|
|
156
|
+
- the sizes the base did not take **fold under it as cells**, seeded `breakpoint=<dp>` and named
|
|
157
|
+
`<dp>dp`, so they are published rather than discarded.
|
|
158
|
+
|
|
159
|
+
Two captures of the *same* width are still a mode, whatever devices they name: nothing orders them,
|
|
160
|
+
so they stay `ambiguousMode`. An `@OverrideVariant` cell rides the base breakpoint only — the
|
|
161
|
+
product of both axes would multiply the sheet by every size, and the base carries the matrix.
|
|
162
|
+
|
|
140
163
|
**`overrides.props` beats `overrides.seeds` where both exist.** They are not the same list. `seeds`
|
|
141
164
|
holds only the values that differ from the composable's defaults; `props` — emitted for a
|
|
142
165
|
`@PreviewAxis` cross product — carries the full axis assignment, defaults included. A cell that
|
package/design-map.mjs
CHANGED
|
@@ -113,9 +113,51 @@ export function captureIdentity(preview) {
|
|
|
113
113
|
* guessing which of `Dark` and `Coral` the kit drew, and pairing the wrong one diffs a whole
|
|
114
114
|
* palette — so it is reported instead (`diagnostics.ambiguousMode`).
|
|
115
115
|
*/
|
|
116
|
-
function preferredMode(modes) {
|
|
116
|
+
function preferredMode(modes, widthByMode, baseBreakpointDp) {
|
|
117
117
|
if (modes.has(LIGHT_MODE)) return LIGHT_MODE;
|
|
118
|
-
|
|
118
|
+
if (modes.size === 1) return [...modes][0];
|
|
119
|
+
|
|
120
|
+
// A BREAKPOINT FAN-OUT is not an ambiguous mode, and telling them apart is what this arm is for.
|
|
121
|
+
//
|
|
122
|
+
// A multipreview that renders one function at several screen sizes produces several captures of
|
|
123
|
+
// one composable, exactly like a themed pair does — and the id segment they are told apart by is
|
|
124
|
+
// the same segment. Read as modes they are unresolvable (`Light` is nowhere among
|
|
125
|
+
// `wearos_small_round` / `wearos_large_round`), so every full-screen component of a Wear catalog
|
|
126
|
+
// dropped out of the map the moment it gained a second size, and `--strict` failed on it.
|
|
127
|
+
//
|
|
128
|
+
// They are distinguishable by a fact the id does not carry: each capture names a `device`, and
|
|
129
|
+
// the devices have DIFFERENT WIDTHS. A palette does not change the frame's width, so a set of
|
|
130
|
+
// captures whose modes map one-to-one onto distinct device widths is a size axis rather than a
|
|
131
|
+
// colour one, and one of them can be picked on the merits.
|
|
132
|
+
const sized = [...modes].map((mode) => [mode, widthByMode.get(mode)]);
|
|
133
|
+
const widths = sized.map(([, width]) => width);
|
|
134
|
+
if (!widths.every((width) => Number.isFinite(width))) return null;
|
|
135
|
+
if (new Set(widths).size !== widths.length) return null;
|
|
136
|
+
|
|
137
|
+
// NARROWEST by default, because that is the size a kit draws: a design kit publishes its screen
|
|
138
|
+
// artwork at one size and leaves adaptation to the implementation, and the narrowest is the one
|
|
139
|
+
// every larger screen is an adaptation OF. `baseBreakpointDp` overrides it for a kit that draws
|
|
140
|
+
// somewhere else. A named base this composable does not render falls back to the narrowest
|
|
141
|
+
// rather than dropping the component: rendering a subset of the catalog's breakpoints is a
|
|
142
|
+
// legitimate thing for one screen to do, and it is not a reason to publish no map row for it.
|
|
143
|
+
sized.sort((a, b) => a[1] - b[1]);
|
|
144
|
+
const named = Number.isFinite(baseBreakpointDp)
|
|
145
|
+
? sized.find(([, width]) => width === baseBreakpointDp)
|
|
146
|
+
: null;
|
|
147
|
+
return (named ?? sized[0])[0];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The device width a capture was drawn at, or `null` when it names no device.
|
|
152
|
+
*
|
|
153
|
+
* Read from `params.device` rather than `params.widthDp` alone: a device-less preview carries no
|
|
154
|
+
* width at all, and a `wrapSandbox` bound is not a screen size. Only a capture that names a device
|
|
155
|
+
* is claiming to be a picture of a screen.
|
|
156
|
+
*/
|
|
157
|
+
function deviceWidthDp(preview) {
|
|
158
|
+
const params = preview?.params;
|
|
159
|
+
if (!params?.device) return null;
|
|
160
|
+
return Number.isFinite(params.widthDp) ? params.widthDp : null;
|
|
119
161
|
}
|
|
120
162
|
|
|
121
163
|
/**
|
|
@@ -124,9 +166,10 @@ function preferredMode(modes) {
|
|
|
124
166
|
*
|
|
125
167
|
* @returns {{participates: (preview: object) => boolean, ambiguous: Array<object>}}
|
|
126
168
|
*/
|
|
127
|
-
export function selectCaptures(previews) {
|
|
169
|
+
export function selectCaptures(previews, { baseBreakpointDp } = {}) {
|
|
128
170
|
const modesBySubject = new Map();
|
|
129
171
|
const componentsBySubject = new Map();
|
|
172
|
+
const widthsBySubject = new Map();
|
|
130
173
|
for (const preview of previews) {
|
|
131
174
|
if (!preview?.catalog) continue;
|
|
132
175
|
const { subject, mode } = captureIdentity(preview);
|
|
@@ -136,12 +179,18 @@ export function selectCaptures(previews) {
|
|
|
136
179
|
const ids = componentsBySubject.get(subject) ?? new Set();
|
|
137
180
|
if (preview.catalog.componentId) ids.add(preview.catalog.componentId);
|
|
138
181
|
componentsBySubject.set(subject, ids);
|
|
182
|
+
const widths = widthsBySubject.get(subject) ?? new Map();
|
|
183
|
+
// A VARIANT capture rides the same device as its base, so it agrees rather than conflicts —
|
|
184
|
+
// but read the base's width first, since that is the one the fan-out is defined by.
|
|
185
|
+
if (!widths.has(mode)) widths.set(mode, deviceWidthDp(preview));
|
|
186
|
+
widthsBySubject.set(subject, widths);
|
|
139
187
|
}
|
|
140
188
|
|
|
141
189
|
const chosen = new Map();
|
|
142
190
|
const ambiguous = [];
|
|
143
191
|
for (const [subject, modes] of modesBySubject) {
|
|
144
|
-
const
|
|
192
|
+
const widths = widthsBySubject.get(subject) ?? new Map();
|
|
193
|
+
const mode = preferredMode(modes, widths, baseBreakpointDp);
|
|
145
194
|
if (mode === null) {
|
|
146
195
|
ambiguous.push({
|
|
147
196
|
subject,
|
|
@@ -160,6 +209,22 @@ export function selectCaptures(previews) {
|
|
|
160
209
|
const { subject, mode } = captureIdentity(preview);
|
|
161
210
|
return chosen.has(subject) && chosen.get(subject) === mode;
|
|
162
211
|
},
|
|
212
|
+
/**
|
|
213
|
+
* The device width of a capture that is a NON-BASE breakpoint of a fan-out, or `null`.
|
|
214
|
+
*
|
|
215
|
+
* This is what turns the sizes the base did not take into cells rather than into silence. A
|
|
216
|
+
* capture qualifies only when its subject resolved to some other mode and both that mode and
|
|
217
|
+
* this one name a device width — so a `Dark` capture standing beside a chosen `Light` one,
|
|
218
|
+
* which is a mode and not a size, is never mistaken for a breakpoint.
|
|
219
|
+
*/
|
|
220
|
+
breakpointOf(preview) {
|
|
221
|
+
const { subject, mode } = captureIdentity(preview);
|
|
222
|
+
if (!chosen.has(subject) || chosen.get(subject) === mode) return null;
|
|
223
|
+
const widths = widthsBySubject.get(subject);
|
|
224
|
+
const base = widths?.get(chosen.get(subject));
|
|
225
|
+
const here = widths?.get(mode);
|
|
226
|
+
return Number.isFinite(base) && Number.isFinite(here) ? here : null;
|
|
227
|
+
},
|
|
163
228
|
};
|
|
164
229
|
}
|
|
165
230
|
|
|
@@ -375,7 +440,25 @@ export function variantRendersByComponent(previews, selection = selectCaptures(p
|
|
|
375
440
|
// mode its component's base reference pairs with.
|
|
376
441
|
const isOverrideVariant = catalog.role === "COMPONENT" && isVariantCapture(preview);
|
|
377
442
|
const isCatalogVariant = catalog.role === "VARIANT";
|
|
378
|
-
|
|
443
|
+
|
|
444
|
+
// A BREAKPOINT capture is the third form, and it is not an annotation at all — it is the same
|
|
445
|
+
// composable drawn on a wider screen by a multipreview. It folds under its component like any
|
|
446
|
+
// other cell, seeded with the width it was drawn at, so the sizes the base did not take are
|
|
447
|
+
// published rather than discarded.
|
|
448
|
+
//
|
|
449
|
+
// Taken BEFORE the `participates` gate, because a non-base breakpoint is by definition the
|
|
450
|
+
// capture that did not participate. An `@OverrideVariant` cell of a non-base breakpoint is
|
|
451
|
+
// skipped, though — that is the product of two axes and would multiply the sheet by every
|
|
452
|
+
// size; the base breakpoint carries the component's matrix.
|
|
453
|
+
if (!isOverrideVariant && !isCatalogVariant) {
|
|
454
|
+
const widthDp = catalog.role === "COMPONENT" ? selection.breakpointOf(preview) : null;
|
|
455
|
+
if (widthDp === null) continue;
|
|
456
|
+
const seeds = [{ key: "breakpoint", raw: String(widthDp) }];
|
|
457
|
+
const list = byComponent.get(catalog.componentId) ?? [];
|
|
458
|
+
list.push({ previewId: preview.id, name: `${widthDp}dp`, seeds });
|
|
459
|
+
byComponent.set(catalog.componentId, list);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
379
462
|
if (!selection.participates(preview)) continue;
|
|
380
463
|
|
|
381
464
|
// A variant that names no axis says only "this is different", which is not enough to look
|
|
@@ -401,34 +484,55 @@ export function variantRendersByComponent(previews, selection = selectCaptures(p
|
|
|
401
484
|
* fact to report, not a failure.
|
|
402
485
|
*/
|
|
403
486
|
export function projectDesignMap(previews, opts = {}) {
|
|
404
|
-
const selection = selectCaptures(previews);
|
|
487
|
+
const selection = selectCaptures(previews, { baseBreakpointDp: opts.baseBreakpointDp });
|
|
405
488
|
const variantRenders = variantRendersByComponent(previews, selection);
|
|
406
489
|
|
|
407
490
|
const components = [];
|
|
408
491
|
const declarations = [];
|
|
492
|
+
/**
|
|
493
|
+
* Whether a component reaches a design reference at all, and what it said if not.
|
|
494
|
+
*
|
|
495
|
+
* Read from the ANNOTATIONS, before and independently of capture selection — which is the whole
|
|
496
|
+
* point. A component publishing several modes with no Light among them is `ambiguousMode`, and
|
|
497
|
+
* `participates()` is false for every one of its captures; computing absence inside the capture
|
|
498
|
+
* loop therefore dropped such a component out of `unmapped` / `statedAbsent` entirely and
|
|
499
|
+
* reported it only as an ambiguous mode. A stated absence would then be fatal under
|
|
500
|
+
* `--strict --allow-stated-absence`, which is exactly the case that flag exists to accept.
|
|
501
|
+
*
|
|
502
|
+
* Keyed by componentId rather than pushed per preview, because a component's absence is one fact
|
|
503
|
+
* however many captures it publishes.
|
|
504
|
+
*/
|
|
505
|
+
const unmappedIds = new Map();
|
|
506
|
+
const statedAbsentIds = new Map();
|
|
507
|
+
for (const preview of previews) {
|
|
508
|
+
const catalog = preview.catalog;
|
|
509
|
+
if (!catalog || catalog.role !== "COMPONENT" || catalog.reference) continue;
|
|
510
|
+
if (isVariantCapture(preview)) continue;
|
|
511
|
+
const id = catalog.componentId;
|
|
512
|
+
if (catalog.noReference) statedAbsentIds.set(id, catalog.noReference);
|
|
513
|
+
else if (!statedAbsentIds.has(id)) unmappedIds.set(id, true);
|
|
514
|
+
}
|
|
409
515
|
/** Components carrying neither a reference nor a stated reason for its absence. */
|
|
410
|
-
const unmapped = [];
|
|
516
|
+
const unmapped = [...unmappedIds.keys()].filter((id) => !statedAbsentIds.has(id));
|
|
411
517
|
/**
|
|
412
518
|
* Components whose reference is absent for a STATED reason. Reported apart from `unmapped`
|
|
413
519
|
* because they are the opposite situation: someone looked, and what they found is that the kit
|
|
414
520
|
* has nothing live to point at. Rolling the two together is what made a retired pattern read as
|
|
415
521
|
* neglect.
|
|
416
522
|
*/
|
|
417
|
-
const statedAbsent = []
|
|
523
|
+
const statedAbsent = [...statedAbsentIds].map(([componentId, reason]) => ({
|
|
524
|
+
componentId,
|
|
525
|
+
reason,
|
|
526
|
+
}));
|
|
527
|
+
/** Every component that reaches no reference, however its absence was spelled. */
|
|
528
|
+
const referencelessIds = new Set([...unmapped, ...statedAbsentIds.keys()]);
|
|
418
529
|
|
|
419
530
|
for (const preview of previews) {
|
|
420
531
|
const catalog = preview.catalog;
|
|
421
532
|
if (!catalog || catalog.role !== "COMPONENT") continue;
|
|
422
533
|
if (isVariantCapture(preview) || !selection.participates(preview)) continue;
|
|
423
534
|
|
|
424
|
-
if (!catalog.reference)
|
|
425
|
-
if (catalog.noReference) {
|
|
426
|
-
statedAbsent.push({ componentId: catalog.componentId, reason: catalog.noReference });
|
|
427
|
-
} else {
|
|
428
|
-
unmapped.push(catalog.componentId);
|
|
429
|
-
}
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
535
|
+
if (!catalog.reference) continue;
|
|
432
536
|
|
|
433
537
|
const code = codeHandle(preview, opts);
|
|
434
538
|
components.push({
|
|
@@ -478,7 +582,14 @@ export function projectDesignMap(previews, opts = {}) {
|
|
|
478
582
|
// Composables whose captures name no mode a reference could pair with — several modes, none
|
|
479
583
|
// of them light. Reported rather than guessed at: pairing `Dark` when the kit drew `Coral`
|
|
480
584
|
// diffs a whole palette.
|
|
481
|
-
|
|
585
|
+
// An ambiguous mode is only ever a problem BECAUSE a reference needs one capture to pair
|
|
586
|
+
// with. A component that reaches no reference has nothing to pair, so which of its captures
|
|
587
|
+
// the kit drew is not a question anyone is asking — reporting it would be noise on top of the
|
|
588
|
+
// absence already reported above, and under --strict it would be a second, unfixable failure
|
|
589
|
+
// for the same component.
|
|
590
|
+
ambiguousMode: selection.ambiguous.filter(
|
|
591
|
+
(a) => !a.componentIds.length || a.componentIds.some((id) => !referencelessIds.has(id)),
|
|
592
|
+
),
|
|
482
593
|
variantRenders: declarations.reduce((n, d) => n + d.renders.length, 0),
|
|
483
594
|
withSet: components.filter((c) => c.refSet).length,
|
|
484
595
|
},
|
package/emit-design-map.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* npx @yschimke/compose-design-map [--previews <path>] [--out design-map.json]
|
|
6
6
|
* [--variants design-map-variants.json] [--prefix catalog]
|
|
7
|
-
* [--check] [--strict]
|
|
7
|
+
* [--check] [--strict] [--allow-stated-absence]
|
|
8
|
+
* [--base-breakpoint <dp>]
|
|
8
9
|
*
|
|
9
10
|
* Run `./gradlew :<module>:composePreviewDiscover` first so the manifest exists.
|
|
10
11
|
*
|
|
@@ -32,6 +33,14 @@
|
|
|
32
33
|
* whose captures name no mode the reference could pair with. The annotation still earns its keep in
|
|
33
34
|
* the default mode, where the three are reported apart so a retired pattern does not read as
|
|
34
35
|
* neglect; `--strict` simply says there are no exceptions.
|
|
36
|
+
*
|
|
37
|
+
* `--allow-stated-absence` narrows `--strict` back to what it is usually wanted for: it still fails
|
|
38
|
+
* on a missing `reference` and on an ambiguous mode, but accepts a component whose absence a
|
|
39
|
+
* `noReference` explains. That is the posture of a catalog with two doors — one for the components
|
|
40
|
+
* that reproduce a kit set, one for the components of its own library the kit never published —
|
|
41
|
+
* where an exception is a fact about the kit rather than a gap. Without it such a catalog has to
|
|
42
|
+
* drop `--strict` altogether and loses the guard against silence as well. No effect without
|
|
43
|
+
* `--strict`.
|
|
35
44
|
*/
|
|
36
45
|
import fs from "node:fs";
|
|
37
46
|
import path from "node:path";
|
|
@@ -51,6 +60,16 @@ const VARIANTS_OUT = arg("variants", "design-map-variants.json");
|
|
|
51
60
|
const PREFIX = arg("prefix", "catalog");
|
|
52
61
|
const CHECK = process.argv.includes("--check");
|
|
53
62
|
const STRICT = process.argv.includes("--strict");
|
|
63
|
+
const ALLOW_STATED_ABSENCE = process.argv.includes("--allow-stated-absence");
|
|
64
|
+
/**
|
|
65
|
+
* The screen width, in dp, whose capture is the BASE of a breakpoint fan-out.
|
|
66
|
+
*
|
|
67
|
+
* A multipreview that draws one composable at several screen sizes publishes several captures of
|
|
68
|
+
* it, and exactly one can carry the component's design reference — the rest fold under it as size
|
|
69
|
+
* cells. Absent this flag the narrowest wins, which is the size a kit usually draws. Pass it when
|
|
70
|
+
* the kit draws somewhere else, so the base capture and the base reference are the same width.
|
|
71
|
+
*/
|
|
72
|
+
const BASE_BREAKPOINT = Number(arg("base-breakpoint", ""));
|
|
54
73
|
|
|
55
74
|
if (!fs.existsSync(PREVIEWS)) {
|
|
56
75
|
console.error(
|
|
@@ -63,6 +82,9 @@ if (!fs.existsSync(PREVIEWS)) {
|
|
|
63
82
|
const manifest = JSON.parse(fs.readFileSync(PREVIEWS, "utf8"));
|
|
64
83
|
const { map, variants, diagnostics } = projectDesignMap(manifest.previews ?? [], {
|
|
65
84
|
prefix: PREFIX,
|
|
85
|
+
...(Number.isFinite(BASE_BREAKPOINT) && BASE_BREAKPOINT > 0
|
|
86
|
+
? { baseBreakpointDp: BASE_BREAKPOINT }
|
|
87
|
+
: {}),
|
|
66
88
|
});
|
|
67
89
|
|
|
68
90
|
// Gate BEFORE writing, not after. A run that fails should leave the committed map intact rather
|
|
@@ -71,7 +93,16 @@ const { map, variants, diagnostics } = projectDesignMap(manifest.previews ?? [],
|
|
|
71
93
|
if (STRICT) {
|
|
72
94
|
const missing = [
|
|
73
95
|
...diagnostics.unmapped.map((id) => `${id} — no reference, and no reason given`),
|
|
74
|
-
|
|
96
|
+
// A STATED absence is a gap under plain --strict and not under
|
|
97
|
+
// `--strict --allow-stated-absence`. The two postures are both real: a catalog whose inventory
|
|
98
|
+
// is exactly the kit's wants no exceptions at all, while one that also publishes components of
|
|
99
|
+
// its own library that the kit never drew (wear-m3-catalog's `ButtonGroup`, `Scaffold`) wants
|
|
100
|
+
// strictness about SILENCE without being failed by the four cases somebody already looked at
|
|
101
|
+
// and wrote down. Without the opt-in those catalogs cannot use --strict at all, which costs
|
|
102
|
+
// them the guard against silence too — the thing --strict was actually for.
|
|
103
|
+
...(ALLOW_STATED_ABSENCE
|
|
104
|
+
? []
|
|
105
|
+
: diagnostics.statedAbsent.map((s) => `${s.componentId} — ${s.reason}`)),
|
|
75
106
|
// An ambiguous mode is the third way a component ends up outside the map, and the quietest:
|
|
76
107
|
// the reference is there, but nothing says which capture it pairs with, so the component is
|
|
77
108
|
// simply absent. Under --strict that is as much a gap as a missing reference.
|
|
@@ -90,7 +121,10 @@ if (STRICT) {
|
|
|
90
121
|
for (const line of missing) console.error(` - ${line}`);
|
|
91
122
|
console.error(
|
|
92
123
|
`A catalog that reproduces a kit has nothing to compare these against — remove them, ` +
|
|
93
|
-
`or drop --strict to publish them unmapped
|
|
124
|
+
`or drop --strict to publish them unmapped` +
|
|
125
|
+
(ALLOW_STATED_ABSENCE
|
|
126
|
+
? `.`
|
|
127
|
+
: `, or pass --allow-stated-absence to accept the ones a noReference explains.`),
|
|
94
128
|
);
|
|
95
129
|
process.exit(1);
|
|
96
130
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yschimke/compose-design-map",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "Project a compose-preview discovery manifest into design-parity's design-map.json, plus a sidecar of unresolved variant declarations. Dependency-free.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|