@yschimke/compose-design-map 1.19.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 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
- return modes.size === 1 ? [...modes][0] : null;
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 mode = preferredMode(modes);
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
- if (!isOverrideVariant && !isCatalogVariant) continue;
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,7 +484,7 @@ 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 = [];
@@ -5,6 +5,7 @@
5
5
  * npx @yschimke/compose-design-map [--previews <path>] [--out design-map.json]
6
6
  * [--variants design-map-variants.json] [--prefix catalog]
7
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
  *
@@ -60,6 +61,15 @@ const PREFIX = arg("prefix", "catalog");
60
61
  const CHECK = process.argv.includes("--check");
61
62
  const STRICT = process.argv.includes("--strict");
62
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", ""));
63
73
 
64
74
  if (!fs.existsSync(PREVIEWS)) {
65
75
  console.error(
@@ -72,6 +82,9 @@ if (!fs.existsSync(PREVIEWS)) {
72
82
  const manifest = JSON.parse(fs.readFileSync(PREVIEWS, "utf8"));
73
83
  const { map, variants, diagnostics } = projectDesignMap(manifest.previews ?? [], {
74
84
  prefix: PREFIX,
85
+ ...(Number.isFinite(BASE_BREAKPOINT) && BASE_BREAKPOINT > 0
86
+ ? { baseBreakpointDp: BASE_BREAKPOINT }
87
+ : {}),
75
88
  });
76
89
 
77
90
  // Gate BEFORE writing, not after. A run that fails should leave the committed map intact rather
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yschimke/compose-design-map",
3
- "version": "1.19.0",
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",