@oddkin/workbench 1.0.0-beta.0 → 1.0.0-beta.1

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 (113) hide show
  1. package/README.md +33 -3
  2. package/dist/app/card-previews.d.ts +14 -0
  3. package/dist/app/card-previews.d.ts.map +1 -0
  4. package/dist/app/card-previews.js +140 -0
  5. package/dist/app/card-previews.js.map +1 -0
  6. package/dist/app/copy.d.ts +6 -1
  7. package/dist/app/copy.d.ts.map +1 -1
  8. package/dist/app/copy.js +6 -1
  9. package/dist/app/copy.js.map +1 -1
  10. package/dist/app/details.d.ts.map +1 -1
  11. package/dist/app/details.js +2 -1
  12. package/dist/app/details.js.map +1 -1
  13. package/dist/app/routes.d.ts +1 -1
  14. package/dist/app/routes.d.ts.map +1 -1
  15. package/dist/app/routes.js +5 -3
  16. package/dist/app/routes.js.map +1 -1
  17. package/dist/app/shell.d.ts +1 -1
  18. package/dist/app/shell.d.ts.map +1 -1
  19. package/dist/app/shell.js +46 -3
  20. package/dist/app/shell.js.map +1 -1
  21. package/dist/app/styles.d.ts.map +1 -1
  22. package/dist/app/styles.js +9 -26
  23. package/dist/app/styles.js.map +1 -1
  24. package/dist/app/views.d.ts +4 -0
  25. package/dist/app/views.d.ts.map +1 -1
  26. package/dist/app/views.js +47 -25
  27. package/dist/app/views.js.map +1 -1
  28. package/dist/data/build.d.ts.map +1 -1
  29. package/dist/data/build.js +13 -1
  30. package/dist/data/build.js.map +1 -1
  31. package/dist/data/import-map.d.ts +6 -0
  32. package/dist/data/import-map.d.ts.map +1 -1
  33. package/dist/data/import-map.js +35 -0
  34. package/dist/data/import-map.js.map +1 -1
  35. package/dist/data/theme-studio.d.ts.map +1 -1
  36. package/dist/data/theme-studio.js +2 -6
  37. package/dist/data/theme-studio.js.map +1 -1
  38. package/dist/data/types.d.ts +18 -0
  39. package/dist/data/types.d.ts.map +1 -1
  40. package/dist/frame/index.d.ts.map +1 -1
  41. package/dist/frame/index.js +78 -64
  42. package/dist/frame/index.js.map +1 -1
  43. package/dist/node/example-modules.d.ts +8 -0
  44. package/dist/node/example-modules.d.ts.map +1 -0
  45. package/dist/node/example-modules.js +28 -0
  46. package/dist/node/example-modules.js.map +1 -0
  47. package/dist/node/html.js +1 -1
  48. package/dist/node/index.d.ts +1 -1
  49. package/dist/node/index.d.ts.map +1 -1
  50. package/dist/node/index.js +1 -1
  51. package/dist/node/index.js.map +1 -1
  52. package/dist/node/inputs.d.ts +2 -0
  53. package/dist/node/inputs.d.ts.map +1 -1
  54. package/dist/node/inputs.js +26 -3
  55. package/dist/node/inputs.js.map +1 -1
  56. package/dist/node/server.d.ts.map +1 -1
  57. package/dist/node/server.js +56 -15
  58. package/dist/node/server.js.map +1 -1
  59. package/dist/node/static.d.ts +10 -0
  60. package/dist/node/static.d.ts.map +1 -1
  61. package/dist/node/static.js +35 -19
  62. package/dist/node/static.js.map +1 -1
  63. package/dist/node/workbench.d.ts.map +1 -1
  64. package/dist/node/workbench.js +25 -4
  65. package/dist/node/workbench.js.map +1 -1
  66. package/dist/pages/build.js +2 -2
  67. package/dist/pages/build.js.map +1 -1
  68. package/dist/pages/defaults.js +3 -3
  69. package/dist/pages/defaults.js.map +1 -1
  70. package/dist/pages/types.d.ts +5 -0
  71. package/dist/pages/types.d.ts.map +1 -1
  72. package/dist/panels/catalog-layout-styles.d.ts +2 -0
  73. package/dist/panels/catalog-layout-styles.d.ts.map +1 -0
  74. package/dist/panels/catalog-layout-styles.js +54 -0
  75. package/dist/panels/catalog-layout-styles.js.map +1 -0
  76. package/dist/panels/catalog-styles.d.ts +2 -0
  77. package/dist/panels/catalog-styles.d.ts.map +1 -0
  78. package/dist/panels/catalog-styles.js +106 -0
  79. package/dist/panels/catalog-styles.js.map +1 -0
  80. package/dist/panels/catalog.d.ts +7 -3
  81. package/dist/panels/catalog.d.ts.map +1 -1
  82. package/dist/panels/catalog.js +24 -4
  83. package/dist/panels/catalog.js.map +1 -1
  84. package/dist/panels/component-detail.d.ts +3 -0
  85. package/dist/panels/component-detail.d.ts.map +1 -1
  86. package/dist/panels/component-detail.js +488 -256
  87. package/dist/panels/component-detail.js.map +1 -1
  88. package/dist/panels/example-styles.d.ts +2 -0
  89. package/dist/panels/example-styles.d.ts.map +1 -0
  90. package/dist/panels/example-styles.js +343 -0
  91. package/dist/panels/example-styles.js.map +1 -0
  92. package/dist/panels/model.d.ts +11 -0
  93. package/dist/panels/model.d.ts.map +1 -1
  94. package/dist/panels/model.js +44 -18
  95. package/dist/panels/model.js.map +1 -1
  96. package/dist/panels/styles.d.ts.map +1 -1
  97. package/dist/panels/styles.js +2 -53
  98. package/dist/panels/styles.js.map +1 -1
  99. package/dist/panels/theme-model.d.ts +20 -0
  100. package/dist/panels/theme-model.d.ts.map +1 -1
  101. package/dist/panels/theme-model.js +20 -0
  102. package/dist/panels/theme-model.js.map +1 -1
  103. package/dist/panels/theme-studio.d.ts.map +1 -1
  104. package/dist/panels/theme-studio.js +64 -1
  105. package/dist/panels/theme-studio.js.map +1 -1
  106. package/dist/preview/frame.d.ts.map +1 -1
  107. package/dist/preview/frame.js +46 -36
  108. package/dist/preview/frame.js.map +1 -1
  109. package/dist/protocol/index.d.ts +15 -0
  110. package/dist/protocol/index.d.ts.map +1 -1
  111. package/dist/protocol/index.js +12 -1
  112. package/dist/protocol/index.js.map +1 -1
  113. package/package.json +7 -7
@@ -8,9 +8,11 @@ import { OddkinElement, prop } from '@oddkin/core/element';
8
8
  import { signal, untracked } from '@oddkin/core/reactive';
9
9
  import { createScope } from '@oddkin/core/scope';
10
10
  import { html, nothing, repeat } from '@oddkin/core/template';
11
- import { createIsolatedPreview } from '../preview/frame.js';
12
- import { authoredValues, componentFacts, decodeShare, defaultWriteMode, encodeShare, initialState, intendedUse, pageCoverage, presets, usagePairs, MAX_SHARE_WIDTH, MIN_SHARE_WIDTH, SHARE_PARAM, sharePayload, snippet, } from './model.js';
11
+ import { createIsolatedPreview, PreviewFrameError, } from '../preview/frame.js';
12
+ import { isCustomPropertyName, } from '../protocol/index.js';
13
+ import { authoredValues, componentFacts, decodeShare, defaultWriteMode, encodeShare, initialState, intendedUse, pageCoverage, presets, registrationOf, usagePairs, MAX_SHARE_WIDTH, MIN_SHARE_WIDTH, SHARE_PARAM, sharePayload, snippet, } from './model.js';
13
14
  import { contentStyles, panelHostStyles } from './styles.js';
15
+ import { exampleStyles } from './example-styles.js';
14
16
  const LOG_LIMIT = 50;
15
17
  const WIDTHS = {
16
18
  narrow: 320,
@@ -40,11 +42,27 @@ const notDocumented = (key, subject) => {
40
42
  const bullets = (items) => html `<ul class="wb-list">
41
43
  ${items.map((item) => html `<li>${inline(item)}</li>`)}
42
44
  </ul>`;
43
- function covered(coverage) {
45
+ /** The reference layout's links to its sections (Figma 19:874), by section id. */
46
+ const PAGE_LINKS = [
47
+ ['overview', 'Overview'],
48
+ ['interactive-example', 'Controls'],
49
+ ['examples', 'Examples'],
50
+ ['behavior-and-accessibility', 'Accessibility'],
51
+ ];
52
+ /** `'secondary'` reads as `secondary`; an empty literal stays as written. */
53
+ const unquote = (value) => (/^(['"`]).+\1$/.test(value) ? value.slice(1, -1) : value);
54
+ /** A row's name cell: the name, and its description or "Not documented". */
55
+ const named = (name, description) => html `<th scope="row" data-label="Name">
56
+ <code>${name}</code>
57
+ <small
58
+ >${description ? inline(description) : html `<span class="wb-not-documented">Not documented</span>`}</small
59
+ >
60
+ </th>`;
61
+ function covered(coverage, quiet) {
44
62
  const byId = new Map(coverage.map((item) => [item.id, item]));
45
63
  return (id, content) => {
46
64
  const item = byId.get(id);
47
- if (!item)
65
+ if (!item || (quiet && item.state !== 'documented'))
48
66
  return nothing;
49
67
  return html `<div data-coverage=${id} data-state=${item.state}>
50
68
  ${item.state === 'documented'
@@ -70,8 +88,31 @@ export class ComponentDetailElement extends OddkinElement.with({
70
88
  attribute: false,
71
89
  description: 'URL of the preview frame document.',
72
90
  }),
91
+ layout: prop.enum(['workbench', 'reference'], {
92
+ default: 'workbench',
93
+ description: "'reference' is the documentation page (Figma 20:2190): links to the page's sections, no preview tools (presets, settings, example link, event log), and absent items left out instead of labelled.",
94
+ }),
95
+ examplePreview: prop.custom({
96
+ default: () => undefined,
97
+ coerce: (value) => value === undefined || typeof value === 'function'
98
+ ? {
99
+ ok: true,
100
+ value: value,
101
+ }
102
+ : { ok: false, reason: 'expected a function' },
103
+ description: 'Returns a live element for an example card (the example id, `example:<tag>/<name>`). Only a host that loads the library supplies one; the same example should get the same node.',
104
+ }),
105
+ sections: prop.custom({
106
+ default: () => null,
107
+ coerce: (value) => value === null || value === undefined
108
+ ? { ok: true, value: null }
109
+ : Array.isArray(value) && value.every((id) => typeof id === 'string')
110
+ ? { ok: true, value: Object.freeze([...value]) }
111
+ : { ok: false, reason: 'expected an array of section ids, or null' },
112
+ description: "The section ids to show, for a host's tabs (overview, interactive-example, api, examples, tokens, behavior-and-accessibility, guidance, source, known-limitations); null shows every section. The others stay rendered but hidden, so the example's preview and controls survive a switch, and the panel leaves out its own section links.",
113
+ }),
73
114
  }) {
74
- static styles = [panelHostStyles, contentStyles];
115
+ static styles = [panelHostStyles, contentStyles, exampleStyles];
75
116
  #state = signal({
76
117
  preset: 'default',
77
118
  attributes: {},
@@ -87,6 +128,12 @@ export class ComponentDetailElement extends OddkinElement.with({
87
128
  alert: false,
88
129
  });
89
130
  #link = signal('');
131
+ /** The generated code is shown under the example. */
132
+ #code = signal(false);
133
+ /** The section the reference layout's links mark as current. */
134
+ #current = signal('');
135
+ /** The reference layout shows every example card, not the first four. */
136
+ #allExamples = signal(false);
90
137
  #hosts;
91
138
  #previews = [];
92
139
  #previewScope;
@@ -114,10 +161,41 @@ export class ComponentDetailElement extends OddkinElement.with({
114
161
  this.#key = '';
115
162
  this.#stop();
116
163
  });
164
+ let frame = 0;
165
+ scope.listen(this.ownerDocument, 'scroll', () => {
166
+ if (this.layout === 'reference' && !frame)
167
+ frame = requestAnimationFrame(() => {
168
+ frame = 0;
169
+ this.#spy();
170
+ });
171
+ }, { capture: true, passive: true });
172
+ scope.add(() => cancelAnimationFrame(frame));
173
+ }
174
+ /** The current section is the last linked one whose top has passed a third of the view. */
175
+ #spy() {
176
+ const view = this.ownerDocument.defaultView;
177
+ const page = this.ownerDocument.scrollingElement;
178
+ if (!view || !page)
179
+ return;
180
+ const linked = PAGE_LINKS.flatMap(([id]) => this.shadowRoot?.getElementById(id) ?? []);
181
+ const atEnd = page.scrollTop + view.innerHeight >= page.scrollHeight - 2;
182
+ let current = linked[0]?.id ?? '';
183
+ for (const section of linked)
184
+ if (atEnd || section.getBoundingClientRect().top <= view.innerHeight / 3)
185
+ current = section.id;
186
+ this.#current.value = current;
187
+ }
188
+ /** Scroll a section into view and move focus to its heading. */
189
+ #jump(event, id) {
190
+ event.preventDefault();
191
+ const section = this.shadowRoot?.getElementById(id);
192
+ section?.scrollIntoView({ block: 'start' });
193
+ section?.querySelector('h2')?.focus({ preventScroll: true });
194
+ this.#current.value = id;
117
195
  }
118
196
  /** Restart the preview only when the component, registration, or frame document changes. */
119
197
  #sync(page, frameUrl) {
120
- const registration = page?.enrichment?.['registration'];
198
+ const registration = page && registrationOf(page);
121
199
  const key = page ? `${page.tag}\n${String(registration)}\n${frameUrl}` : '';
122
200
  if (key === this.#key)
123
201
  return;
@@ -192,6 +270,7 @@ export class ComponentDetailElement extends OddkinElement.with({
192
270
  #mountPayload(page, registration, index) {
193
271
  const state = this.#state.peek();
194
272
  const facts = componentFacts(page);
273
+ const fixture = presets(page).some((p) => p.name === state.preset && p.fixture);
195
274
  return {
196
275
  tag: page.tag,
197
276
  registration,
@@ -199,6 +278,8 @@ export class ComponentDetailElement extends OddkinElement.with({
199
278
  properties: state.properties,
200
279
  attributes: state.attributes,
201
280
  slots: state.slots,
281
+ styles: state.styles ?? {},
282
+ ...(fixture && page.exampleModule ? { fixture: page.exampleModule } : {}),
202
283
  events: facts.events.map((event) => event.name),
203
284
  sync: facts.syncPairs,
204
285
  settings: this.#frameSettings(index),
@@ -210,9 +291,9 @@ export class ComponentDetailElement extends OddkinElement.with({
210
291
  const page = this.page;
211
292
  if (!page || !this.isConnected)
212
293
  return;
213
- const registration = page.enrichment?.['registration'];
294
+ const registration = registrationOf(page);
214
295
  // Without a registration entry the preview item itself says what is missing.
215
- if (typeof registration !== 'string') {
296
+ if (registration === undefined) {
216
297
  if (!this.#status.peek().keep)
217
298
  this.#status.value = { text: '', alert: false };
218
299
  return;
@@ -248,8 +329,8 @@ export class ComponentDetailElement extends OddkinElement.with({
248
329
  this.#mountAll(scope, page, registration);
249
330
  }
250
331
  #mountAll(scope, page, registration) {
251
- const previews = [...this.#previews];
252
- void Promise.all(previews.map((preview, i) => preview.ready.then(() => preview.request('mount', this.#mountPayload(page, registration, i))))).then(() => {
332
+ // Each preview handles requests in order, so changes made from now on follow this mount.
333
+ void Promise.all(this.#previews.map((preview, i) => preview.request('mount', this.#mountPayload(page, registration, i)))).then(() => {
253
334
  if (!scope.disposed && !this.#status.peek().alert && !this.#status.peek().keep)
254
335
  this.#status.value = { text: '', alert: false };
255
336
  }, (error) => {
@@ -258,6 +339,10 @@ export class ComponentDetailElement extends OddkinElement.with({
258
339
  });
259
340
  }
260
341
  #fail(error) {
342
+ // A request for a frame that was replaced (Reset) or removed has nothing left to report.
343
+ if (error instanceof PreviewFrameError &&
344
+ (error.code === 'frame.generation.superseded' || error.code === 'frame.disposed'))
345
+ return;
261
346
  this.#status.value = { text: `Preview unavailable: ${message(error)}`, alert: true };
262
347
  }
263
348
  #onMessage(notification, index) {
@@ -274,6 +359,15 @@ export class ComponentDetailElement extends OddkinElement.with({
274
359
  };
275
360
  this.#log.value = [entry, ...this.#log.peek()].slice(0, LOG_LIMIT);
276
361
  }
362
+ else if (notification.kind === 'error' &&
363
+ isRecord(payload) &&
364
+ payload['phase'] === 'fixture') {
365
+ this.#status.value = {
366
+ text: `The example's setup did not run (${String(payload['message'])}), so the preview shows the element without it.`,
367
+ alert: false,
368
+ keep: true,
369
+ };
370
+ }
277
371
  else if (notification.kind === 'error' && isRecord(payload)) {
278
372
  this.#status.value = {
279
373
  text: `Preview ${String(payload['phase'])} failed: ${String(payload['message'])}. Use Reset to start a fresh preview.`,
@@ -304,6 +398,18 @@ export class ComponentDetailElement extends OddkinElement.with({
304
398
  delete errors[id];
305
399
  this.#errors.value = errors;
306
400
  }
401
+ /** Why a typed value is not written (a number field's text that is not a number yet). */
402
+ #textProblem(number, mode, text) {
403
+ if (!number)
404
+ return undefined;
405
+ if (mode === 'attribute')
406
+ return text !== '' && !Number.isFinite(Number(text))
407
+ ? 'Not written: not a number.'
408
+ : undefined;
409
+ return text.trim() === '' || !Number.isFinite(Number(text))
410
+ ? 'Not written: enter a number, or use Unset.'
411
+ : undefined;
412
+ }
307
413
  #writeAttribute(row, value) {
308
414
  const name = row.attribute;
309
415
  const state = this.#state.peek();
@@ -340,12 +446,23 @@ export class ComponentDetailElement extends OddkinElement.with({
340
446
  this.#state.value = { ...state, slots: { ...state.slots, [name]: text } };
341
447
  this.#send('slot', { name, text });
342
448
  }
449
+ /** A CSS custom property on the element's style; `null` removes it. */
450
+ #writeStyle(name, value) {
451
+ const state = this.#state.peek();
452
+ const styles = { ...state.styles };
453
+ if (value === null)
454
+ delete styles[name];
455
+ else
456
+ styles[name] = value;
457
+ this.#state.value = { ...state, styles };
458
+ this.#send('set', { cssProperty: name, cssValue: value });
459
+ }
343
460
  /** Mount the current state again in the existing frames (a clean instance, same realm). */
344
461
  #remount(note) {
345
462
  const page = this.page;
346
- const registration = page?.enrichment?.['registration'];
463
+ const registration = page && registrationOf(page);
347
464
  const scope = this.#previewScope;
348
- if (!page || typeof registration !== 'string' || !scope)
465
+ if (!page || registration === undefined || !scope)
349
466
  return;
350
467
  this.#status.value = { text: note ?? '', alert: false, ...(note ? { keep: true } : {}) };
351
468
  this.#mountAll(scope, page, registration);
@@ -379,9 +496,9 @@ export class ComponentDetailElement extends OddkinElement.with({
379
496
  }
380
497
  #replace() {
381
498
  const page = this.page;
382
- const registration = page?.enrichment?.['registration'];
499
+ const registration = page && registrationOf(page);
383
500
  const scope = this.#previewScope;
384
- if (!page || typeof registration !== 'string' || !scope) {
501
+ if (!page || registration === undefined || !scope) {
385
502
  this.#restart();
386
503
  return Promise.resolve();
387
504
  }
@@ -442,6 +559,10 @@ export class ComponentDetailElement extends OddkinElement.with({
442
559
  return;
443
560
  clipboard.writeText(link).then(() => (this.#status.value = { text: 'Example link copied.', alert: false, keep: true }), () => undefined);
444
561
  }
562
+ #copyCode() {
563
+ const code = this.shadowRoot?.querySelector('.wb-snippet')?.textContent ?? '';
564
+ this.ownerDocument.defaultView?.navigator.clipboard?.writeText(code).then(() => (this.#status.value = { text: 'Code copied.', alert: false, keep: true }), () => undefined);
565
+ }
445
566
  // ---- Rendering ----
446
567
  render() {
447
568
  const page = this.page;
@@ -449,62 +570,73 @@ export class ComponentDetailElement extends OddkinElement.with({
449
570
  if (!page || !index)
450
571
  return html `<div class="wb-content"></div>`;
451
572
  const entry = index.registry.pages.find((p) => p.id === page.id);
452
- const sections = (entry?.sections ?? []).filter((s) => s.visible);
573
+ const reference = this.layout === 'reference';
453
574
  const facts = componentFacts(page);
454
- const at = covered(pageCoverage(page));
575
+ const coverage = pageCoverage(page);
576
+ const at = covered(coverage, reference);
577
+ // The reference layout leaves out a section with nothing to show; Workbench labels it.
578
+ const sections = (entry?.sections ?? []).filter((s) => s.visible &&
579
+ (!reference ||
580
+ coverage.some((item) => item.section === s.id && item.state === 'documented') ||
581
+ (s.id === 'api' && facts.cssProperties.length > 0)));
582
+ const links = reference && !this.sections
583
+ ? PAGE_LINKS.filter(([id]) => sections.some((s) => s.id === id))
584
+ : [];
585
+ const current = this.#current.value || links[0]?.[0];
586
+ const shown = this.sections;
455
587
  // Sections are built eagerly (not with repeat()), so the signals they read are tracked.
456
588
  return html `<div class="wb-content">
457
- ${sections.map((section) => html `<section id=${section.id} aria-labelledby=${`${section.id}-title`}>
458
- <h2 id=${`${section.id}-title`}>${section.title}</h2>
459
- ${this.#section(section.id, page, facts, at)}
589
+ ${links.length
590
+ ? html `<nav class="wb-page-nav" aria-label="Page sections">
591
+ ${links.map(([id, label]) => html `<a
592
+ href=${`#${id}`}
593
+ aria-current=${current === id ? 'true' : null}
594
+ @click=${(event) => this.#jump(event, id)}
595
+ >${label}</a
596
+ >`)}
597
+ </nav>`
598
+ : nothing}
599
+ ${sections.map((section) => html `<section
600
+ id=${section.id}
601
+ aria-labelledby=${`${section.id}-title`}
602
+ ?hidden=${!!shown && !shown.includes(section.id)}
603
+ >
604
+ <h2 id=${`${section.id}-title`} tabindex="-1">${section.title}</h2>
605
+ ${this.#section(section.id, page, facts, at, sections)}
460
606
  </section>`)}
461
607
  </div>`;
462
608
  }
463
- #section(id, page, facts, at) {
609
+ #section(id, page, facts, at, sections) {
464
610
  switch (id) {
465
611
  case 'overview':
466
612
  return this.#overview(page, at);
467
613
  case 'interactive-example':
468
614
  return this.#interactive(page, facts, at);
469
615
  case 'api':
470
- return this.#api(facts, at);
616
+ return this.#reference(facts, at, sections.some((s) => s.id === 'interactive-example'));
471
617
  case 'examples':
472
618
  return at('examples/examples', () => this.#examples(page));
473
619
  case 'tokens':
474
620
  return this.#tokens(page, at);
475
621
  case 'behavior-and-accessibility':
476
- return this.#behavior(page, facts, at);
622
+ return this.#behavior(page, at);
477
623
  case 'guidance':
478
624
  return this.#guidance(page, at);
479
625
  case 'source':
480
- return at('source/module', () => this.#source(page));
626
+ return this.#source(page, at);
481
627
  case 'known-limitations':
482
- return at('known-limitations/limitations', () => bullets(authoredValues(page.enrichment?.['limitations'])));
628
+ return at('known-limitations/limitations', () => html `<div class="wb-callout">
629
+ ${bullets(authoredValues(page.enrichment?.['limitations']))}
630
+ </div>`);
483
631
  default:
484
632
  return notDocumented(`pages["${page.id}"].sections["${id}"] in oddkin.docs.ts`);
485
633
  }
486
634
  }
487
635
  #overview(page, at) {
488
636
  const description = typeof page.declaration['description'] === 'string' ? page.declaration['description'] : '';
489
- const registration = page.enrichment?.['registration'];
490
- const related = Array.isArray(page.enrichment?.['related'])
491
- ? page.enrichment['related'].filter((r) => typeof r === 'string')
492
- : [];
493
- return html `${at('overview/description', () => description
637
+ return at('overview/description', () => description
494
638
  .split(/\n\s*\n/)
495
- .map((paragraph) => html `<p>${inline(paragraph.replace(/\s*\n\s*/g, ' '))}</p>`))}
496
- <h3>Imports</h3>
497
- ${at('overview/imports', () => html `<p>Register the element with its define entry:</p>
498
- <pre class="wb-code" tabindex="0"><code>import '${String(registration)}';</code></pre>`)}
499
- ${related.length
500
- ? html `<h3>Related components</h3>
501
- <ul class="wb-list">
502
- ${related.map((tag) => {
503
- const name = tag.replace(/^component:/, '');
504
- return html `<li><a href=${this.href(`/components/${name}`)}>${name}</a></li>`;
505
- })}
506
- </ul>`
507
- : nothing}`;
639
+ .map((paragraph) => html `<p>${inline(paragraph.replace(/\s*\n\s*/g, ' '))}</p>`));
508
640
  }
509
641
  /** Intended use and explained mistakes, each paired with the example to use instead. */
510
642
  #guidance(page, at) {
@@ -544,23 +676,96 @@ export class ComponentDetailElement extends OddkinElement.with({
544
676
  const link = this.#link.value;
545
677
  const frameSettings = settings.pair ? {} : this.#frameSettings(0);
546
678
  const update = (patch) => this.#applySettings({ ...settings, ...patch });
547
- return html `<div class="wb-toolbar">
548
- ${at('interactive-example/presets', () => html `<label
549
- >Preset
550
- <select
551
- .value=${state.preset}
552
- @change=${(event) => {
553
- const name = event.target.value;
554
- const entry = entries.find((p) => p.name === name);
555
- if (entry)
556
- this.#applyPreset(entry);
557
- }}
558
- >
559
- ${entries.map((p) => html `<option value=${p.name} ?selected=${p.name === state.preset}>
560
- ${p.label}${p.incorrect ? ' (incorrect usage)' : ''}
561
- </option>`)}
562
- </select></label
563
- >`)}
679
+ const code = this.#code.value;
680
+ const tools = this.layout === 'workbench';
681
+ return html `<div class="wb-example">
682
+ <div class="wb-example-bar">
683
+ <p class="wb-example-title">Interactive example · ${page.tag}</p>
684
+ ${tools
685
+ ? at('interactive-example/presets', () => html `<label
686
+ >Preset
687
+ <select
688
+ .value=${state.preset}
689
+ @change=${(event) => {
690
+ const name = event.target.value;
691
+ const entry = entries.find((p) => p.name === name);
692
+ if (entry)
693
+ this.#applyPreset(entry);
694
+ }}
695
+ >
696
+ ${entries.map((p) => html `<option value=${p.name} ?selected=${p.name === state.preset}>
697
+ ${p.label}${p.incorrect ? ' (incorrect usage)' : ''}
698
+ </option>`)}
699
+ </select></label
700
+ >`)
701
+ : nothing}
702
+ <button
703
+ type="button"
704
+ aria-expanded=${code ? 'true' : 'false'}
705
+ aria-controls="wb-example-code"
706
+ @click=${() => (this.#code.value = !code)}
707
+ >
708
+ ${code ? 'Hide code' : 'See code'}
709
+ </button>
710
+ ${tools
711
+ ? html `<button type="button" @click=${() => this.#copyLink(page)}>
712
+ Copy example link
713
+ </button>`
714
+ : nothing}
715
+ <button type="button" class="wb-quiet" @click=${() => this.#reset()}>Reset</button>
716
+ </div>
717
+ ${link
718
+ ? html `<label class="wb-share"
719
+ >Example link <input type="url" readonly .value=${link} size="40"
720
+ /></label>`
721
+ : nothing}
722
+ <div class="wb-example-stage">
723
+ ${at('interactive-example/preview', () => html `<div class=${settings.pair ? 'wb-previews wb-pair' : 'wb-previews'}>
724
+ <div class="wb-preview">
725
+ ${settings.pair ? html `<p>Light</p>` : nothing}${this.#hosts[0]}
726
+ </div>
727
+ ${settings.pair
728
+ ? html `<div class="wb-preview">
729
+ <p>Dark</p>
730
+ ${this.#hosts[1]}
731
+ </div>`
732
+ : nothing}
733
+ </div>
734
+ <p class="wb-muted wb-example-caption">Changes appear here as you edit.</p>`)}
735
+ <p class="wb-status" role=${status.alert ? 'alert' : 'status'}>${status.text}</p>
736
+ </div>
737
+ ${tools ? this.#settingsBar(settings, update) : nothing}
738
+ <div class="wb-example-controls">
739
+ <div class="wb-controls-head">
740
+ <h3>Controls and API</h3>
741
+ <p class="wb-muted">Scroll the table; the preview stays in view.</p>
742
+ </div>
743
+ <div class="wb-controls-scroll" role="region" aria-label="Controls and API" tabindex="0">
744
+ ${at('api/properties', () => this.#apiTable(facts))}
745
+ ${at('api/slots', () => this.#slots(facts))}
746
+ ${at('api/css-properties', () => this.#cssTable(facts))}
747
+ </div>
748
+ </div>
749
+ <div class="wb-example-code" id="wb-example-code" ?hidden=${!code}>
750
+ <div class="wb-code-head">
751
+ <p class="wb-example-title">Generated HTML · follows the current controls</p>
752
+ <button type="button" @click=${() => this.#copyCode()}>Copy code</button>
753
+ </div>
754
+ <pre
755
+ class="wb-code wb-snippet"
756
+ tabindex="0"
757
+ ><code>${snippet(page, state, { settings: frameSettings })}</code></pre>
758
+ </div>
759
+ </div>
760
+ ${tools
761
+ ? html `<h3>Event log</h3>
762
+ ${this.#eventLog(facts)}`
763
+ : nothing}`;
764
+ }
765
+ /** Workbench's preview settings: mode, light and dark together, direction, and width. */
766
+ #settingsBar(settings, update) {
767
+ return html `<div class="wb-example-settings">
768
+ <div class="wb-toolbar">
564
769
  <label
565
770
  >Mode
566
771
  <select
@@ -623,8 +828,6 @@ export class ComponentDetailElement extends OddkinElement.with({
623
828
  }}
624
829
  /></label>`
625
830
  : nothing}
626
- <button type="button" @click=${() => this.#reset()}>Reset</button>
627
- <button type="button" @click=${() => this.#copyLink(page)}>Copy example link</button>
628
831
  </div>
629
832
  <p class="wb-muted wb-settings-summary">
630
833
  Preview only:
@@ -634,31 +837,7 @@ export class ComponentDetailElement extends OddkinElement.with({
634
837
  ? 'fills the column'
635
838
  : `${settings.width === 'custom' ? settings.customWidth : WIDTHS[settings.width]}px container`}
636
839
  </p>
637
- ${link
638
- ? html `<label class="wb-share"
639
- >Example link <input type="url" readonly .value=${link} size="40"
640
- /></label>`
641
- : nothing}
642
- ${at('interactive-example/preview', () => html `<div class=${settings.pair ? 'wb-previews wb-pair' : 'wb-previews'}>
643
- <div class="wb-preview">
644
- ${settings.pair ? html `<p>Light</p>` : nothing}${this.#hosts[0]}
645
- </div>
646
- ${settings.pair
647
- ? html `<div class="wb-preview">
648
- <p>Dark</p>
649
- ${this.#hosts[1]}
650
- </div>`
651
- : nothing}
652
- </div>`)}
653
- <p class="wb-status" role=${status.alert ? 'alert' : 'status'}>${status.text}</p>
654
- <h3>Event log</h3>
655
- ${this.#eventLog(facts)}
656
- <h3>Code</h3>
657
- <p class="wb-muted">Plain HTML and JavaScript for the current configuration.</p>
658
- <pre
659
- class="wb-code wb-snippet"
660
- tabindex="0"
661
- ><code>${snippet(page, state, { settings: frameSettings })}</code></pre>`;
840
+ </div>`;
662
841
  }
663
842
  #eventLog(facts) {
664
843
  if (!facts.events.length)
@@ -684,88 +863,58 @@ export class ComponentDetailElement extends OddkinElement.with({
684
863
  : html `<p class="wb-muted">No events yet.</p>`}
685
864
  </div>`;
686
865
  }
687
- #api(facts, at) {
688
- return html `${at('api/properties', () => this.#apiTable(facts))}
689
- <h3>Slots</h3>
690
- ${at('api/slots', () => this.#slots(facts))}
691
- <h3>Events</h3>
692
- ${at('api/events', () => this.#namedTable(facts.events, 'events', 'Event', true))}
693
- <h3>Methods</h3>
694
- ${at('api/methods', () => html `<p class="wb-muted">
695
- Methods are documented here and never invoked by the Workbench.
696
- </p>
697
- ${this.#namedTable(facts.methods, 'methods', 'Method', false)}`)}
698
- <h3>CSS parts</h3>
699
- ${at('api/parts', () => this.#namedTable(facts.parts, 'parts', 'Part', false))}
700
- <h3>CSS custom properties</h3>
701
- ${at('api/css-properties', () => this.#namedTable(facts.cssProperties, 'css-properties', 'Property', false))}
702
- <h3>Custom states</h3>
703
- ${at('api/css-states', () => this.#namedTable(facts.cssStates, 'states', 'State', false))}`;
704
- }
705
- #namedTable(entries, id, label, withType) {
706
- return html `<div class="wb-table-wrap">
707
- <table class="wb-stack" data-table=${id}>
708
- <thead>
709
- <tr>
710
- <th scope="col">${label}</th>
711
- ${withType ? html `<th scope="col">Type</th>` : nothing}
712
- <th scope="col">Description</th>
713
- </tr>
714
- </thead>
715
- <tbody>
716
- ${entries.map((entry) => html `<tr>
717
- <td data-label=${label}><code>${entry.name || '(default)'}</code></td>
718
- ${withType ? html `<td data-label="Type"><code>${entry.type ?? 'Unknown'}</code></td>` : nothing}
719
- <td data-label="Description">
720
- ${entry.description ? inline(entry.description) : html `<span class="wb-not-documented">Not documented</span>`}
721
- </td>
722
- </tr>`)}
723
- </tbody>
724
- </table>
725
- </div>`;
866
+ /** Contracts beside the controls (Figma 232:9895): events, parts, methods, states, and styling hooks. */
867
+ #reference(facts, at, inExample) {
868
+ const reference = this.layout === 'reference';
869
+ const card = (id, title, entries, name = (n) => n) => reference && !entries.length
870
+ ? nothing
871
+ : html `<div class="wb-box" data-card=${id}>
872
+ <h3>${title}</h3>
873
+ ${at(`api/${id}`, () => html `<ul class="wb-plain">
874
+ ${entries.map((entry) => html `<li>
875
+ <code>${name(entry.name)}</code
876
+ >${entry.description ? html ` — ${inline(entry.description)}` : nothing}
877
+ </li>`)}
878
+ </ul>`)}
879
+ </div>`;
880
+ return html `${inExample
881
+ ? html `<p class="wb-lede">
882
+ Contracts that complement the properties and slots table above.
883
+ </p>`
884
+ : html `${at('api/properties', () => this.#apiTable(facts))}
885
+ ${at('api/slots', () => this.#slots(facts))}
886
+ ${at('api/css-properties', () => this.#cssTable(facts))}`}
887
+ <div class="wb-boxes">
888
+ ${card('events', 'Events', facts.events)} ${card('parts', 'CSS parts', facts.parts)}
889
+ ${card('methods', 'Methods', facts.methods)}
890
+ ${card('css-states', 'Custom states', facts.cssStates, (n) => `:state(${n})`)}
891
+ </div>
892
+ ${facts.cssProperties.length
893
+ ? html `<div class="wb-box wb-sunken" data-card="css-properties">
894
+ <h3>CSS custom properties</h3>
895
+ <ul class="wb-plain wb-names">
896
+ ${facts.cssProperties.map((entry) => html `<li><code>${entry.name}</code></li>`)}
897
+ </ul>
898
+ </div>`
899
+ : nothing}`;
726
900
  }
727
901
  #apiTable(facts) {
728
- return html `<div class="wb-table-wrap">
729
- <table class="wb-stack wb-api" data-table="api">
730
- <caption>
731
- Editing a value updates the preview: one write per change, as a property or as an
732
- attribute. Reflected attributes are not written back.
733
- </caption>
734
- <thead>
735
- <tr>
736
- <th scope="col">Name</th>
737
- <th scope="col">Type</th>
738
- <th scope="col">Default</th>
739
- <th scope="col">Current value</th>
740
- <th scope="col">Description</th>
741
- </tr>
742
- </thead>
743
- <tbody>
744
- ${facts.rows.map((row) => this.#apiRow(row))}
745
- </tbody>
746
- </table>
747
- </div>`;
902
+ return this.#group('api', 'Editing a value updates the preview: one write per change, as a property or as an attribute. Reflected attributes are not written back.', ['Properties', 'Types', 'Default', 'Attribute', 'Reflected', 'Control / current value'], facts.rows.map((row) => this.#apiRow(row)));
748
903
  }
749
904
  #apiRow(row) {
750
- const name = row.property ?? row.attribute ?? '';
751
- const notes = [];
752
- if (row.property && row.attribute)
753
- notes.push(`attr ${row.attribute}${row.reflects ? ', reflects' : ''}`);
754
- else if (!row.property)
755
- notes.push('attribute only');
756
- else
757
- notes.push(row.propertyOnly ? 'property only' : 'no attribute');
758
905
  return html `<tr data-row=${row.id}>
759
- <th scope="row" data-label="Name"><code>${name}</code><small>${notes.join(' · ')}</small></th>
760
- <td data-label="Type">
761
- <code>${row.values ? row.values.map((v) => JSON.stringify(v)).join(' | ') : row.type}</code>
762
- ${row.nullable ? html `<small>nullable</small>` : nothing}
906
+ ${named(row.property ?? row.attribute ?? '', row.description)}
907
+ <td data-label="Types">
908
+ ${row.values ? row.values.join(', ') : row.type}${row.values && row.nullable ? ' · nullable' : ''}
763
909
  </td>
764
- <td data-label="Default">${row.default ? html `<code>${row.default}</code>` : 'Unknown'}</td>
765
- <td data-label="Current value">${this.#control(row)}</td>
766
- <td data-label="Description">
767
- ${row.description ? inline(row.description) : html `<span class="wb-not-documented">Not documented</span>`}
910
+ <td data-label="Default">${row.default ? unquote(row.default) : 'Unknown'}</td>
911
+ <td data-label="Attribute">
912
+ ${row.attribute ? (row.property ? row.attribute : `${row.attribute} (attribute only)`) : row.propertyOnly ? 'Property only' : 'None'}
768
913
  </td>
914
+ <td data-label="Reflected">
915
+ ${row.property && row.attribute ? (row.reflects ? 'Yes' : 'No') : '—'}
916
+ </td>
917
+ <td data-label="Control">${this.#control(row)}</td>
769
918
  </tr>`;
770
919
  }
771
920
  #control(row) {
@@ -786,7 +935,11 @@ export class ComponentDetailElement extends OddkinElement.with({
786
935
  const attrValue = attr ? state.attributes[attr] : undefined;
787
936
  const propSet = prop !== undefined && prop in state.properties;
788
937
  const propValue = prop !== undefined ? state.properties[prop] : undefined;
789
- const modeSwitch = row.property && row.attribute && !row.propertyOnly && row.control !== 'json'
938
+ const modeSwitch = this.layout === 'workbench' &&
939
+ row.property &&
940
+ row.attribute &&
941
+ !row.propertyOnly &&
942
+ row.control !== 'json'
790
943
  ? html `<select
791
944
  aria-label=${`Write ${name} as`}
792
945
  .value=${mode}
@@ -806,6 +959,8 @@ export class ComponentDetailElement extends OddkinElement.with({
806
959
  const checked = mode === 'attribute' ? attrValue !== undefined : propValue === true;
807
960
  control = html `<input
808
961
  type="checkbox"
962
+ role="switch"
963
+ class="wb-switch"
809
964
  aria-label=${label}
810
965
  .checked=${checked}
811
966
  @change=${(event) => {
@@ -834,21 +989,22 @@ export class ComponentDetailElement extends OddkinElement.with({
834
989
  ? '\u0000null'
835
990
  : String(propValue)
836
991
  : '';
837
- control = html `<select
838
- aria-label=${label}
839
- .value=${current}
840
- @change=${(event) => {
841
- const value = event.target.value;
992
+ const write = (value) => {
842
993
  if (mode === 'attribute')
843
994
  this.#writeAttribute(row, value === '' ? null : value);
844
995
  else if (value === '')
845
996
  this.#unsetProperty(row);
846
997
  else
847
998
  this.#writeProperty(row, value === '\u0000null' ? null : value);
848
- }}
999
+ };
1000
+ const values = row.values ?? [];
1001
+ control = html `<select
1002
+ aria-label=${label}
1003
+ .value=${current}
1004
+ @change=${(event) => write(event.target.value)}
849
1005
  >
850
1006
  <option value="" ?selected=${current === ''}>(unset)</option>
851
- ${(row.values ?? []).map((value) => html `<option value=${value} ?selected=${current === value}>${value}</option>`)}
1007
+ ${values.map((value) => html `<option value=${value} ?selected=${current === value}>${value}</option>`)}
852
1008
  ${mode === 'property' && row.nullable
853
1009
  ? html `<option value=${'\u0000null'} ?selected=${current === '\u0000null'}>
854
1010
  null
@@ -893,25 +1049,20 @@ export class ComponentDetailElement extends OddkinElement.with({
893
1049
  aria-label=${label}
894
1050
  placeholder=${isSet ? '' : 'unset'}
895
1051
  .value=${shown}
896
- @change=${(event) => {
1052
+ @input=${(event) => {
1053
+ // The preview follows the field as the reader types; an unfinished number waits.
897
1054
  const text = event.target.value;
898
- if (mode === 'attribute') {
899
- if (number && text !== '' && !Number.isFinite(Number(text))) {
900
- this.#setError(row.id, 'Not written: not a number.');
901
- return;
902
- }
903
- this.#writeAttribute(row, text);
1055
+ if (this.#textProblem(number, mode, text))
904
1056
  return;
905
- }
906
- if (number) {
907
- if (text.trim() === '' || !Number.isFinite(Number(text))) {
908
- this.#setError(row.id, 'Not written: enter a number, or use Unset.');
909
- return;
910
- }
911
- this.#writeProperty(row, Number(text));
912
- }
1057
+ if (mode === 'attribute')
1058
+ this.#writeAttribute(row, text);
913
1059
  else
914
- this.#writeProperty(row, text);
1060
+ this.#writeProperty(row, number ? Number(text) : text);
1061
+ }}
1062
+ @change=${(event) => {
1063
+ const problem = this.#textProblem(number, mode, event.target.value);
1064
+ if (problem)
1065
+ this.#setError(row.id, problem);
915
1066
  }}
916
1067
  />
917
1068
  <button
@@ -948,61 +1099,133 @@ export class ComponentDetailElement extends OddkinElement.with({
948
1099
  return html `<div class="wb-cell-controls">${control} ${modeSwitch}</div>
949
1100
  ${error ? html `<p class="wb-error" role="alert">${error}</p>` : nothing}`;
950
1101
  }
951
- #slots(facts) {
952
- const state = this.#state.value;
1102
+ /** One group of the controls: its header row names the group and its columns, and stays in view. */
1103
+ #group(id, caption, heads, rows) {
953
1104
  return html `<div class="wb-table-wrap">
954
- <table class="wb-stack" data-table="slots">
1105
+ <table class="wb-stack wb-api" data-table=${id}>
1106
+ <caption class="wb-visually-hidden">
1107
+ ${caption}
1108
+ </caption>
1109
+ <colgroup>
1110
+ ${[0, 1, 2, 3, 4, 5].map(() => html `<col />`)}
1111
+ </colgroup>
955
1112
  <thead>
956
1113
  <tr>
957
- <th scope="col">Slot</th>
958
- <th scope="col">Description</th>
959
- <th scope="col">Text content</th>
1114
+ ${heads.map((head, i) => html `<th scope="col" colspan=${heads.length === 5 && i === 3 ? '2' : null}>
1115
+ ${head}
1116
+ </th>`)}
960
1117
  </tr>
961
1118
  </thead>
962
1119
  <tbody>
963
- ${facts.slots.map((slot) => {
964
- const label = slot.name || '(default)';
965
- return html `<tr data-slot=${slot.name}>
966
- <th scope="row" data-label="Slot"><code>${label}</code></th>
967
- <td data-label="Description">
968
- ${slot.description ? inline(slot.description) : html `<span class="wb-not-documented">Not documented</span>`}
969
- </td>
970
- <td data-label="Text content">
971
- <input
972
- type="text"
973
- aria-label=${`${label} slot text`}
974
- .value=${state.slots[slot.name] ?? ''}
975
- @change=${(event) => this.#writeSlot(slot.name, event.target.value)}
976
- />
977
- <small>Text only; richer compositions need a fixture from defineExamples.</small>
978
- </td>
979
- </tr>`;
980
- })}
1120
+ ${rows}
981
1121
  </tbody>
982
1122
  </table>
983
1123
  </div>`;
984
1124
  }
1125
+ #slots(facts) {
1126
+ const state = this.#state.value;
1127
+ return this.#group('slots', 'Slots: text content written into the preview.', ['Slots', 'Content type', 'Default', 'Name', 'Position', 'Control / current content'], facts.slots.map((slot) => {
1128
+ const label = slot.name || 'default';
1129
+ return html `<tr data-slot=${slot.name}>
1130
+ ${named(label, undefined)}
1131
+ <td data-label="Content type">Text</td>
1132
+ <td data-label="Default">Empty</td>
1133
+ <td data-label="Name">${slot.name || '(none)'}</td>
1134
+ <td data-label="Position">
1135
+ ${slot.description ? inline(slot.description) : html `<span class="wb-not-documented">Not documented</span>`}
1136
+ </td>
1137
+ <td data-label="Control">
1138
+ <input
1139
+ type="text"
1140
+ aria-label=${`${label} slot text`}
1141
+ .value=${state.slots[slot.name] ?? ''}
1142
+ @input=${(event) => this.#writeSlot(slot.name, event.target.value)}
1143
+ />
1144
+ </td>
1145
+ </tr>`;
1146
+ }));
1147
+ }
1148
+ #cssTable(facts) {
1149
+ const styles = this.#state.value.styles ?? {};
1150
+ return this.#group('css-properties', "CSS custom properties: values set on the preview element's style.", ['CSS custom properties', 'Syntax', 'Default', 'Description', 'Control / current value'], facts.cssProperties.map((entry) => {
1151
+ const value = styles[entry.name];
1152
+ return html `<tr data-css=${entry.name}>
1153
+ ${named(entry.name, undefined)}
1154
+ <td data-label="Syntax">${entry.type ?? 'Unknown'}</td>
1155
+ <td data-label="Default">${entry.default ?? 'Unknown'}</td>
1156
+ <td data-label="Description" colspan="2">
1157
+ ${entry.description ? inline(entry.description) : html `<span class="wb-not-documented">Not documented</span>`}
1158
+ </td>
1159
+ <td data-label="Control">
1160
+ ${isCustomPropertyName(entry.name)
1161
+ ? html `<div class="wb-cell-controls">
1162
+ <input
1163
+ type="text"
1164
+ aria-label=${`${entry.name} value`}
1165
+ placeholder="unset"
1166
+ .value=${value ?? ''}
1167
+ @input=${(event) => {
1168
+ const text = event.target.value;
1169
+ this.#writeStyle(entry.name, text === '' ? null : text);
1170
+ }}
1171
+ />
1172
+ <button
1173
+ type="button"
1174
+ ?disabled=${value === undefined}
1175
+ @click=${() => this.#writeStyle(entry.name, null)}
1176
+ >
1177
+ Unset
1178
+ </button>
1179
+ </div>`
1180
+ : html `<span class="wb-muted">Not editable: not a custom property name.</span>`}
1181
+ </td>
1182
+ </tr>`;
1183
+ }));
1184
+ }
1185
+ /** The published examples as cards (Figma 20:2266), each with a live specimen when the host gives one. */
985
1186
  #examples(page) {
986
- const entries = presets(page);
987
- return html `<ul class="wb-list wb-examples">
988
- ${entries.map((entry) => html `<li data-example=${entry.id}>
989
- <strong>${entry.label}</strong> <code>${entry.id}</code>
990
- ${entry.incorrect ? html `<small>Incorrect usage</small>` : nothing}
991
- ${entry.guidance ? html `<p>${inline(entry.guidance)}</p>` : nothing}
992
- ${entry.incorrect?.explanation ? html `<p>${inline(entry.incorrect.explanation)}</p>` : nothing}
993
- ${entry.requiresFixture
994
- ? html `<p class="wb-muted">
995
- Requires fixture: rendered by its example module only.
996
- </p>`
997
- : entry.state
998
- ? html `<button type="button" @click=${() => this.#applyPreset(entry)}>
999
- Show in preview
1000
- </button>`
1001
- : html `<p class="wb-muted">
1002
- Values not published (the example module was not loaded).
1003
- </p>`}
1004
- </li>`)}
1005
- </ul>`;
1187
+ const preview = this.examplePreview;
1188
+ const tools = this.layout === 'workbench';
1189
+ const shown = presets(page).filter((e) => !e.incorrect && (tools || !e.requiresFixture));
1190
+ // Presets lead; the reference layout shows four and keeps the rest one action away.
1191
+ const ordered = [...shown.filter((e) => e.preset), ...shown.filter((e) => !e.preset)];
1192
+ const all = tools || this.#allExamples.value;
1193
+ return html `<p class="wb-lede">Compare the published examples without leaving the page.</p>
1194
+ <ul class="wb-boxes wb-examples" role="list">
1195
+ ${(all ? ordered : ordered.slice(0, 4)).map((entry) => {
1196
+ const node = preview?.(page, entry.id);
1197
+ return html `<li class="wb-box" data-example=${entry.id}>
1198
+ ${node ? html `<div class="wb-specimen">${node}</div>` : nothing}
1199
+ <div class="wb-box-body">
1200
+ <h3>${entry.label}</h3>
1201
+ ${entry.guidance ? html `<p>${inline(entry.guidance)}</p>` : nothing}
1202
+ ${entry.requiresFixture
1203
+ ? html `<p class="wb-muted">
1204
+ Requires fixture: rendered by its example module only.
1205
+ </p>`
1206
+ : !tools
1207
+ ? nothing
1208
+ : entry.state
1209
+ ? html `<button type="button" @click=${() => this.#applyPreset(entry)}>
1210
+ Show in preview
1211
+ </button>`
1212
+ : html `<p class="wb-muted">
1213
+ Values not published (the example module was not loaded).
1214
+ </p>`}
1215
+ </div>
1216
+ </li>`;
1217
+ })}
1218
+ </ul>
1219
+ ${!tools && ordered.length > 4
1220
+ ? html `<button
1221
+ type="button"
1222
+ class="wb-more"
1223
+ aria-expanded=${all ? 'true' : 'false'}
1224
+ @click=${() => (this.#allExamples.value = !all)}
1225
+ >
1226
+ ${all ? 'Show fewer examples' : `Show all ${ordered.length} examples`}
1227
+ </button>`
1228
+ : nothing}`;
1006
1229
  }
1007
1230
  #tokens(page, at) {
1008
1231
  const mappings = Array.isArray(page.enrichment?.['styleMappings'])
@@ -1083,38 +1306,47 @@ export class ComponentDetailElement extends OddkinElement.with({
1083
1306
  </table>
1084
1307
  </div>`)}`;
1085
1308
  }
1086
- #behavior(page, facts, at) {
1087
- return html `<h3>Accessibility</h3>
1088
- ${at('behavior-and-accessibility/a11y', () => bullets(authoredValues(page.enrichment?.['a11y'])))}
1089
- <h3>Keyboard</h3>
1090
- ${at('behavior-and-accessibility/keyboard', () => bullets(authoredValues(page.enrichment?.['keyboard'])))}
1091
- ${facts.cssStates.length
1092
- ? html `<h3>States</h3>
1093
- <ul class="wb-list">
1094
- ${facts.cssStates.map((s) => html `<li>
1095
- <code>:state(${s.name})</code
1096
- >${s.description ? html ` — ${inline(s.description)}` : nothing}
1097
- </li>`)}
1098
- </ul>`
1099
- : nothing}`;
1309
+ /** Accessibility as two cards (Figma 20:2292): behavior, then keyboard and guidance. */
1310
+ #behavior(page, at) {
1311
+ const card = (title, key) => {
1312
+ const items = authoredValues(page.enrichment?.[key]);
1313
+ return this.layout === 'reference' && !items.length
1314
+ ? nothing
1315
+ : html `<div class="wb-box">
1316
+ <h3>${title}</h3>
1317
+ ${at(`behavior-and-accessibility/${key}`, () => bullets(items))}
1318
+ </div>`;
1319
+ };
1320
+ return html `<div class="wb-boxes">
1321
+ ${card('Behavior', 'a11y')} ${card('Keyboard and guidance', 'keyboard')}
1322
+ </div>`;
1100
1323
  }
1101
- #source(page) {
1324
+ /** Where the element lives and how to register it (Figma 232:9845). */
1325
+ #source(page, at) {
1102
1326
  const definition = isRecord(page.enrichment?.['definition'])
1103
1327
  ? page.enrichment['definition']
1104
1328
  : undefined;
1105
1329
  const ref = isRecord(definition?.['ref']) ? definition['ref'] : undefined;
1106
- return html `<dl>
1107
- <dt>Package</dt>
1108
- <dd><code>${page.package}</code></dd>
1109
- <dt>Module</dt>
1110
- <dd><code>${page.module}</code></dd>
1111
- <dt>Class</dt>
1112
- <dd><code>${String(page.declaration['name'] ?? 'Unknown')}</code></dd>
1113
- ${ref
1114
- ? html `<dt>Definition</dt>
1115
- <dd><code>${String(ref['module'])}</code> (<code>${String(ref['name'])}</code>)</dd>`
1116
- : nothing}
1117
- </dl>`;
1330
+ const field = (label, ...values) => html `<div class="wb-fact">
1331
+ <span class="wb-fact-label">${label}</span>
1332
+ ${values.map((value) => html `<code
1333
+ >${String(value)
1334
+ .split('/')
1335
+ .map((part, i) => (i ? html `/<wbr />${part}` : part))}</code
1336
+ >`)}
1337
+ </div>`;
1338
+ return html `<p class="wb-lede">
1339
+ Where the element is published, so it is easy to locate, import, and verify.
1340
+ </p>
1341
+ ${at('source/module', () => html `<div class="wb-facts">
1342
+ ${field('Package', page.package)} ${field('Module', page.module)}
1343
+ ${field('Class', page.declaration['name'] ?? 'Unknown')}
1344
+ ${ref ? field('Definition', ref['name'], ref['module']) : nothing}
1345
+ </div>`)}
1346
+ ${at('source/register', () => html `<div class="wb-fact wb-register">
1347
+ <span class="wb-fact-label">Register</span>
1348
+ <code>import '${String(registrationOf(page))}';</code>
1349
+ </div>`)}`;
1118
1350
  }
1119
1351
  }
1120
1352
  //# sourceMappingURL=component-detail.js.map