konpeki 0.1.1 → 0.3.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.
Files changed (53) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.md +57 -21
  4. package/SECURITY.md +22 -0
  5. package/SETUP.md +112 -51
  6. package/composition/README.md +42 -52
  7. package/composition/compile.ts +2 -2
  8. package/composition/document.ts +2 -111
  9. package/composition/schema.json +12 -3
  10. package/composition/schema.ts +4 -2
  11. package/composition/types.ts +4 -21
  12. package/composition/validate.ts +3 -461
  13. package/composition/visualizations.ts +0 -16
  14. package/design/themes/README.md +25 -7
  15. package/design/themes/index.ts +15 -4
  16. package/docs/development.md +75 -11
  17. package/docs/workflow.md +60 -11
  18. package/index.html +17 -1
  19. package/package.json +10 -2
  20. package/plugin.json +22 -0
  21. package/public/og.png +0 -0
  22. package/runtime/konpeki.mjs +223 -473
  23. package/skills/konpeki/SKILL.md +186 -0
  24. package/skills/konpeki/assets/blank.json +23 -0
  25. package/skills/konpeki/scripts/ensure-runtime.mjs +69 -0
  26. package/skills/konpeki/scripts/prepare-document.mjs +40 -0
  27. package/slides/introducing-konpeki/PROMPT.md +21 -0
  28. package/slides/introducing-konpeki/README.md +52 -30
  29. package/slides/introducing-konpeki/SOURCE.md +16 -10
  30. package/slides/introducing-konpeki/author.ts +44 -42
  31. package/slides/introducing-konpeki/composition.json +1748 -1743
  32. package/src/app/App.tsx +179 -37
  33. package/src/components/BuildOrb.tsx +40 -0
  34. package/src/components/Canvas.tsx +18 -1
  35. package/src/components/InspectorPanel.tsx +130 -43
  36. package/src/components/LeftPanel.tsx +16 -3
  37. package/src/components/RevisionNotes.tsx +56 -0
  38. package/src/components/WorkspaceChrome.tsx +98 -11
  39. package/src/lib/examples/react-page-migration.json +1 -1
  40. package/src/lib/export-png.ts +4 -1
  41. package/src/lib/file-session.ts +14 -1
  42. package/src/lib/review.ts +26 -0
  43. package/src/lib/storage.ts +38 -10
  44. package/src/lib/use-file-session.ts +49 -13
  45. package/src/main.tsx +3 -0
  46. package/src/styles/base.css +16 -0
  47. package/src/styles/canvas.css +43 -12
  48. package/src/styles/chrome.css +174 -52
  49. package/src/styles/feedback.css +39 -4
  50. package/src/styles/left-panel.css +48 -7
  51. package/src/styles/right-panel.css +90 -47
  52. package/src/styles/shell.css +99 -53
  53. package/.agents/skills/authoring-visuals/SKILL.md +0 -82
@@ -1,9 +1,6 @@
1
1
  import { Ajv2020 } from "ajv/dist/2020.js";
2
2
  import { schema } from "./schema.ts";
3
- import { migrateLegacyDiagramType } from "./visualizations.ts";
4
3
  import {
5
- compositionSchema,
6
- legacyCompositionSchemas,
7
4
  type CompositionDocument,
8
5
  } from "./types.ts";
9
6
  import { vectorAttributeNames } from "./vector.ts";
@@ -17,461 +14,8 @@ const structural = new Ajv2020({
17
14
  allErrors: true,
18
15
  strict: false,
19
16
  }).compile<CompositionDocument>(schema);
20
- function migratePreV5Document(input: Record<string, unknown>) {
21
- const slide = input.slide as Record<string, unknown> | undefined;
22
- const components = Array.isArray(slide?.components)
23
- ? slide.components.map((value: unknown) => {
24
- if (!value || typeof value !== "object") return value;
25
- const component = value as Record<string, unknown>;
26
- if (
27
- component.kind !== "callout" &&
28
- component.kind !== "comparison" &&
29
- component.kind !== "text-block"
30
- )
31
- return component;
32
- const previous =
33
- component.appearance && typeof component.appearance === "object"
34
- ? (component.appearance as Record<string, unknown>)
35
- : {};
36
- const common = Object.fromEntries(
37
- ["alignment", "border", "titleStyle", "rule"].flatMap((key) =>
38
- key in previous ? [[key, previous[key]]] : [],
39
- ),
40
- );
41
- const appearance =
42
- component.kind === "callout"
43
- ? {
44
- ...common,
45
- layout: "single",
46
- orientation: "horizontal",
47
- purpose: "emphasis",
48
- treatment: "strong",
49
- }
50
- : component.kind === "comparison"
51
- ? {
52
- ...common,
53
- layout: "two-column",
54
- orientation: "horizontal",
55
- purpose: "comparison",
56
- treatment: "plain",
57
- }
58
- : {
59
- ...previous,
60
- purpose: previous.purpose ?? "narrative",
61
- treatment: previous.treatment ?? "plain",
62
- };
63
- return { ...component, kind: "text-block", appearance };
64
- })
65
- : slide?.components;
66
- return {
67
- ...Object.fromEntries(
68
- Object.entries(input).filter(
69
- ([key]) => key !== "target" && key !== "slide",
70
- ),
71
- ),
72
- schema: "konpeki-composition/v5",
73
- ...(slide
74
- ? { slides: [{ ...slide, name: "Slide 01", components }] }
75
- : {}),
76
- };
77
- }
78
- function migrateV5Document(input: Record<string, unknown>) {
79
- const slides = Array.isArray(input.slides)
80
- ? input.slides.map((value: unknown) => {
81
- if (!value || typeof value !== "object") return value;
82
- const slide = value as Record<string, unknown>;
83
- const previousComponents = Array.isArray(slide.components)
84
- ? (slide.components as Record<string, unknown>[])
85
- : [];
86
- const pageNumber = previousComponents.find(
87
- (component) => component.kind === "page-number",
88
- );
89
- const pageAppearance =
90
- pageNumber?.appearance && typeof pageNumber.appearance === "object"
91
- ? (pageNumber.appearance as Record<string, unknown>)
92
- : {};
93
- const removedIds = new Set(
94
- previousComponents
95
- .filter((component) => component.kind === "page-number")
96
- .map((component) => String(component.id)),
97
- );
98
- const removedSlots = new Set(
99
- previousComponents
100
- .filter((component) => component.kind === "page-number")
101
- .flatMap((component) =>
102
- Array.isArray(component.slotIds)
103
- ? component.slotIds.map(String)
104
- : [],
105
- ),
106
- );
107
- const components = previousComponents.flatMap((component) => {
108
- const appearance =
109
- component.appearance && typeof component.appearance === "object"
110
- ? (component.appearance as Record<string, unknown>)
111
- : {};
112
- if (component.kind === "page-number") return [];
113
- if (component.kind === "evidence")
114
- return [{ ...component, kind: "visual" }];
115
- if (component.kind === "process")
116
- return [
117
- {
118
- ...component,
119
- kind: "diagram",
120
- appearance: { ...appearance, type: "process" },
121
- },
122
- ];
123
- if (component.kind === "system-map")
124
- return [
125
- {
126
- ...component,
127
- kind: "diagram",
128
- appearance: { ...appearance, type: "system" },
129
- },
130
- ];
131
- return [component];
132
- });
133
- const componentIds = new Set(components.map((component) => String(component.id)));
134
- const groups = Array.isArray(slide.groups)
135
- ? (slide.groups as Record<string, unknown>[])
136
- .map((group) => ({
137
- ...group,
138
- childIds: Array.isArray(group.childIds)
139
- ? group.childIds.filter((id) => !removedIds.has(String(id)))
140
- : group.childIds,
141
- }))
142
- .filter(
143
- (group) =>
144
- !Array.isArray(group.childIds) || group.childIds.length > 0,
145
- )
146
- : slide.groups;
147
- const groupIds = new Set(
148
- Array.isArray(groups)
149
- ? groups.map((group) => String(group.id))
150
- : [],
151
- );
152
- const contentSlots = Array.isArray(slide.contentSlots)
153
- ? (slide.contentSlots as Record<string, unknown>[])
154
- .filter((slot) => !removedSlots.has(String(slot.id)))
155
- .map((slot) => {
156
- if (!Array.isArray(slot.targets)) return slot;
157
- const targets = slot.targets.filter(
158
- (target) =>
159
- !removedIds.has(String(target)) &&
160
- !removedSlots.has(String(target)),
161
- );
162
- return {
163
- ...slot,
164
- targets: targets.length
165
- ? targets
166
- : [String(components[0]?.id ?? "")],
167
- };
168
- })
169
- : slide.contentSlots;
170
- return {
171
- ...slide,
172
- pageNumber: {
173
- style: pageNumber ? (pageAppearance.style ?? "01") : "none",
174
- color: pageAppearance.color ?? "muted",
175
- },
176
- components,
177
- contentSlots,
178
- groups,
179
- readingOrder: Array.isArray(slide.readingOrder)
180
- ? (slide.readingOrder as Record<string, unknown>[]).filter((entry) =>
181
- entry.kind === "group"
182
- ? groupIds.has(String(entry.id))
183
- : componentIds.has(String(entry.id)),
184
- )
185
- : slide.readingOrder,
186
- paintOrder: Array.isArray(slide.paintOrder)
187
- ? slide.paintOrder.filter((id) => !removedIds.has(String(id)))
188
- : slide.paintOrder,
189
- relationships: Array.isArray(slide.relationships)
190
- ? (slide.relationships as Record<string, unknown>[]).filter(
191
- (relationship) => {
192
- const from = relationship.from as Record<string, unknown> | undefined;
193
- const to = relationship.to as Record<string, unknown> | undefined;
194
- return (
195
- !removedIds.has(String(from?.nodeId)) &&
196
- !removedIds.has(String(to?.nodeId))
197
- );
198
- },
199
- )
200
- : slide.relationships,
201
- };
202
- })
203
- : input.slides;
204
- return { ...input, schema: compositionSchema, slides };
205
- }
206
- function migrateV7Document(input: Record<string, unknown>) {
207
- const slides = Array.isArray(input.slides)
208
- ? input.slides.map((value: unknown) => {
209
- if (!value || typeof value !== "object") return value;
210
- const slide = value as Record<string, unknown>;
211
- const components = Array.isArray(slide.components)
212
- ? slide.components.map((value: unknown) => {
213
- if (!value || typeof value !== "object") return value;
214
- const component = value as Record<string, unknown>;
215
- const appearance =
216
- component.appearance && typeof component.appearance === "object"
217
- ? (component.appearance as Record<string, unknown>)
218
- : {};
219
- if (component.kind !== "visual" || appearance.template !== "image")
220
- return component;
221
- return {
222
- ...component,
223
- kind: "image",
224
- appearance: {
225
- ...(appearance.border ? { border: appearance.border } : {}),
226
- fit: "contain",
227
- },
228
- };
229
- })
230
- : slide.components;
231
- return { ...slide, components };
232
- })
233
- : input.slides;
234
- return { ...input, schema: compositionSchema, slides };
235
- }
236
- function migrateV10Document(input: Record<string, unknown>) {
237
- const slides = Array.isArray(input.slides)
238
- ? input.slides.map((value: unknown) => {
239
- if (!value || typeof value !== "object") return value;
240
- const slide = value as Record<string, unknown>;
241
- const components = Array.isArray(slide.components)
242
- ? slide.components.map((value: unknown) => {
243
- if (!value || typeof value !== "object") return value;
244
- const component = value as Record<string, unknown>;
245
- const appearance =
246
- component.appearance && typeof component.appearance === "object"
247
- ? (component.appearance as Record<string, unknown>)
248
- : {};
249
- if (component.kind === "headline")
250
- return {
251
- ...component,
252
- kind: "text-block",
253
- appearance: {
254
- ...appearance,
255
- role: "title",
256
- layout: "single",
257
- orientation: "horizontal",
258
- purpose: "narrative",
259
- treatment: "plain",
260
- logicalOrder: "none",
261
- },
262
- };
263
- if (component.kind === "footnote")
264
- return {
265
- ...component,
266
- kind: "text-block",
267
- appearance: {
268
- ...appearance,
269
- role: "footnote",
270
- layout: "single",
271
- orientation: "horizontal",
272
- purpose: "narrative",
273
- treatment: "plain",
274
- logicalOrder: "none",
275
- titleStyle: "plain",
276
- },
277
- };
278
- if (component.kind === "text-block")
279
- return {
280
- ...component,
281
- appearance: { role: "body", ...appearance },
282
- };
283
- if (component.kind === "visual")
284
- return { ...component, kind: "chart" };
285
- if (component.kind === "diagram") {
286
- const { template, ...rest } = appearance;
287
- return {
288
- ...component,
289
- appearance: {
290
- ...rest,
291
- layout: template ?? appearance.layout ?? "linear",
292
- },
293
- };
294
- }
295
- if (component.kind === "icon" || component.kind === "shape") {
296
- const slotIds = Array.isArray(component.slotIds)
297
- ? component.slotIds.map(String)
298
- : [];
299
- const slotId = slotIds[0] ?? `${String(component.id)}-content`;
300
- const primitive = component.kind === "icon"
301
- ? {
302
- kind: "icon" as const,
303
- ...(typeof component.intent === "string" && component.intent.trim()
304
- ? { name: component.intent.trim() }
305
- : {}),
306
- ...(appearance.style ? { style: appearance.style } : {}),
307
- ...(appearance.color ? { color: appearance.color } : {}),
308
- }
309
- : {
310
- kind: "shape" as const,
311
- shape: appearance.shape ?? "rectangle",
312
- ...(appearance.color ? { color: appearance.color } : {}),
313
- ...(appearance.fill ? { fill: appearance.fill } : {}),
314
- };
315
- return {
316
- ...component,
317
- kind: "diagram",
318
- slotIds: slotIds.length ? slotIds : [slotId],
319
- appearance: {
320
- type: "system",
321
- layout: "hub-and-spoke",
322
- border: appearance.border ?? "none",
323
- colorScheme: "accent-with-muted-context",
324
- emphasis: "primary",
325
- density: "sparse",
326
- },
327
- topology: {
328
- kind: "explicit",
329
- nodes: (slotIds.length ? slotIds : [slotId]).map(
330
- (currentSlotId, index) => ({
331
- id: index
332
- ? `${String(component.id)}-slot-${index + 1}`
333
- : String(component.id),
334
- slotId: currentSlotId,
335
- ...(index ? { visible: false } : { primitive }),
336
- ...(component.preferredRect && typeof component.preferredRect === "object"
337
- ? { preferredRect: component.preferredRect }
338
- : {}),
339
- }),
340
- ),
341
- edges: [],
342
- },
343
- };
344
- }
345
- return component;
346
- })
347
- : slide.components;
348
- const contentSlots = Array.isArray(slide.contentSlots)
349
- ? slide.contentSlots.map((value: unknown) => {
350
- if (!value || typeof value !== "object") return value;
351
- const slot = value as Record<string, unknown>;
352
- return slot.role === "icon" || slot.role === "shape"
353
- ? { ...slot, role: "entity" }
354
- : slot;
355
- })
356
- : slide.contentSlots;
357
- return { ...slide, components, contentSlots };
358
- })
359
- : input.slides;
360
- return { ...input, schema: "konpeki-composition/v11", slides };
361
- }
362
- function migrateV11Document(input: Record<string, unknown>) {
363
- const slides = Array.isArray(input.slides)
364
- ? input.slides.map((value: unknown) => {
365
- if (!value || typeof value !== "object") return value;
366
- const slide = value as Record<string, unknown>;
367
- const components = Array.isArray(slide.components)
368
- ? slide.components.map((value: unknown) => {
369
- if (!value || typeof value !== "object") return value;
370
- const component = value as Record<string, unknown>;
371
- if (component.kind !== "diagram") return component;
372
- const appearance =
373
- component.appearance && typeof component.appearance === "object"
374
- ? (component.appearance as Record<string, unknown>)
375
- : {};
376
- const migratedType = migrateLegacyDiagramType(
377
- appearance.type,
378
- appearance.layout,
379
- );
380
- const { layout, ...rest } = appearance;
381
- return {
382
- ...component,
383
- appearance: {
384
- ...rest,
385
- type: migratedType ?? null,
386
- },
387
- };
388
- })
389
- : slide.components;
390
- return { ...slide, components };
391
- })
392
- : input.slides;
393
- return { ...input, schema: compositionSchema, slides };
394
- }
395
- function migrateInterimV12Sankey(input: unknown) {
396
- if (!input || typeof input !== "object") return input;
397
- const document = input as Record<string, unknown>;
398
- if (document.schema !== compositionSchema || !Array.isArray(document.slides))
399
- return input;
400
- const slides = document.slides.map((value: unknown) => {
401
- if (!value || typeof value !== "object") return value;
402
- const slide = value as Record<string, unknown>;
403
- if (!Array.isArray(slide.components)) return value;
404
- const components = slide.components.map((componentValue: unknown) => {
405
- if (!componentValue || typeof componentValue !== "object") return componentValue;
406
- const component = componentValue as Record<string, unknown>;
407
- const appearance = component.appearance && typeof component.appearance === "object"
408
- ? component.appearance as Record<string, unknown>
409
- : {};
410
- if (component.kind !== "diagram" || appearance.type !== "sankey")
411
- return componentValue;
412
- const { type: _type, ...sharedAppearance } = appearance;
413
- return {
414
- ...component,
415
- kind: "chart",
416
- appearance: { ...sharedAppearance, template: "sankey" },
417
- };
418
- });
419
- return { ...slide, components };
420
- });
421
- return { ...document, slides };
422
- }
423
- function preserveLegacyVisualizationChoices(input: unknown) {
424
- if (!input || typeof input !== "object") return input;
425
- const document = input as Record<string, unknown>;
426
- if (!Array.isArray(document.slides)) return input;
427
- return { ...document, slides: document.slides.map((value: unknown) => {
428
- if (!value || typeof value !== "object") return value;
429
- const slide = value as Record<string, unknown>;
430
- if (!Array.isArray(slide.components)) return value;
431
- return { ...slide, components: slide.components.map((value: unknown) => {
432
- if (!value || typeof value !== "object") return value;
433
- const component = value as Record<string, unknown>;
434
- if ((component.kind !== "diagram" && component.kind !== "chart") || !component.appearance || typeof component.appearance !== "object") return value;
435
- return { ...component, appearance: { ...component.appearance, selection: "explicit" } };
436
- }) };
437
- }) };
438
- }
439
17
  export function validateComposition(input: unknown): ValidationResult {
440
- const legacy = Boolean(
441
- input &&
442
- typeof input === "object" &&
443
- "schema" in input &&
444
- legacyCompositionSchemas.includes(
445
- input.schema as (typeof legacyCompositionSchemas)[number],
446
- ),
447
- );
448
- const legacyInput = input as Record<string, unknown>;
449
- const migrated = legacy
450
- ? ["konpeki-composition/v12", "konpeki-composition/v13", "konpeki-composition/v14", "konpeki-composition/v15", "konpeki-composition/v16", "konpeki-composition/v17"].includes(
451
- String(legacyInput.schema),
452
- )
453
- ? { ...legacyInput, schema: compositionSchema }
454
- : migrateV11Document(
455
- legacyInput.schema === "konpeki-composition/v11"
456
- ? legacyInput
457
- : migrateV10Document(
458
- ["konpeki-composition/v6", "konpeki-composition/v7", "konpeki-composition/v8", "konpeki-composition/v9", "konpeki-composition/v10"].includes(
459
- String(legacyInput.schema),
460
- )
461
- ? migrateV7Document(legacyInput)
462
- : migrateV7Document(
463
- migrateV5Document(
464
- legacyInput.schema === "konpeki-composition/v5"
465
- ? legacyInput
466
- : migratePreV5Document(legacyInput),
467
- ),
468
- ),
469
- ),
470
- )
471
- : input;
472
- const normalized = migrateInterimV12Sankey(migrated);
473
- const candidate = legacy ? preserveLegacyVisualizationChoices(normalized) : normalized;
474
- if (!structural(candidate))
18
+ if (!structural(input))
475
19
  return {
476
20
  ok: false,
477
21
  issues: (structural.errors ?? []).map((error) => ({
@@ -479,6 +23,7 @@ export function validateComposition(input: unknown): ValidationResult {
479
23
  message: error.message ?? "Invalid value",
480
24
  })),
481
25
  };
26
+ const candidate = input;
482
27
  const issues: ValidationIssue[] = [];
483
28
  const fail = (path: string, message: string) =>
484
29
  issues.push({ path, message });
@@ -667,10 +212,7 @@ export function validateComposition(input: unknown): ValidationResult {
667
212
  });
668
213
  return issues.length
669
214
  ? { ok: false, issues }
670
- : { ok: true, document: legacy ? { ...candidate, slides: candidate.slides.map((slide) => ({
671
- ...slide, components: slide.components.map((component) => component.kind === "text-block" && !component.customVisual && component.content === undefined
672
- ? { ...component, content: component.intent ?? "" } : component),
673
- })) } : candidate };
215
+ : { ok: true, document: input };
674
216
  }
675
217
  export function assertComposition(input: unknown): CompositionDocument {
676
218
  const result = validateComposition(input);
@@ -317,19 +317,3 @@ export const diagramCategories = Array.from(
317
317
  export function diagramDefinition(type: DiagramType): DiagramDefinition {
318
318
  return diagramDefinitions[type];
319
319
  }
320
-
321
- export function migrateLegacyDiagramType(
322
- type: unknown,
323
- layout: unknown,
324
- ): DiagramType | undefined {
325
- if (typeof type !== "string" || typeof layout !== "string") return undefined;
326
- const legacyTypes: Record<string, DiagramType> = {
327
- "process/linear": "process",
328
- "process/branching": "flowchart",
329
- "process/cyclical": "loop-flywheel",
330
- "system/layered": "layer-stack",
331
- "system/request-flow": "sequence",
332
- "system/hub-and-spoke": "architecture",
333
- };
334
- return legacyTypes[`${type}/${layout}`];
335
- }
@@ -1,21 +1,39 @@
1
1
  # Themes
2
2
 
3
- Themes combine font roles and a referenced color palette, not a slide layout.
3
+ Document appearance has independent **Typography**, **Color palette**, and
4
+ **Background** controls. These do not choose a layout or change font sizes.
4
5
 
5
6
  Use [authoring guidance](../../AUTHORING.md#taste-and-creative-freedom) for theme
6
7
  selection, font defaults and deck-wide consistency.
7
8
 
8
9
  ```ts
9
10
  import { getTheme } from './design/themes/index.ts';
10
- const theme = getTheme('Plex', 'paper');
11
+ const theme = getTheme('Green', 'paper', 'noto-sans');
11
12
  // theme.body, theme.headline, theme.typography, theme.palette
12
13
  ```
13
14
 
14
- Eight light/dark themes: Plex, Precision, Editorial, Blue–cyan, Orange–coral,
15
- Yellow, Green and Graphite. Precision uses Noto Sans; Editorial uses Plex Serif
16
- headlines with Plex Sans body; the others use Plex Sans. Font loading remains
17
- the author's responsibility; load the required Fontsource Latin weights before
18
- measuring or rendering text.
15
+ Eight palettes retain their existing names: Plex, Precision, Editorial,
16
+ Blue–cyan, Orange–coral, Yellow, Green and Graphite. Each supports Paper and Night.
17
+ Choose any palette with any of these typography presets:
18
+
19
+ | Typography ID | Headings | Body |
20
+ | --- | --- | --- |
21
+ | `plex-sans` | IBM Plex Sans | IBM Plex Sans |
22
+ | `noto-sans` | Noto Sans | Noto Sans |
23
+ | `plex-serif` | IBM Plex Serif | IBM Plex Sans |
24
+ | `hanken-grotesk` | Hanken Grotesk | Hanken Grotesk |
25
+
26
+ Composition JSON stores the optional choice in `theme.typography`, independently
27
+ of `theme.id` (palette) and `theme.mode` (background). Omitted typography preserves
28
+ legacy appearance: Precision uses Noto Sans, Editorial uses Plex Serif headings
29
+ with Plex Sans body, and all other palettes use Plex Sans. Changing the palette
30
+ in the editor records the current typography so colors no longer change fonts.
31
+ Two-argument `getTheme` callers retain those legacy pairings too.
32
+
33
+ Font loading remains the author's responsibility; load the required Fontsource
34
+ Latin weights before measuring or rendering text. Theme-linked native text and
35
+ vectors follow the selection; literal font/color overrides remain fixed.
36
+ Different fonts can change wrapping or overflow, so inspect the result.
19
37
 
20
38
  Mineral and Botanical are light-only palette candidates in
21
39
  [palettes/candidates.ts](../palettes/candidates.ts).
@@ -1,13 +1,24 @@
1
1
  import { typography } from '../../lib/taste.ts';
2
2
  import { paletteNames, resolvePalette, type PaletteName, type PaletteMode } from '../palettes/index.ts';
3
+ import type { TypographyId } from '../../composition/types.ts';
3
4
 
4
5
  export const themeNames = paletteNames;
5
6
  export type ThemeName = PaletteName;
6
7
  export type ThemeMode = PaletteMode;
8
+ export const typographyLabels: Record<TypographyId, string> = {
9
+ 'plex-sans': 'IBM Plex Sans',
10
+ 'noto-sans': 'Noto Sans',
11
+ 'plex-serif': 'Plex Serif + Plex Sans',
12
+ 'hanken-grotesk': 'Hanken Grotesk',
13
+ };
7
14
 
8
15
  // Themes combine typography with a palette; they do not choose a composition.
9
- export function getTheme(name: ThemeName, mode: ThemeMode) {
10
- const body = name === 'Precision' ? '"Noto Sans", sans-serif' : typography.family;
11
- const headline = name === 'Editorial' ? '"IBM Plex Serif", serif' : body;
12
- return { name, body, headline, typography: { ...typography, family: body }, palette: resolvePalette(name, mode) };
16
+ // Omitted typography retains the original pairing for older documents/callers.
17
+ export function getTheme(name: ThemeName, mode: ThemeMode,
18
+ typographyId: TypographyId = name === 'Precision' ? 'noto-sans' : name === 'Editorial' ? 'plex-serif' : 'plex-sans',
19
+ ) {
20
+ const body = typographyId === 'noto-sans' ? '"Noto Sans", sans-serif'
21
+ : typographyId === 'hanken-grotesk' ? '"Hanken Grotesk", sans-serif' : typography.family;
22
+ const headline = typographyId === 'plex-serif' ? '"IBM Plex Serif", serif' : body;
23
+ return { name, typographyId, body, headline, typography: { ...typography, family: body }, palette: resolvePalette(name, mode) };
13
24
  }