@yschimke/compose-design-map 1.14.1 → 1.16.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
@@ -122,10 +122,21 @@ written when nothing declares an axis.
122
122
 
123
123
  ## Two things worth knowing
124
124
 
125
- **Only the light capture is mapped.** One entry per component, not per rendered mode, and the light
126
- one because that is the mode design kits draw their frames in. Diffing a dark render against a
125
+ **One capture per component is mapped, not one per rendered mode** a component maps to a single
126
+ design node. Where a composable publishes a themed pair, the **light** capture is the one that
127
+ pairs, because that is the mode design kits draw their frames in: diffing a dark render against a
127
128
  light reference reports the whole palette as a finding.
128
129
 
130
+ Where it publishes exactly **one** mode, that one pairs, whatever it is. A dark-first catalog — a
131
+ Wear watch face is a black screen, so its component multipreview is a single dark capture — names no
132
+ `Light` capture anywhere, and demanding one used to project the whole catalog to an empty map: a
133
+ file reading as "nothing here corresponds to the kit" rather than "the projector could not see
134
+ these", which `--strict` could not fire on either.
135
+
136
+ Several modes with **no light among them** is the one case that stays unmapped. Picking one would be
137
+ guessing which of `Dark` and `Coral` the kit drew, so those components are reported
138
+ (`diagnostics.ambiguousMode`, and a `--strict` failure) rather than paired at random.
139
+
129
140
  **`overrides.props` beats `overrides.seeds` where both exist.** They are not the same list. `seeds`
130
141
  holds only the values that differ from the composable's defaults; `props` — emitted for a
131
142
  `@PreviewAxis` cross product — carries the full axis assignment, defaults included. A cell that
package/design-map.mjs CHANGED
@@ -59,16 +59,109 @@
59
59
  export const DESIGN_MAP_VARIANTS_SCHEMA = "compose-preview-design-map-variants/v1";
60
60
 
61
61
  /**
62
- * The capture a component's base reference pairs with.
62
+ * The mode a capture is drawn in, when a design map should prefer one: the mode design kits draw
63
+ * their frames in. Diffing a dark render against a light reference reports the whole palette as a
64
+ * finding, so where a light capture exists it is the one that pairs with the reference.
65
+ */
66
+ const LIGHT_MODE = "Light";
67
+
68
+ /** The tag discovery puts in the id of an `@OverrideVariant` reseed: `…_VARIANT_<name>`. */
69
+ const VARIANT_TAG = "_VARIANT_";
70
+
71
+ /** Whether a capture is an `@OverrideVariant` reseed rather than a base capture. */
72
+ function isVariantCapture(preview) {
73
+ return String(preview.id ?? "").includes(VARIANT_TAG);
74
+ }
75
+
76
+ /**
77
+ * A capture's id split into the composable it captures and the mode it was drawn in.
63
78
  *
64
- * One entry per component, not per rendered mode and the LIGHT capture, because that is the mode
65
- * design kits draw their frames in. Diffing a dark render against a light reference reports the
66
- * whole palette as a finding.
79
+ * Discovery builds an id as `<class>.<function>[_<mode>][_VARIANT_<name>]`, where the mode segment
80
+ * is the `@Preview` name a multipreview gives the capture `Light` / `Dark` for a themed pair, and
81
+ * EMPTY for an unnamed single capture. Splitting on the function name rather than pattern-matching
82
+ * the tail is what lets a dark-first catalog be recognised at all: its ids carry no mode segment to
83
+ * match against.
84
+ *
85
+ * A capture whose id does not contain its own function name is left as its own subject with an
86
+ * empty mode — it cannot be grouped with anything, so it selects itself.
67
87
  */
68
- const LIGHT_CAPTURE = /_Light$/;
88
+ export function captureIdentity(preview) {
89
+ const head = String(preview.id ?? "").split(VARIANT_TAG)[0];
90
+ const marker = preview.functionName ? `.${preview.functionName}` : null;
91
+ const at = marker ? head.lastIndexOf(marker) : -1;
92
+ if (at < 0) return { subject: head, mode: "" };
93
+ const cut = at + marker.length;
94
+ return { subject: head.slice(0, cut), mode: head.slice(cut).replace(/^_/, "") };
95
+ }
96
+
97
+ /**
98
+ * The one mode of a composable's captures that pairs with its design reference, or `null` when the
99
+ * captures do not say which that would be.
100
+ *
101
+ * LIGHT wins whenever it is published, which is every catalog that renders a themed pair — design
102
+ * kits draw their frames in light mode, so a dark render diffed against a light reference reports
103
+ * the whole palette as a finding.
104
+ *
105
+ * A composable that publishes exactly ONE mode pairs with that one, whatever it is. A dark-first
106
+ * catalog — a Wear watch face is a black screen, so its component multipreview is a single dark
107
+ * capture — names no `Light` capture anywhere, and demanding one projected it to an empty map: a
108
+ * file that reads as "nothing here corresponds to the kit" rather than "the projector could not see
109
+ * these", and that `--strict` cannot fire on either, since there is nothing to be strict about
110
+ * (compose-ai-tools#4192).
111
+ *
112
+ * Several modes with no light among them is the case that stays unselected. Picking one would be
113
+ * guessing which of `Dark` and `Coral` the kit drew, and pairing the wrong one diffs a whole
114
+ * palette — so it is reported instead (`diagnostics.ambiguousMode`).
115
+ */
116
+ function preferredMode(modes) {
117
+ if (modes.has(LIGHT_MODE)) return LIGHT_MODE;
118
+ return modes.size === 1 ? [...modes][0] : null;
119
+ }
69
120
 
70
- /** A light capture that is also an `@OverrideVariant` render: `…_Light_VARIANT_<name>`. */
71
- const LIGHT_VARIANT_CAPTURE = /_Light_VARIANT_/;
121
+ /**
122
+ * Which capture of each composable participates in the projection — one per composable, never one
123
+ * per rendered mode, since a component maps to a single design node.
124
+ *
125
+ * @returns {{participates: (preview: object) => boolean, ambiguous: Array<object>}}
126
+ */
127
+ export function selectCaptures(previews) {
128
+ const modesBySubject = new Map();
129
+ const componentsBySubject = new Map();
130
+ for (const preview of previews) {
131
+ if (!preview?.catalog) continue;
132
+ const { subject, mode } = captureIdentity(preview);
133
+ const modes = modesBySubject.get(subject) ?? new Set();
134
+ modes.add(mode);
135
+ modesBySubject.set(subject, modes);
136
+ const ids = componentsBySubject.get(subject) ?? new Set();
137
+ if (preview.catalog.componentId) ids.add(preview.catalog.componentId);
138
+ componentsBySubject.set(subject, ids);
139
+ }
140
+
141
+ const chosen = new Map();
142
+ const ambiguous = [];
143
+ for (const [subject, modes] of modesBySubject) {
144
+ const mode = preferredMode(modes);
145
+ if (mode === null) {
146
+ ambiguous.push({
147
+ subject,
148
+ componentIds: [...(componentsBySubject.get(subject) ?? [])].sort(),
149
+ modes: [...modes].sort(),
150
+ });
151
+ } else {
152
+ chosen.set(subject, mode);
153
+ }
154
+ }
155
+ ambiguous.sort((a, b) => a.subject.localeCompare(b.subject));
156
+
157
+ return {
158
+ ambiguous,
159
+ participates(preview) {
160
+ const { subject, mode } = captureIdentity(preview);
161
+ return chosen.has(subject) && chosen.get(subject) === mode;
162
+ },
163
+ };
164
+ }
72
165
 
73
166
  /** design-parity addresses a code subject as `<path>#<function>`. */
74
167
  export function codeHandle(preview, { prefix = "catalog" } = {}) {
@@ -263,7 +356,7 @@ export function declarationMisses(preview) {
263
356
  * this projection, which is why a FAB size axis read as unauthored while `FabSmall`/`FabMedium`/
264
357
  * `FabLarge` sat in the catalog all along.
265
358
  */
266
- export function variantRendersByComponent(previews) {
359
+ export function variantRendersByComponent(previews, selection = selectCaptures(previews)) {
267
360
  const byComponent = new Map();
268
361
  for (const preview of previews) {
269
362
  const catalog = preview.catalog;
@@ -272,19 +365,18 @@ export function variantRendersByComponent(previews) {
272
365
  // An `@OverrideVariant` render is a reseed of the SAME composable, so it keeps the parent's
273
366
  // COMPONENT role and is distinguished only by the `_VARIANT_` tag discovery puts in its id.
274
367
  // A `@CatalogVariant` render is its own composable, so it carries the VARIANT role and an
275
- // ordinary light-capture id.
368
+ // ordinary base-capture id.
276
369
  //
277
370
  // A VARIANT role with a `_VARIANT_` id is the third case, and it used to fall through both
278
371
  // tests into the `continue` below: a folded component carrying a matrix of its own. Discovery
279
372
  // emitted those renders all along — they were simply never projected, so the kit nodes they
280
373
  // sit on went uncompared, and a component could not be folded without deleting its cells.
281
- // Either way only the light capture participates.
282
- const isOverrideVariant =
283
- catalog.role === "COMPONENT" && LIGHT_VARIANT_CAPTURE.test(preview.id);
284
- const isCatalogVariant =
285
- catalog.role === "VARIANT" &&
286
- (LIGHT_CAPTURE.test(preview.id) || LIGHT_VARIANT_CAPTURE.test(preview.id));
374
+ // Either way only the selected capture participates — one declaration per variant, in the same
375
+ // mode its component's base reference pairs with.
376
+ const isOverrideVariant = catalog.role === "COMPONENT" && isVariantCapture(preview);
377
+ const isCatalogVariant = catalog.role === "VARIANT";
287
378
  if (!isOverrideVariant && !isCatalogVariant) continue;
379
+ if (!selection.participates(preview)) continue;
288
380
 
289
381
  // A variant that names no axis says only "this is different", which is not enough to look
290
382
  // anything up in a kit. Dropped rather than guessed at from the function name.
@@ -309,7 +401,8 @@ export function variantRendersByComponent(previews) {
309
401
  * fact to report, not a failure.
310
402
  */
311
403
  export function projectDesignMap(previews, opts = {}) {
312
- const variantRenders = variantRendersByComponent(previews);
404
+ const selection = selectCaptures(previews);
405
+ const variantRenders = variantRendersByComponent(previews, selection);
313
406
 
314
407
  const components = [];
315
408
  const declarations = [];
@@ -326,7 +419,7 @@ export function projectDesignMap(previews, opts = {}) {
326
419
  for (const preview of previews) {
327
420
  const catalog = preview.catalog;
328
421
  if (!catalog || catalog.role !== "COMPONENT") continue;
329
- if (!LIGHT_CAPTURE.test(preview.id)) continue;
422
+ if (isVariantCapture(preview) || !selection.participates(preview)) continue;
330
423
 
331
424
  if (!catalog.reference) {
332
425
  if (catalog.noReference) {
@@ -372,11 +465,7 @@ export function projectDesignMap(previews, opts = {}) {
372
465
  // Only the captures that participate: a variant declares once, and reporting its dark capture
373
466
  // beside its light one would double every line of a list that exists to be acted on.
374
467
  const unplacedDeclarations = previews
375
- .filter(
376
- (preview) =>
377
- preview.catalog &&
378
- (LIGHT_CAPTURE.test(preview.id) || LIGHT_VARIANT_CAPTURE.test(preview.id)),
379
- )
468
+ .filter((preview) => preview.catalog && selection.participates(preview))
380
469
  .flatMap(declarationMisses);
381
470
 
382
471
  return {
@@ -386,6 +475,10 @@ export function projectDesignMap(previews, opts = {}) {
386
475
  unmapped,
387
476
  statedAbsent,
388
477
  unplacedDeclarations,
478
+ // Composables whose captures name no mode a reference could pair with — several modes, none
479
+ // of them light. Reported rather than guessed at: pairing `Dark` when the kit drew `Coral`
480
+ // diffs a whole palette.
481
+ ambiguousMode: selection.ambiguous,
389
482
  variantRenders: declarations.reduce((n, d) => n + d.renders.length, 0),
390
483
  withSet: components.filter((c) => c.refSet).length,
391
484
  },
@@ -28,9 +28,10 @@
28
28
  * `--strict` is the opposite posture, for a catalog whose whole purpose is to reproduce a kit —
29
29
  * there, a component with no kit node to compare against does not belong in the published
30
30
  * inventory at all, and publishing it means shipping a sticker that can never be checked. It gates
31
- * on BOTH kinds of absence: a missing `reference`, and one explained by `noReference`. The
32
- * annotation still earns its keep in the default mode, where the two are reported apart so a
33
- * retired pattern does not read as neglect; `--strict` simply says there are no exceptions.
31
+ * on EVERY kind of absence: a missing `reference`, one explained by `noReference`, and a component
32
+ * whose captures name no mode the reference could pair with. The annotation still earns its keep in
33
+ * the default mode, where the three are reported apart so a retired pattern does not read as
34
+ * neglect; `--strict` simply says there are no exceptions.
34
35
  */
35
36
  import fs from "node:fs";
36
37
  import path from "node:path";
@@ -71,11 +72,20 @@ if (STRICT) {
71
72
  const missing = [
72
73
  ...diagnostics.unmapped.map((id) => `${id} — no reference, and no reason given`),
73
74
  ...diagnostics.statedAbsent.map((s) => `${s.componentId} — ${s.reason}`),
75
+ // An ambiguous mode is the third way a component ends up outside the map, and the quietest:
76
+ // the reference is there, but nothing says which capture it pairs with, so the component is
77
+ // simply absent. Under --strict that is as much a gap as a missing reference.
78
+ ...diagnostics.ambiguousMode.map(
79
+ (a) =>
80
+ `${a.componentIds.join(", ") || a.subject} — captures ${a.modes
81
+ .map((m) => m || "(unnamed)")
82
+ .join(", ")}, none of them Light, so none pairs with the reference`,
83
+ ),
74
84
  ];
75
85
  if (missing.length) {
76
86
  console.error(
77
- `::error::--strict: ${missing.length} component(s) carry no ` +
78
- `@CatalogComponent(reference = …):`,
87
+ `::error::--strict: ${missing.length} component(s) reach no design reference — no ` +
88
+ `@CatalogComponent(reference = …), or none their captures can pair with:`,
79
89
  );
80
90
  for (const line of missing) console.error(` - ${line}`);
81
91
  console.error(
@@ -162,6 +172,19 @@ if (diagnostics.unplacedDeclarations?.length) {
162
172
  }
163
173
  }
164
174
 
175
+ if (diagnostics.ambiguousMode?.length) {
176
+ console.log(
177
+ `\n${diagnostics.ambiguousMode.length} composable(s) publish several capture modes with no ` +
178
+ `Light among them, so which one the reference pairs with is undeclared, and they were ` +
179
+ `skipped. Rendering a single mode makes it the one that pairs; naming one of them "Light" ` +
180
+ `picks it explicitly:`,
181
+ );
182
+ for (const a of diagnostics.ambiguousMode) {
183
+ const who = a.componentIds.join(", ") || a.subject;
184
+ console.log(` - ${who} — ${a.modes.map((m) => m || "(unnamed)").join(", ")}`);
185
+ }
186
+ }
187
+
165
188
  if (diagnostics.unmapped.length) {
166
189
  console.log(
167
190
  `\n${diagnostics.unmapped.length} component(s) carry neither ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yschimke/compose-design-map",
3
- "version": "1.14.1",
3
+ "version": "1.16.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",