@yschimke/compose-design-map 1.11.1 → 1.13.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
@@ -26,7 +26,7 @@ Every field the projection reads is defined in this repository:
26
26
  | Field on `previews.json` | Declared by |
27
27
  | --- | --- |
28
28
  | `catalog.reference`, `referenceSet`, `noReference`, `referenceContentsOnly`, `kitAxis` | [`@CatalogComponent`](https://github.com/yschimke/compose-ai-tools/blob/main/api/preview-annotations/src/commonMain/kotlin/ee/schimke/composeai/preview/CatalogComponent.kt) |
29
- | `catalog.props`, `catalog.state` | `@CatalogVariant` |
29
+ | `catalog.props`, `catalog.state`, `catalog.kitValue` | `@CatalogVariant` |
30
30
  | `overrides.seeds`, `overrides.props` | `@OverrideVariant` / `@PreviewAxis` |
31
31
  | `overrides.kitAxis`, `overrides.kitValue` | `@OverrideVariant` |
32
32
 
@@ -67,6 +67,29 @@ Figma concern, it needs a Figma credential to derive, and it differs per kit —
67
67
  repo has any business holding. So the variant renders come out as **declarations** and a resolver
68
68
  that owns a kit index turns them into node ids.
69
69
 
70
+ ### When the catalog knows the kit's word for it
71
+
72
+ Some values no translation table reaches: the Material 3 kit files one date-picker variant as
73
+ `Type=Full-screen (range)`, and `type=range` finds nothing against it. `kitAxis` / `kitValue` are
74
+ how a variant names both sides — the Compose word in `props`/`strings`, the kit's word beside it —
75
+ and this projection carries them onto the seed so the resolver can prefer them over its own tables:
76
+
77
+ ```kotlin
78
+ @CatalogVariant(of = "DatePicker/Modal", props = ["type=range"],
79
+ kitAxis = "Type", kitValue = "Full-screen (range)")
80
+ ```
81
+
82
+ ```jsonc
83
+ { "seeds": [{ "key": "type", "raw": "range",
84
+ "kitAxis": "Type", "kitValue": "Full-screen (range)" }] }
85
+ ```
86
+
87
+ Projecting is not translating: nothing here checks a declaration against a kit, because there is no
88
+ kit here to check against. The one thing it does judge is whether the declaration can be *placed* —
89
+ the annotation carries one pair per variant, so a cell seeding two knobs gives no way to say which
90
+ one the axis names. Those are reported and dropped rather than guessed at, since guessing pins the
91
+ wrong axis and resolves, confidently, to the wrong node.
92
+
70
93
  The two halves are separable because the first is useful alone: a repo with no kit index still gets
71
94
  a valid map of base references, which is most of the value at none of the cost.
72
95
 
package/design-map.mjs CHANGED
@@ -34,6 +34,13 @@
34
34
  * [`@design-parity/kit-index`](https://github.com/yschimke/design-parity/tree/main/packages/kit-index)
35
35
  * does hold.
36
36
  *
37
+ * What it does carry across is the catalog's own statement about the kit, when it makes one:
38
+ * `@OverrideVariant(kitAxis = "Show avatar", kitValue = "True")` names the kit's spelling directly,
39
+ * and those names ride on the seed into the sidecar for a resolver to prefer over its alias tables.
40
+ * Projecting them is not translating them — this module never checks a declaration against a kit,
41
+ * because it has no kit to check against; it only puts the author's words where the resolver can
42
+ * read them. They were dead metadata until it did (compose-ai-tools#4086).
43
+ *
37
44
  * So the variant renders come out as **declarations**, in a sidecar
38
45
  * ({@link DESIGN_MAP_VARIANTS_SCHEMA}): "this preview is the same component with these knobs
39
46
  * turned". A resolver that owns a kit index turns each into a tagged `ref`/`previewId` pair beside
@@ -98,6 +105,35 @@ export function sourceForRef(ref) {
98
105
  * its non-default seeds is missing the axes it happens to sit at, and a kit that spells its default
99
106
  * size explicitly in a combination cell then has nothing to match against.
100
107
  */
108
+ /**
109
+ * Attach a kit-side declaration to the one seed it can belong to.
110
+ *
111
+ * `kitAxis` / `kitValue` name the design kit's own spelling for *a* knob — `content=avatar` is the
112
+ * kit's `Show avatar=True` — and the annotation carries one pair per variant, so a variant that
113
+ * turns two knobs gives no way to say which of them the axis names. It attaches to a lone seed;
114
+ * with several, the declaration is reported and dropped rather than guessed at, since guessing
115
+ * would pin the wrong axis and resolve to a confidently wrong node.
116
+ *
117
+ * A `null` declaration (neither field) leaves the seeds exactly as they were, which is every
118
+ * variant written before these fields existed.
119
+ */
120
+ function declareKitNames(seeds, kitAxis, kitValue) {
121
+ if (!kitAxis && !kitValue) return { seeds, unattached: [] };
122
+ if (seeds.length !== 1) {
123
+ return { seeds, unattached: [{ kitAxis, kitValue, seeds: seeds.map((s) => s.key) }] };
124
+ }
125
+ return {
126
+ seeds: [
127
+ {
128
+ ...seeds[0],
129
+ ...(kitAxis ? { kitAxis } : {}),
130
+ ...(kitValue ? { kitValue } : {}),
131
+ },
132
+ ],
133
+ unattached: [],
134
+ };
135
+ }
136
+
101
137
  function foldSeeds(catalog) {
102
138
  // `props` names the axis; `state` is the annotation's shorthand for the one axis common enough
103
139
  // to have its own parameter. Either is a declaration, so neither is inferred —
@@ -107,11 +143,15 @@ function foldSeeds(catalog) {
107
143
  if (catalog.state && !props.some((p) => p.key === "state")) {
108
144
  props.push({ key: "state", value: catalog.state });
109
145
  }
110
- return props.map((p) => ({ key: p.key, raw: p.value }));
146
+ return declareKitNames(
147
+ props.map((p) => ({ key: p.key, raw: p.value })),
148
+ catalog.kitAxis,
149
+ catalog.kitValue,
150
+ );
111
151
  }
112
152
 
113
- function cellSeeds(overrides) {
114
- if (!overrides) return [];
153
+ function cellSeeds(overrides, catalog) {
154
+ if (!overrides) return { seeds: [], unattached: [] };
115
155
 
116
156
  const seeds = overrides.props?.length
117
157
  ? overrides.props.map((p) => ({ key: p.key, raw: p.value }))
@@ -124,16 +164,42 @@ function cellSeeds(overrides) {
124
164
  // that knob already has. Without this the variant declares nothing: `seeds` is empty, an empty
125
165
  // vector matches every sibling, and the render is dropped as "names no axis".
126
166
  const interaction = overrides.interaction;
127
- if (interaction && interaction !== "None" && !seeds.some((s) => s.key === "state")) {
128
- seeds.push({ key: "state", raw: String(interaction).toLowerCase() });
129
- }
130
- return seeds;
167
+ const drivenState =
168
+ interaction && interaction !== "None" && !seeds.some((s) => s.key === "state")
169
+ ? { key: "state", raw: String(interaction).toLowerCase() }
170
+ : undefined;
171
+
172
+ // A COMPONENT's own `kitAxis` is a DEFAULT for its cells — "every variant of this one turns the
173
+ // same kit property" — so a cell that names its own axis wins, and a cell that names only its
174
+ // exceptional VALUE still inherits the axis, which is the case the default exists for. A default
175
+ // that cannot be placed is silent, where the explicit form is reported: one is a blanket that
176
+ // need not cover everything, the other is an assertion about this cell that could not be
177
+ // honoured.
178
+ const componentDefault = catalog?.role === "COMPONENT" ? catalog.kitAxis : undefined;
179
+ const axis = overrides.kitAxis ?? componentDefault;
180
+ const explicit = Boolean(overrides.kitAxis || overrides.kitValue);
181
+
182
+ // The interaction axis is not a knob anybody seeded — the harness drives it — so it does not
183
+ // count towards "which knob does this declaration name". A cell seeding `size=l` and pressing
184
+ // the component still names one knob, and its declaration belongs to that one. Only an
185
+ // interaction-only cell has the state seed as its subject.
186
+ const declarable = seeds.length ? seeds : drivenState ? [drivenState] : [];
187
+ const declared = explicit
188
+ ? declareKitNames(declarable, axis, overrides.kitValue)
189
+ : axis && declarable.length === 1
190
+ ? declareKitNames(declarable, axis, undefined)
191
+ : { seeds: declarable, unattached: [] };
192
+
193
+ return {
194
+ seeds: seeds.length && drivenState ? [...declared.seeds, drivenState] : declared.seeds,
195
+ unattached: declared.unattached,
196
+ };
131
197
  }
132
198
 
133
199
  export function variantSeeds(preview) {
134
200
  const catalog = preview.catalog;
135
- const fold = catalog?.role === "VARIANT" ? foldSeeds(catalog) : [];
136
- const cell = cellSeeds(preview.overrides);
201
+ const { seeds: fold } = catalog?.role === "VARIANT" ? foldSeeds(catalog) : { seeds: [] };
202
+ const { seeds: cell } = cellSeeds(preview.overrides, catalog);
137
203
  if (!fold.length) return cell;
138
204
  if (!cell.length) return fold;
139
205
 
@@ -144,8 +210,17 @@ export function variantSeeds(preview) {
144
210
  //
145
211
  // The CELL wins a key collision. Both describe the same render, but the cell's value is what the
146
212
  // renderer actually seeded, and the fold's is the default it seeded over.
213
+ //
214
+ // Its kit AXIS survives that, though. The axis is a fact about the knob — what the kit calls the
215
+ // thing being turned — and the collision only changes which way it is turned, so dropping the
216
+ // fold's seed wholesale would lose the one name a resolver cannot work out for itself, silently.
217
+ // The fold's `kitValue` does not survive: it described the value the cell has just replaced.
218
+ const foldAxes = new Map(fold.filter((s) => s.kitAxis).map((s) => [s.key, s.kitAxis]));
219
+ const merged = cell.map((s) =>
220
+ !s.kitAxis && foldAxes.has(s.key) ? { ...s, kitAxis: foldAxes.get(s.key) } : s,
221
+ );
147
222
  const seeded = new Set(cell.map((s) => s.key));
148
- return [...fold.filter((s) => !seeded.has(s.key)), ...cell];
223
+ return [...fold.filter((s) => !seeded.has(s.key)), ...merged];
149
224
  }
150
225
 
151
226
  /** The name a variant render goes by, for a report and for the design-map `state` slot. */
@@ -156,12 +231,31 @@ function variantName(preview, seeds) {
156
231
  // Named for the FOLD's own axis, not for the merged vector — `wave`, not `wave-1.0` — so a
157
232
  // folded component's cells read as `wave-full` / `wave-quarter` under it, the same shape a
158
233
  // top-level component's cells have.
159
- const fold = catalog.state ?? foldSeeds(catalog).map((s) => s.raw).join("-");
234
+ const fold =
235
+ catalog.state ??
236
+ foldSeeds(catalog)
237
+ .seeds.map((s) => s.raw)
238
+ .join("-");
160
239
  return cell ? `${fold}-${cell}` : fold;
161
240
  }
162
241
  return cell ?? seeds.map((s) => `${s.key}=${s.raw}`).join(", ");
163
242
  }
164
243
 
244
+ /**
245
+ * Declarations this render could not place on a seed, one entry per variant that names a kit axis
246
+ * or value it has more than one knob to hang it on.
247
+ *
248
+ * Reported rather than silently dropped: somebody took the trouble to spell the kit's own name,
249
+ * and silence would leave them believing the render compares against a node it never reached —
250
+ * the exact failure `kitAxis` exists to remove.
251
+ */
252
+ export function declarationMisses(preview) {
253
+ const catalog = preview.catalog;
254
+ const fold = catalog?.role === "VARIANT" ? foldSeeds(catalog).unattached : [];
255
+ const cell = cellSeeds(preview.overrides, catalog).unattached;
256
+ return [...fold, ...cell].map((miss) => ({ previewId: preview.id, ...miss }));
257
+ }
258
+
165
259
  /**
166
260
  * Every variant render, grouped by the component it folds under.
167
261
  *
@@ -275,12 +369,23 @@ export function projectDesignMap(previews, opts = {}) {
275
369
  unmapped.sort();
276
370
  statedAbsent.sort((a, b) => a.componentId.localeCompare(b.componentId));
277
371
 
372
+ // Only the captures that participate: a variant declares once, and reporting its dark capture
373
+ // beside its light one would double every line of a list that exists to be acted on.
374
+ const unplacedDeclarations = previews
375
+ .filter(
376
+ (preview) =>
377
+ preview.catalog &&
378
+ (LIGHT_CAPTURE.test(preview.id) || LIGHT_VARIANT_CAPTURE.test(preview.id)),
379
+ )
380
+ .flatMap(declarationMisses);
381
+
278
382
  return {
279
383
  map: { components },
280
384
  variants: { schema: DESIGN_MAP_VARIANTS_SCHEMA, components: declarations },
281
385
  diagnostics: {
282
386
  unmapped,
283
387
  statedAbsent,
388
+ unplacedDeclarations,
284
389
  variantRenders: declarations.reduce((n, d) => n + d.renders.length, 0),
285
390
  withSet: components.filter((c) => c.refSet).length,
286
391
  },
@@ -144,6 +144,24 @@ if (diagnostics.statedAbsent.length) {
144
144
  }
145
145
  }
146
146
 
147
+ if (diagnostics.unplacedDeclarations?.length) {
148
+ console.log(
149
+ `\n${diagnostics.unplacedDeclarations.length} variant(s) name a kit axis or value that could ` +
150
+ `not be placed: the annotation carries one kitAxis/kitValue and the variant turns more than ` +
151
+ `one knob, so which knob the axis names is undeclared. Split the cell, or drop the kit ` +
152
+ `names and let the resolver match on the knob's own spelling:`,
153
+ );
154
+ for (const miss of diagnostics.unplacedDeclarations) {
155
+ const named = [
156
+ miss.kitAxis ? `kitAxis = "${miss.kitAxis}"` : null,
157
+ miss.kitValue ? `kitValue = "${miss.kitValue}"` : null,
158
+ ]
159
+ .filter(Boolean)
160
+ .join(", ");
161
+ console.log(` - ${miss.previewId} — ${named} against ${miss.seeds.join(", ")}`);
162
+ }
163
+ }
164
+
147
165
  if (diagnostics.unmapped.length) {
148
166
  console.log(
149
167
  `\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.11.1",
3
+ "version": "1.13.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",