@oddkin/workbench 0.0.0-stage → 1.0.0-beta.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 (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -2
  3. package/dist/app/copy.d.ts +43 -0
  4. package/dist/app/copy.d.ts.map +1 -0
  5. package/dist/app/copy.js +47 -0
  6. package/dist/app/copy.js.map +1 -0
  7. package/dist/app/define.d.ts +3 -0
  8. package/dist/app/define.d.ts.map +1 -0
  9. package/dist/app/define.js +10 -0
  10. package/dist/app/define.js.map +1 -0
  11. package/dist/app/details.d.ts +22 -0
  12. package/dist/app/details.d.ts.map +1 -0
  13. package/dist/app/details.js +16 -0
  14. package/dist/app/details.js.map +1 -0
  15. package/dist/app/index.d.ts +12 -0
  16. package/dist/app/index.d.ts.map +1 -0
  17. package/dist/app/index.js +9 -0
  18. package/dist/app/index.js.map +1 -0
  19. package/dist/app/routes.d.ts +26 -0
  20. package/dist/app/routes.d.ts.map +1 -0
  21. package/dist/app/routes.js +94 -0
  22. package/dist/app/routes.js.map +1 -0
  23. package/dist/app/search.d.ts +17 -0
  24. package/dist/app/search.d.ts.map +1 -0
  25. package/dist/app/search.js +52 -0
  26. package/dist/app/search.js.map +1 -0
  27. package/dist/app/shell.d.ts +29 -0
  28. package/dist/app/shell.d.ts.map +1 -0
  29. package/dist/app/shell.js +435 -0
  30. package/dist/app/shell.js.map +1 -0
  31. package/dist/app/styles.d.ts +2 -0
  32. package/dist/app/styles.d.ts.map +1 -0
  33. package/dist/app/styles.js +373 -0
  34. package/dist/app/styles.js.map +1 -0
  35. package/dist/app/theme-studio.d.ts +12 -0
  36. package/dist/app/theme-studio.d.ts.map +1 -0
  37. package/dist/app/theme-studio.js +14 -0
  38. package/dist/app/theme-studio.js.map +1 -0
  39. package/dist/app/views.d.ts +34 -0
  40. package/dist/app/views.d.ts.map +1 -0
  41. package/dist/app/views.js +230 -0
  42. package/dist/app/views.js.map +1 -0
  43. package/dist/config/index.d.ts +170 -0
  44. package/dist/config/index.d.ts.map +1 -0
  45. package/dist/config/index.js +148 -0
  46. package/dist/config/index.js.map +1 -0
  47. package/dist/conformance/button.d.ts +2 -0
  48. package/dist/conformance/button.d.ts.map +1 -0
  49. package/dist/conformance/button.js +233 -0
  50. package/dist/conformance/button.js.map +1 -0
  51. package/dist/conformance/index.d.ts +107 -0
  52. package/dist/conformance/index.d.ts.map +1 -0
  53. package/dist/conformance/index.js +60 -0
  54. package/dist/conformance/index.js.map +1 -0
  55. package/dist/data/build.d.ts +36 -0
  56. package/dist/data/build.d.ts.map +1 -0
  57. package/dist/data/build.js +248 -0
  58. package/dist/data/build.js.map +1 -0
  59. package/dist/data/import-map.d.ts +53 -0
  60. package/dist/data/import-map.d.ts.map +1 -0
  61. package/dist/data/import-map.js +160 -0
  62. package/dist/data/import-map.js.map +1 -0
  63. package/dist/data/index.d.ts +14 -0
  64. package/dist/data/index.d.ts.map +1 -0
  65. package/dist/data/index.js +10 -0
  66. package/dist/data/index.js.map +1 -0
  67. package/dist/data/theme-studio.d.ts +21 -0
  68. package/dist/data/theme-studio.d.ts.map +1 -0
  69. package/dist/data/theme-studio.js +116 -0
  70. package/dist/data/theme-studio.js.map +1 -0
  71. package/dist/data/types.d.ts +169 -0
  72. package/dist/data/types.d.ts.map +1 -0
  73. package/dist/data/types.js +4 -0
  74. package/dist/data/types.js.map +1 -0
  75. package/dist/examples/index.d.ts +101 -0
  76. package/dist/examples/index.d.ts.map +1 -0
  77. package/dist/examples/index.js +106 -0
  78. package/dist/examples/index.js.map +1 -0
  79. package/dist/frame/index.d.ts +9 -0
  80. package/dist/frame/index.d.ts.map +1 -0
  81. package/dist/frame/index.js +468 -0
  82. package/dist/frame/index.js.map +1 -0
  83. package/dist/internal/diagnostics.d.ts +17 -0
  84. package/dist/internal/diagnostics.d.ts.map +1 -0
  85. package/dist/internal/diagnostics.js +19 -0
  86. package/dist/internal/diagnostics.js.map +1 -0
  87. package/dist/node/bin.d.ts +3 -0
  88. package/dist/node/bin.d.ts.map +1 -0
  89. package/dist/node/bin.js +12 -0
  90. package/dist/node/bin.js.map +1 -0
  91. package/dist/node/cli.d.ts +17 -0
  92. package/dist/node/cli.d.ts.map +1 -0
  93. package/dist/node/cli.js +198 -0
  94. package/dist/node/cli.js.map +1 -0
  95. package/dist/node/config.d.ts +28 -0
  96. package/dist/node/config.d.ts.map +1 -0
  97. package/dist/node/config.js +80 -0
  98. package/dist/node/config.js.map +1 -0
  99. package/dist/node/generated.d.ts +19 -0
  100. package/dist/node/generated.d.ts.map +1 -0
  101. package/dist/node/generated.js +109 -0
  102. package/dist/node/generated.js.map +1 -0
  103. package/dist/node/html.d.ts +35 -0
  104. package/dist/node/html.d.ts.map +1 -0
  105. package/dist/node/html.js +51 -0
  106. package/dist/node/html.js.map +1 -0
  107. package/dist/node/index.d.ts +24 -0
  108. package/dist/node/index.d.ts.map +1 -0
  109. package/dist/node/index.js +15 -0
  110. package/dist/node/index.js.map +1 -0
  111. package/dist/node/inputs.d.ts +24 -0
  112. package/dist/node/inputs.d.ts.map +1 -0
  113. package/dist/node/inputs.js +259 -0
  114. package/dist/node/inputs.js.map +1 -0
  115. package/dist/node/packages.d.ts +35 -0
  116. package/dist/node/packages.d.ts.map +1 -0
  117. package/dist/node/packages.js +87 -0
  118. package/dist/node/packages.js.map +1 -0
  119. package/dist/node/preload.d.ts +10 -0
  120. package/dist/node/preload.d.ts.map +1 -0
  121. package/dist/node/preload.js +61 -0
  122. package/dist/node/preload.js.map +1 -0
  123. package/dist/node/server.d.ts +42 -0
  124. package/dist/node/server.d.ts.map +1 -0
  125. package/dist/node/server.js +369 -0
  126. package/dist/node/server.js.map +1 -0
  127. package/dist/node/static.d.ts +33 -0
  128. package/dist/node/static.d.ts.map +1 -0
  129. package/dist/node/static.js +141 -0
  130. package/dist/node/static.js.map +1 -0
  131. package/dist/node/workbench.d.ts +43 -0
  132. package/dist/node/workbench.d.ts.map +1 -0
  133. package/dist/node/workbench.js +183 -0
  134. package/dist/node/workbench.js.map +1 -0
  135. package/dist/pages/build.d.ts +22 -0
  136. package/dist/pages/build.d.ts.map +1 -0
  137. package/dist/pages/build.js +363 -0
  138. package/dist/pages/build.js.map +1 -0
  139. package/dist/pages/defaults.d.ts +16 -0
  140. package/dist/pages/defaults.d.ts.map +1 -0
  141. package/dist/pages/defaults.js +88 -0
  142. package/dist/pages/defaults.js.map +1 -0
  143. package/dist/pages/index.d.ts +14 -0
  144. package/dist/pages/index.d.ts.map +1 -0
  145. package/dist/pages/index.js +10 -0
  146. package/dist/pages/index.js.map +1 -0
  147. package/dist/pages/navigation.d.ts +48 -0
  148. package/dist/pages/navigation.d.ts.map +1 -0
  149. package/dist/pages/navigation.js +83 -0
  150. package/dist/pages/navigation.js.map +1 -0
  151. package/dist/pages/types.d.ts +143 -0
  152. package/dist/pages/types.d.ts.map +1 -0
  153. package/dist/pages/types.js +3 -0
  154. package/dist/pages/types.js.map +1 -0
  155. package/dist/panels/catalog-define.d.ts +3 -0
  156. package/dist/panels/catalog-define.d.ts.map +1 -0
  157. package/dist/panels/catalog-define.js +7 -0
  158. package/dist/panels/catalog-define.js.map +1 -0
  159. package/dist/panels/catalog.d.ts +33 -0
  160. package/dist/panels/catalog.d.ts.map +1 -0
  161. package/dist/panels/catalog.js +54 -0
  162. package/dist/panels/catalog.js.map +1 -0
  163. package/dist/panels/component-define.d.ts +3 -0
  164. package/dist/panels/component-define.d.ts.map +1 -0
  165. package/dist/panels/component-define.js +6 -0
  166. package/dist/panels/component-define.js.map +1 -0
  167. package/dist/panels/component-detail.d.ts +46 -0
  168. package/dist/panels/component-detail.d.ts.map +1 -0
  169. package/dist/panels/component-detail.js +1120 -0
  170. package/dist/panels/component-detail.js.map +1 -0
  171. package/dist/panels/component.d.ts +17 -0
  172. package/dist/panels/component.d.ts.map +1 -0
  173. package/dist/panels/component.js +10 -0
  174. package/dist/panels/component.js.map +1 -0
  175. package/dist/panels/foundation-define.d.ts +3 -0
  176. package/dist/panels/foundation-define.d.ts.map +1 -0
  177. package/dist/panels/foundation-define.js +6 -0
  178. package/dist/panels/foundation-define.js.map +1 -0
  179. package/dist/panels/foundation-detail.d.ts +23 -0
  180. package/dist/panels/foundation-detail.d.ts.map +1 -0
  181. package/dist/panels/foundation-detail.js +216 -0
  182. package/dist/panels/foundation-detail.js.map +1 -0
  183. package/dist/panels/foundation.d.ts +15 -0
  184. package/dist/panels/foundation.d.ts.map +1 -0
  185. package/dist/panels/foundation.js +9 -0
  186. package/dist/panels/foundation.js.map +1 -0
  187. package/dist/panels/model.d.ts +173 -0
  188. package/dist/panels/model.d.ts.map +1 -0
  189. package/dist/panels/model.js +462 -0
  190. package/dist/panels/model.js.map +1 -0
  191. package/dist/panels/styles.d.ts +3 -0
  192. package/dist/panels/styles.d.ts.map +1 -0
  193. package/dist/panels/styles.js +316 -0
  194. package/dist/panels/styles.js.map +1 -0
  195. package/dist/panels/theme-model.d.ts +49 -0
  196. package/dist/panels/theme-model.d.ts.map +1 -0
  197. package/dist/panels/theme-model.js +146 -0
  198. package/dist/panels/theme-model.js.map +1 -0
  199. package/dist/panels/theme-studio.d.ts +32 -0
  200. package/dist/panels/theme-studio.d.ts.map +1 -0
  201. package/dist/panels/theme-studio.js +312 -0
  202. package/dist/panels/theme-studio.js.map +1 -0
  203. package/dist/preview/frame.d.ts +44 -0
  204. package/dist/preview/frame.d.ts.map +1 -0
  205. package/dist/preview/frame.js +250 -0
  206. package/dist/preview/frame.js.map +1 -0
  207. package/dist/preview/index.d.ts +26 -0
  208. package/dist/preview/index.d.ts.map +1 -0
  209. package/dist/preview/index.js +58 -0
  210. package/dist/preview/index.js.map +1 -0
  211. package/dist/preview/theme-generation.d.ts +33 -0
  212. package/dist/preview/theme-generation.d.ts.map +1 -0
  213. package/dist/preview/theme-generation.js +48 -0
  214. package/dist/preview/theme-generation.js.map +1 -0
  215. package/dist/protocol/index.d.ts +207 -0
  216. package/dist/protocol/index.d.ts.map +1 -0
  217. package/dist/protocol/index.js +261 -0
  218. package/dist/protocol/index.js.map +1 -0
  219. package/dist/testing/index.d.ts +47 -0
  220. package/dist/testing/index.d.ts.map +1 -0
  221. package/dist/testing/index.js +145 -0
  222. package/dist/testing/index.js.map +1 -0
  223. package/package.json +118 -4
@@ -0,0 +1,1120 @@
1
+ /**
2
+ * Component detail content (workbench-pages.md §7): every section of a component page, with the
3
+ * editable API table as the only control surface over one isolated preview (or a light/dark
4
+ * pair). Rendered by the public `<oddkin-component-panel>` (open shadow root) and by the shell
5
+ * (light DOM, so "On this page" fragments resolve).
6
+ */
7
+ import { OddkinElement, prop } from '@oddkin/core/element';
8
+ import { signal, untracked } from '@oddkin/core/reactive';
9
+ import { createScope } from '@oddkin/core/scope';
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';
13
+ import { contentStyles, panelHostStyles } from './styles.js';
14
+ const LOG_LIMIT = 50;
15
+ const WIDTHS = {
16
+ narrow: 320,
17
+ medium: 640,
18
+ wide: 960,
19
+ };
20
+ const DEFAULT_SETTINGS = {
21
+ mode: 'site',
22
+ pair: false,
23
+ direction: 'ltr',
24
+ width: 'fill',
25
+ customWidth: 480,
26
+ };
27
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
28
+ const message = (error) => (error instanceof Error ? error.message : String(error));
29
+ /** Text with Markdown code spans shown as code; everything stays text, never markup. */
30
+ function inline(text) {
31
+ const parts = text.split('`');
32
+ if (parts.length % 2 === 0)
33
+ parts[parts.length - 2] += `\`${parts.pop()}`;
34
+ return parts.map((part, i) => (i % 2 ? html `<code>${part}</code>` : part));
35
+ }
36
+ const notDocumented = (key, subject) => {
37
+ const lead = `${subject ? `${subject}: not documented` : 'Not documented'}. Supplied by `;
38
+ return html `<p class="wb-not-documented">${lead}<code>${key}</code>.</p>`;
39
+ };
40
+ const bullets = (items) => html `<ul class="wb-list">
41
+ ${items.map((item) => html `<li>${inline(item)}</li>`)}
42
+ </ul>`;
43
+ function covered(coverage) {
44
+ const byId = new Map(coverage.map((item) => [item.id, item]));
45
+ return (id, content) => {
46
+ const item = byId.get(id);
47
+ if (!item)
48
+ return nothing;
49
+ return html `<div data-coverage=${id} data-state=${item.state}>
50
+ ${item.state === 'documented'
51
+ ? content()
52
+ : item.state === 'none'
53
+ ? html `<p class="wb-muted">${item.empty}</p>`
54
+ : notDocumented(item.source ?? '', item.subject)}
55
+ </div>`;
56
+ };
57
+ }
58
+ export class ComponentDetailElement extends OddkinElement.with({
59
+ page: prop.object({ description: 'The component page data.' }),
60
+ index: prop.object({ description: 'The Workbench index.' }),
61
+ href: prop.custom({
62
+ default: () => (route) => route,
63
+ coerce: (value) => typeof value === 'function'
64
+ ? { ok: true, value: value }
65
+ : { ok: false, reason: 'expected a function' },
66
+ description: 'Maps a Workbench route to a URL.',
67
+ }),
68
+ frameUrl: prop.string({
69
+ default: '',
70
+ attribute: false,
71
+ description: 'URL of the preview frame document.',
72
+ }),
73
+ }) {
74
+ static styles = [panelHostStyles, contentStyles];
75
+ #state = signal({
76
+ preset: 'default',
77
+ attributes: {},
78
+ properties: {},
79
+ slots: {},
80
+ });
81
+ #modes = signal({});
82
+ #errors = signal({});
83
+ #settings = signal(DEFAULT_SETTINGS);
84
+ #log = signal([]);
85
+ #status = signal({
86
+ text: '',
87
+ alert: false,
88
+ });
89
+ #link = signal('');
90
+ #hosts;
91
+ #previews = [];
92
+ #previewScope;
93
+ #key = '';
94
+ #logId = 0;
95
+ constructor() {
96
+ super();
97
+ this.#hosts = [document.createElement('div'), document.createElement('div')];
98
+ }
99
+ /** Current preview configuration (for tests and embedding); read-only snapshot. */
100
+ get previewState() {
101
+ return this.#state.peek();
102
+ }
103
+ /** Live preview frames, in order (light first when shown side by side). */
104
+ get previews() {
105
+ return this.#previews;
106
+ }
107
+ connected(scope) {
108
+ scope.effect(() => {
109
+ const page = this.page;
110
+ const frameUrl = this.frameUrl;
111
+ untracked(() => this.#sync(page, frameUrl));
112
+ });
113
+ scope.add(() => {
114
+ this.#key = '';
115
+ this.#stop();
116
+ });
117
+ }
118
+ /** Restart the preview only when the component, registration, or frame document changes. */
119
+ #sync(page, frameUrl) {
120
+ const registration = page?.enrichment?.['registration'];
121
+ const key = page ? `${page.tag}\n${String(registration)}\n${frameUrl}` : '';
122
+ if (key === this.#key)
123
+ return;
124
+ const sameTag = this.#key.split('\n')[0] === page?.tag;
125
+ this.#key = key;
126
+ if (!page) {
127
+ this.#stop();
128
+ return;
129
+ }
130
+ if (!sameTag) {
131
+ this.#modes.value = {};
132
+ this.#errors.value = {};
133
+ this.#log.value = [];
134
+ this.#link.value = '';
135
+ this.#state.value = this.#fromLocation(page) ?? initialState(page);
136
+ }
137
+ this.#restart();
138
+ }
139
+ #fromLocation(page) {
140
+ const location = this.ownerDocument.defaultView?.location;
141
+ const hashQuery = location?.hash.includes('?')
142
+ ? location.hash.slice(location.hash.indexOf('?'))
143
+ : '';
144
+ const encoded = new URLSearchParams(location?.search ?? '').get(SHARE_PARAM) ??
145
+ new URLSearchParams(hashQuery.split('#')[0]).get(SHARE_PARAM);
146
+ if (!encoded)
147
+ return undefined;
148
+ const { state, preview, problems } = decodeShare(page, encoded);
149
+ if (problems.length)
150
+ this.#status.value = { text: problems.join(' '), alert: !state, keep: true };
151
+ if (!state)
152
+ return undefined;
153
+ if (preview) {
154
+ const width = preview.width;
155
+ const named = typeof width === 'number'
156
+ ? Object.keys(WIDTHS).find((key) => WIDTHS[key] === width)
157
+ : undefined;
158
+ this.#settings.value = {
159
+ ...DEFAULT_SETTINGS,
160
+ mode: preview.mode ?? 'site',
161
+ direction: preview.direction ?? 'ltr',
162
+ ...(typeof width !== 'number'
163
+ ? {}
164
+ : named
165
+ ? { width: named }
166
+ : { width: 'custom', customWidth: width }),
167
+ };
168
+ }
169
+ return state;
170
+ }
171
+ #stop() {
172
+ this.#previewScope?.dispose();
173
+ this.#previewScope = undefined;
174
+ this.#previews = [];
175
+ for (const host of this.#hosts)
176
+ host.replaceChildren();
177
+ }
178
+ #frameSettings(index) {
179
+ const settings = this.#settings.peek();
180
+ const mode = settings.pair ? (index === 0 ? 'light' : 'dark') : settings.mode;
181
+ const width = settings.width === 'fill'
182
+ ? undefined
183
+ : settings.width === 'custom'
184
+ ? settings.customWidth
185
+ : WIDTHS[settings.width];
186
+ return {
187
+ ...(mode === 'site' ? {} : { mode }),
188
+ direction: settings.direction,
189
+ ...(width ? { container: { width } } : {}),
190
+ };
191
+ }
192
+ #mountPayload(page, registration, index) {
193
+ const state = this.#state.peek();
194
+ const facts = componentFacts(page);
195
+ return {
196
+ tag: page.tag,
197
+ registration,
198
+ example: `example:${page.tag}/${state.preset}`,
199
+ properties: state.properties,
200
+ attributes: state.attributes,
201
+ slots: state.slots,
202
+ events: facts.events.map((event) => event.name),
203
+ sync: facts.syncPairs,
204
+ settings: this.#frameSettings(index),
205
+ };
206
+ }
207
+ /** Create the preview frame(s) and mount the current state; replaces earlier frames. */
208
+ #restart() {
209
+ this.#stop();
210
+ const page = this.page;
211
+ if (!page || !this.isConnected)
212
+ return;
213
+ const registration = page.enrichment?.['registration'];
214
+ // Without a registration entry the preview item itself says what is missing.
215
+ if (typeof registration !== 'string') {
216
+ if (!this.#status.peek().keep)
217
+ this.#status.value = { text: '', alert: false };
218
+ return;
219
+ }
220
+ if (!this.frameUrl) {
221
+ this.#status.value = {
222
+ text: 'Preview unavailable: no preview frame document is configured.',
223
+ alert: false,
224
+ };
225
+ return;
226
+ }
227
+ const scope = createScope({ name: `workbench-detail:${page.tag}` });
228
+ this.#previewScope = scope;
229
+ const count = this.#settings.peek().pair ? 2 : 1;
230
+ try {
231
+ for (let i = 0; i < count; i++) {
232
+ const preview = createIsolatedPreview({
233
+ document: this.ownerDocument,
234
+ container: this.#hosts[i],
235
+ frameUrl: this.frameUrl,
236
+ title: `${page.tag} preview${count === 2 ? (i === 0 ? ' (light)' : ' (dark)') : ''}`,
237
+ onMessage: (notification) => this.#onMessage(notification, i),
238
+ });
239
+ preview.iframe.style.blockSize = '14rem';
240
+ scope.add(() => preview.dispose(), 'isolated preview');
241
+ this.#previews.push(preview);
242
+ }
243
+ }
244
+ catch (error) {
245
+ this.#fail(error);
246
+ return;
247
+ }
248
+ this.#mountAll(scope, page, registration);
249
+ }
250
+ #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(() => {
253
+ if (!scope.disposed && !this.#status.peek().alert && !this.#status.peek().keep)
254
+ this.#status.value = { text: '', alert: false };
255
+ }, (error) => {
256
+ if (!scope.disposed)
257
+ this.#fail(error);
258
+ });
259
+ }
260
+ #fail(error) {
261
+ this.#status.value = { text: `Preview unavailable: ${message(error)}`, alert: true };
262
+ }
263
+ #onMessage(notification, index) {
264
+ const payload = notification.payload;
265
+ if (notification.kind === 'event' && isRecord(payload) && index === 0) {
266
+ const entry = {
267
+ id: ++this.#logId,
268
+ name: String(payload['name']),
269
+ time: typeof payload['time'] === 'number' ? payload['time'] : 0,
270
+ bubbles: payload['bubbles'] === true,
271
+ composed: payload['composed'] === true,
272
+ target: String(payload['target'] ?? ''),
273
+ ...(payload['detail'] !== undefined ? { detail: JSON.stringify(payload['detail']) } : {}),
274
+ };
275
+ this.#log.value = [entry, ...this.#log.peek()].slice(0, LOG_LIMIT);
276
+ }
277
+ else if (notification.kind === 'error' && isRecord(payload)) {
278
+ this.#status.value = {
279
+ text: `Preview ${String(payload['phase'])} failed: ${String(payload['message'])}. Use Reset to start a fresh preview.`,
280
+ alert: true,
281
+ };
282
+ }
283
+ else if (notification.kind === 'state' &&
284
+ isRecord(payload) &&
285
+ typeof payload['property'] === 'string' &&
286
+ index === 0) {
287
+ // A declared sync pair: mirror the value without writing it back (no reflection loop).
288
+ const property = payload['property'];
289
+ const value = payload['value'];
290
+ const state = this.#state.peek();
291
+ this.#state.value = { ...state, properties: { ...state.properties, [property]: value } };
292
+ }
293
+ }
294
+ /** Send one request to every live preview. */
295
+ #send(kind, payload) {
296
+ for (const preview of this.#previews)
297
+ void preview.request(kind, payload).catch((error) => this.#fail(error));
298
+ }
299
+ #setError(id, text) {
300
+ const errors = { ...this.#errors.peek() };
301
+ if (text)
302
+ errors[id] = text;
303
+ else
304
+ delete errors[id];
305
+ this.#errors.value = errors;
306
+ }
307
+ #writeAttribute(row, value) {
308
+ const name = row.attribute;
309
+ const state = this.#state.peek();
310
+ const attributes = { ...state.attributes };
311
+ if (value === null)
312
+ delete attributes[name];
313
+ else
314
+ attributes[name] = value;
315
+ this.#state.value = { ...state, attributes };
316
+ this.#setError(row.id, undefined);
317
+ this.#send('set', { attribute: name, attributeValue: value });
318
+ }
319
+ #writeProperty(row, value) {
320
+ const name = row.property;
321
+ const state = this.#state.peek();
322
+ this.#state.value = { ...state, properties: { ...state.properties, [name]: value } };
323
+ this.#setError(row.id, undefined);
324
+ this.#send('set', { property: name, value });
325
+ }
326
+ /** Unset a property assignment: the frame cannot un-assign, so a clean instance is mounted. */
327
+ #unsetProperty(row) {
328
+ const name = row.property;
329
+ const state = this.#state.peek();
330
+ if (!(name in state.properties))
331
+ return;
332
+ const properties = { ...state.properties };
333
+ delete properties[name];
334
+ this.#state.value = { ...state, properties };
335
+ this.#setError(row.id, undefined);
336
+ this.#remount('Unset recreated the instance without this assignment.');
337
+ }
338
+ #writeSlot(name, text) {
339
+ const state = this.#state.peek();
340
+ this.#state.value = { ...state, slots: { ...state.slots, [name]: text } };
341
+ this.#send('slot', { name, text });
342
+ }
343
+ /** Mount the current state again in the existing frames (a clean instance, same realm). */
344
+ #remount(note) {
345
+ const page = this.page;
346
+ const registration = page?.enrichment?.['registration'];
347
+ const scope = this.#previewScope;
348
+ if (!page || typeof registration !== 'string' || !scope)
349
+ return;
350
+ this.#status.value = { text: note ?? '', alert: false, ...(note ? { keep: true } : {}) };
351
+ this.#mountAll(scope, page, registration);
352
+ }
353
+ #applyPreset(entry) {
354
+ if (!entry.state)
355
+ return;
356
+ this.#state.value = entry.state;
357
+ this.#errors.value = {};
358
+ this.#remount();
359
+ }
360
+ /** Reset: fresh frame documents (new generation) with the selected preset's authored values. */
361
+ #reset() {
362
+ const page = this.page;
363
+ if (!page)
364
+ return;
365
+ const preset = presets(page).find((p) => p.name === this.#state.peek().preset);
366
+ this.#state.value = preset?.state ?? initialState(page);
367
+ this.#errors.value = {};
368
+ this.#log.value = [];
369
+ this.#status.value = { text: '', alert: false };
370
+ this.#replace();
371
+ }
372
+ /**
373
+ * Replace the preview realms with fresh frame documents and mount the current configuration
374
+ * again. The shell calls this when component code changes (a registration cannot be redefined
375
+ * in the same realm).
376
+ */
377
+ replacePreview() {
378
+ return this.#replace();
379
+ }
380
+ #replace() {
381
+ const page = this.page;
382
+ const registration = page?.enrichment?.['registration'];
383
+ const scope = this.#previewScope;
384
+ if (!page || typeof registration !== 'string' || !scope) {
385
+ this.#restart();
386
+ return Promise.resolve();
387
+ }
388
+ return Promise.all(this.#previews.map((preview) => preview.reload())).then(() => this.#mountAll(scope, page, registration), (error) => {
389
+ if (!scope.disposed)
390
+ this.#fail(error);
391
+ });
392
+ }
393
+ #applySettings(next) {
394
+ const pairChanged = next.pair !== this.#settings.peek().pair;
395
+ this.#settings.value = next;
396
+ if (pairChanged) {
397
+ this.#restart();
398
+ return;
399
+ }
400
+ this.#previews.forEach((preview, i) => {
401
+ void preview
402
+ .request('settings', this.#frameSettings(i))
403
+ .catch((error) => this.#fail(error));
404
+ });
405
+ }
406
+ #shareLink(page) {
407
+ const settings = this.#settings.peek();
408
+ const width = settings.width === 'fill'
409
+ ? 'fill'
410
+ : settings.width === 'custom'
411
+ ? settings.customWidth
412
+ : WIDTHS[settings.width];
413
+ // Light and dark side by side is a view of the page, not a preview setting to share.
414
+ const preview = {
415
+ ...(settings.mode === 'site' || settings.pair
416
+ ? { sameAsSite: true }
417
+ : { mode: settings.mode }),
418
+ direction: settings.direction,
419
+ width,
420
+ };
421
+ const encoded = encodeShare(sharePayload(page.tag, this.#state.peek(), preview));
422
+ const route = this.index?.registry.pages.find((p) => p.id === page.id)?.route ?? `/${page.id}`;
423
+ const url = new URL(this.href(route), this.ownerDocument.baseURI);
424
+ // Hash routing keeps the query inside the fragment, where the router reads it.
425
+ if (url.hash.startsWith('#/')) {
426
+ const [path = '', query = ''] = url.hash.slice(1).split('?');
427
+ const params = new URLSearchParams(query);
428
+ params.set(SHARE_PARAM, encoded);
429
+ url.hash = `${path}?${params}`;
430
+ }
431
+ else {
432
+ url.searchParams.set(SHARE_PARAM, encoded);
433
+ url.hash = 'interactive-example';
434
+ }
435
+ return url.href;
436
+ }
437
+ #copyLink(page) {
438
+ const link = this.#shareLink(page);
439
+ this.#link.value = link;
440
+ const clipboard = this.ownerDocument.defaultView?.navigator.clipboard;
441
+ if (!clipboard)
442
+ return;
443
+ clipboard.writeText(link).then(() => (this.#status.value = { text: 'Example link copied.', alert: false, keep: true }), () => undefined);
444
+ }
445
+ // ---- Rendering ----
446
+ render() {
447
+ const page = this.page;
448
+ const index = this.index;
449
+ if (!page || !index)
450
+ return html `<div class="wb-content"></div>`;
451
+ const entry = index.registry.pages.find((p) => p.id === page.id);
452
+ const sections = (entry?.sections ?? []).filter((s) => s.visible);
453
+ const facts = componentFacts(page);
454
+ const at = covered(pageCoverage(page));
455
+ // Sections are built eagerly (not with repeat()), so the signals they read are tracked.
456
+ 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)}
460
+ </section>`)}
461
+ </div>`;
462
+ }
463
+ #section(id, page, facts, at) {
464
+ switch (id) {
465
+ case 'overview':
466
+ return this.#overview(page, at);
467
+ case 'interactive-example':
468
+ return this.#interactive(page, facts, at);
469
+ case 'api':
470
+ return this.#api(facts, at);
471
+ case 'examples':
472
+ return at('examples/examples', () => this.#examples(page));
473
+ case 'tokens':
474
+ return this.#tokens(page, at);
475
+ case 'behavior-and-accessibility':
476
+ return this.#behavior(page, facts, at);
477
+ case 'guidance':
478
+ return this.#guidance(page, at);
479
+ case 'source':
480
+ return at('source/module', () => this.#source(page));
481
+ case 'known-limitations':
482
+ return at('known-limitations/limitations', () => bullets(authoredValues(page.enrichment?.['limitations'])));
483
+ default:
484
+ return notDocumented(`pages["${page.id}"].sections["${id}"] in oddkin.docs.ts`);
485
+ }
486
+ }
487
+ #overview(page, at) {
488
+ 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
494
+ .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}`;
508
+ }
509
+ /** Intended use and explained mistakes, each paired with the example to use instead. */
510
+ #guidance(page, at) {
511
+ const { useFor, notFor } = intendedUse(page);
512
+ const authored = page.authored?.guidance ?? '';
513
+ const show = (entry, label) => entry.state
514
+ ? html `<button type="button" @click=${() => this.#applyPreset(entry)}>${label}</button>`
515
+ : nothing;
516
+ return html `${at('guidance/authored', () => authored.split(/\n\s*\n/).map((paragraph) => html `<p>${inline(paragraph)}</p>`))}
517
+ <h3>Use it for</h3>
518
+ ${at('guidance/use-for', () => bullets(useFor))}
519
+ <h3>Not for</h3>
520
+ ${at('guidance/not-for', () => bullets(notFor))}
521
+ <h3>Correct and incorrect usage</h3>
522
+ ${at('guidance/usage-pairs', () => html `<ul class="wb-list wb-pairs">
523
+ ${usagePairs(page).map(({ incorrect, correct }) => html `<li data-pair=${incorrect.id}>
524
+ <p><strong>Incorrect:</strong> ${incorrect.label} <code>${incorrect.id}</code></p>
525
+ ${incorrect.incorrect?.explanation
526
+ ? html `<p>${inline(incorrect.incorrect.explanation)}</p>`
527
+ : notDocumented('incorrect.explanation in defineExamples', 'Explanation')}
528
+ <p>
529
+ <strong>Correct:</strong>
530
+ ${correct
531
+ ? html `${correct.label} <code>${correct.id}</code>`
532
+ : html `<span class="wb-not-documented">Not documented</span>`}
533
+ </p>
534
+ ${show(incorrect, 'Show the incorrect usage in the preview')}
535
+ ${correct ? show(correct, 'Show the correct usage in the preview') : nothing}
536
+ </li>`)}
537
+ </ul>`)}`;
538
+ }
539
+ #interactive(page, facts, at) {
540
+ const state = this.#state.value;
541
+ const settings = this.#settings.value;
542
+ const status = this.#status.value;
543
+ const entries = presets(page).filter((p) => p.state);
544
+ const link = this.#link.value;
545
+ const frameSettings = settings.pair ? {} : this.#frameSettings(0);
546
+ 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
+ >`)}
564
+ <label
565
+ >Mode
566
+ <select
567
+ .value=${settings.mode}
568
+ ?disabled=${settings.pair}
569
+ @change=${(event) => update({ mode: event.target.value })}
570
+ >
571
+ <option value="site" ?selected=${settings.mode === 'site'}>Same as site</option>
572
+ <option value="light" ?selected=${settings.mode === 'light'}>Light</option>
573
+ <option value="dark" ?selected=${settings.mode === 'dark'}>Dark</option>
574
+ </select></label
575
+ >
576
+ <label
577
+ ><span>Light and dark side by side</span>
578
+ <input
579
+ type="checkbox"
580
+ .checked=${settings.pair}
581
+ @change=${(event) => update({ pair: event.target.checked })}
582
+ /></label>
583
+ <label
584
+ >Direction
585
+ <select
586
+ .value=${settings.direction}
587
+ @change=${(event) => update({
588
+ direction: event.target.value,
589
+ })}
590
+ >
591
+ <option value="ltr" ?selected=${settings.direction === 'ltr'}>Left to right</option>
592
+ <option value="rtl" ?selected=${settings.direction === 'rtl'}>Right to left</option>
593
+ </select></label
594
+ >
595
+ <label
596
+ >Width
597
+ <select
598
+ .value=${settings.width}
599
+ @change=${(event) => update({ width: event.target.value })}
600
+ >
601
+ <option value="fill" ?selected=${settings.width === 'fill'}>Fill</option>
602
+ <option value="narrow" ?selected=${settings.width === 'narrow'}>Narrow (320px)</option>
603
+ <option value="medium" ?selected=${settings.width === 'medium'}>Medium (640px)</option>
604
+ <option value="wide" ?selected=${settings.width === 'wide'}>Wide (960px)</option>
605
+ <option value="custom" ?selected=${settings.width === 'custom'}>Custom</option>
606
+ </select></label
607
+ >
608
+ ${settings.width === 'custom'
609
+ ? html `<label
610
+ >Width in px
611
+ <input
612
+ type="number"
613
+ min=${String(MIN_SHARE_WIDTH)}
614
+ max=${String(MAX_SHARE_WIDTH)}
615
+ step="1"
616
+ .value=${String(settings.customWidth)}
617
+ @change=${(event) => {
618
+ const value = Number(event.target.value);
619
+ if (Number.isInteger(value) &&
620
+ value >= MIN_SHARE_WIDTH &&
621
+ value <= MAX_SHARE_WIDTH)
622
+ update({ customWidth: value });
623
+ }}
624
+ /></label>`
625
+ : nothing}
626
+ <button type="button" @click=${() => this.#reset()}>Reset</button>
627
+ <button type="button" @click=${() => this.#copyLink(page)}>Copy example link</button>
628
+ </div>
629
+ <p class="wb-muted wb-settings-summary">
630
+ Preview only:
631
+ ${settings.pair ? 'light and dark' : settings.mode === 'site' ? 'same mode as the site' : settings.mode}
632
+ · ${settings.direction === 'rtl' ? 'right to left' : 'left to right'} ·
633
+ ${settings.width === 'fill'
634
+ ? 'fills the column'
635
+ : `${settings.width === 'custom' ? settings.customWidth : WIDTHS[settings.width]}px container`}
636
+ </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>`;
662
+ }
663
+ #eventLog(facts) {
664
+ if (!facts.events.length)
665
+ return html `<p class="wb-muted wb-event-log">No events are declared, so none are logged.</p>`;
666
+ const log = this.#log.value;
667
+ return html `<div class="wb-event-log">
668
+ <p class="wb-muted">
669
+ Logging ${facts.events.map((e, i) => html `${i ? ', ' : ''}<code>${e.name}</code>`)} (latest
670
+ first, at most ${LOG_LIMIT}).
671
+ <button type="button" ?disabled=${!log.length} @click=${() => (this.#log.value = [])}>
672
+ Clear
673
+ </button>
674
+ </p>
675
+ ${log.length
676
+ ? html `<ol class="wb-log" aria-label="Events">
677
+ ${repeat(log, (entry) => entry.id, (entry) => html `<li>
678
+ <code>${entry.name}</code> at ${entry.time.toFixed(0)} ms on
679
+ <code>${entry.target}</code> · ${entry.bubbles ? 'bubbles' : 'does not bubble'}
680
+ · ${entry.composed ? 'composed' : 'not composed'}
681
+ ${entry.detail !== undefined ? html `<br /><code>detail: ${entry.detail}</code>` : nothing}
682
+ </li>`)}
683
+ </ol>`
684
+ : html `<p class="wb-muted">No events yet.</p>`}
685
+ </div>`;
686
+ }
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>`;
726
+ }
727
+ #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>`;
748
+ }
749
+ #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
+ 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}
763
+ </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>`}
768
+ </td>
769
+ </tr>`;
770
+ }
771
+ #control(row) {
772
+ const name = row.property ?? row.attribute ?? '';
773
+ if (row.control === 'readonly')
774
+ return html `<span class="wb-muted">Read-only; inspected, never edited.</span>`;
775
+ if (row.control === 'fixture')
776
+ return html `<span class="wb-muted"
777
+ >Requires fixture: <code>${row.type}</code> is not editable generically.</span
778
+ >`;
779
+ const state = this.#state.value;
780
+ const modes = this.#modes.value;
781
+ const mode = modes[row.id] ?? defaultWriteMode(row);
782
+ const error = this.#errors.value[row.id];
783
+ const label = `${name} (${mode})`;
784
+ const attr = row.attribute;
785
+ const prop = row.property;
786
+ const attrValue = attr ? state.attributes[attr] : undefined;
787
+ const propSet = prop !== undefined && prop in state.properties;
788
+ const propValue = prop !== undefined ? state.properties[prop] : undefined;
789
+ const modeSwitch = row.property && row.attribute && !row.propertyOnly && row.control !== 'json'
790
+ ? html `<select
791
+ aria-label=${`Write ${name} as`}
792
+ .value=${mode}
793
+ @change=${(event) => {
794
+ this.#modes.value = {
795
+ ...this.#modes.peek(),
796
+ [row.id]: event.target.value,
797
+ };
798
+ }}
799
+ >
800
+ <option value="attribute" ?selected=${mode === 'attribute'}>attribute</option>
801
+ <option value="property" ?selected=${mode === 'property'}>property</option>
802
+ </select>`
803
+ : nothing;
804
+ let control;
805
+ if (row.control === 'boolean') {
806
+ const checked = mode === 'attribute' ? attrValue !== undefined : propValue === true;
807
+ control = html `<input
808
+ type="checkbox"
809
+ aria-label=${label}
810
+ .checked=${checked}
811
+ @change=${(event) => {
812
+ const on = event.target.checked;
813
+ if (mode === 'attribute')
814
+ this.#writeAttribute(row, on ? '' : null);
815
+ else
816
+ this.#writeProperty(row, on);
817
+ }}
818
+ />
819
+ <span class="wb-muted"
820
+ >${mode === 'attribute'
821
+ ? attrValue !== undefined
822
+ ? 'present'
823
+ : 'absent'
824
+ : propSet
825
+ ? String(propValue)
826
+ : 'unset'}</span
827
+ >`;
828
+ }
829
+ else if (row.control === 'enum') {
830
+ const current = mode === 'attribute'
831
+ ? (attrValue ?? '')
832
+ : propSet
833
+ ? propValue === null
834
+ ? '\u0000null'
835
+ : String(propValue)
836
+ : '';
837
+ control = html `<select
838
+ aria-label=${label}
839
+ .value=${current}
840
+ @change=${(event) => {
841
+ const value = event.target.value;
842
+ if (mode === 'attribute')
843
+ this.#writeAttribute(row, value === '' ? null : value);
844
+ else if (value === '')
845
+ this.#unsetProperty(row);
846
+ else
847
+ this.#writeProperty(row, value === '\u0000null' ? null : value);
848
+ }}
849
+ >
850
+ <option value="" ?selected=${current === ''}>(unset)</option>
851
+ ${(row.values ?? []).map((value) => html `<option value=${value} ?selected=${current === value}>${value}</option>`)}
852
+ ${mode === 'property' && row.nullable
853
+ ? html `<option value=${'\u0000null'} ?selected=${current === '\u0000null'}>
854
+ null
855
+ </option>`
856
+ : nothing}
857
+ </select>`;
858
+ }
859
+ else if (row.control === 'json') {
860
+ control = html `<textarea
861
+ aria-label=${label}
862
+ rows="3"
863
+ .value=${propSet ? JSON.stringify(propValue) : ''}
864
+ placeholder="unset"
865
+ @change=${(event) => {
866
+ const text = event.target.value.trim();
867
+ if (!text) {
868
+ this.#unsetProperty(row);
869
+ return;
870
+ }
871
+ try {
872
+ const value = JSON.parse(text);
873
+ if (!Array.isArray(value) && !(value === null && row.nullable))
874
+ throw new Error('expected a JSON array');
875
+ this.#writeProperty(row, value);
876
+ }
877
+ catch (cause) {
878
+ this.#setError(row.id, `Not written: ${message(cause)}.`);
879
+ }
880
+ }}
881
+ ></textarea>`;
882
+ }
883
+ else {
884
+ const number = row.control === 'number';
885
+ const isSet = mode === 'attribute' ? attrValue !== undefined : propSet;
886
+ const shown = mode === 'attribute'
887
+ ? (attrValue ?? '')
888
+ : propSet && propValue !== null
889
+ ? String(propValue)
890
+ : '';
891
+ control = html `<input
892
+ type=${number ? 'number' : 'text'}
893
+ aria-label=${label}
894
+ placeholder=${isSet ? '' : 'unset'}
895
+ .value=${shown}
896
+ @change=${(event) => {
897
+ 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);
904
+ 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
+ }
913
+ else
914
+ this.#writeProperty(row, text);
915
+ }}
916
+ />
917
+ <button
918
+ type="button"
919
+ ?disabled=${!isSet}
920
+ @click=${() => {
921
+ if (mode === 'attribute')
922
+ this.#writeAttribute(row, null);
923
+ else
924
+ this.#unsetProperty(row);
925
+ }}
926
+ >
927
+ Unset
928
+ </button>
929
+ ${mode === 'property' && row.nullable
930
+ ? html `<button
931
+ type="button"
932
+ ?disabled=${propSet && propValue === null}
933
+ @click=${() => this.#writeProperty(row, null)}
934
+ >
935
+ Set null
936
+ </button>`
937
+ : nothing}
938
+ <span class="wb-muted"
939
+ >${!isSet
940
+ ? 'unset'
941
+ : mode === 'property' && propValue === null
942
+ ? 'null'
943
+ : shown === ''
944
+ ? 'empty string'
945
+ : ''}</span
946
+ >`;
947
+ }
948
+ return html `<div class="wb-cell-controls">${control} ${modeSwitch}</div>
949
+ ${error ? html `<p class="wb-error" role="alert">${error}</p>` : nothing}`;
950
+ }
951
+ #slots(facts) {
952
+ const state = this.#state.value;
953
+ return html `<div class="wb-table-wrap">
954
+ <table class="wb-stack" data-table="slots">
955
+ <thead>
956
+ <tr>
957
+ <th scope="col">Slot</th>
958
+ <th scope="col">Description</th>
959
+ <th scope="col">Text content</th>
960
+ </tr>
961
+ </thead>
962
+ <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
+ })}
981
+ </tbody>
982
+ </table>
983
+ </div>`;
984
+ }
985
+ #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>`;
1006
+ }
1007
+ #tokens(page, at) {
1008
+ const mappings = Array.isArray(page.enrichment?.['styleMappings'])
1009
+ ? page.enrichment['styleMappings'].filter(isRecord)
1010
+ : [];
1011
+ const reads = Array.isArray(page.enrichment?.['tokenReads'])
1012
+ ? page.enrichment['tokenReads'].filter(isRecord)
1013
+ : [];
1014
+ const resolved = new Map((page.tokens ?? []).map((t) => [t.name, t]));
1015
+ const theme = this.index?.libraries.length ? 'the generated baseline theme' : 'the baseline';
1016
+ return html `<h3>From property to token</h3>
1017
+ ${at('tokens/style-mappings', () => html `<div class="wb-table-wrap">
1018
+ <table class="wb-stack" data-table="style-mappings">
1019
+ <caption>
1020
+ Declared style mappings extracted from the component's stylesheets.
1021
+ </caption>
1022
+ <thead>
1023
+ <tr>
1024
+ <th scope="col">When</th>
1025
+ <th scope="col">Part</th>
1026
+ <th scope="col">CSS property</th>
1027
+ <th scope="col">Token</th>
1028
+ </tr>
1029
+ </thead>
1030
+ <tbody>
1031
+ ${mappings.map((m) => html `<tr>
1032
+ <td data-label="When"><code>${String(m['when'] ?? 'default')}</code></td>
1033
+ <td data-label="Part">
1034
+ ${m['part'] ? html `<code>${String(m['part'])}</code>` : 'host'}
1035
+ </td>
1036
+ <td data-label="CSS property">
1037
+ <code>${String(m['cssProperty'] ?? '')}</code>
1038
+ </td>
1039
+ <td data-label="Token"><code>${String(m['token'] ?? '')}</code></td>
1040
+ </tr>`)}
1041
+ </tbody>
1042
+ </table>
1043
+ </div>`)}
1044
+ <h3>Every token read</h3>
1045
+ ${at('tokens/token-reads', () => html `<div class="wb-table-wrap">
1046
+ <table class="wb-stack" data-table="token-reads">
1047
+ <caption>
1048
+ ${resolved.size} tokens read · values for ${theme} (not a live preview reading)
1049
+ </caption>
1050
+ <thead>
1051
+ <tr>
1052
+ <th scope="col">Token</th>
1053
+ <th scope="col">Reference</th>
1054
+ <th scope="col">Read through</th>
1055
+ <th scope="col">Value</th>
1056
+ </tr>
1057
+ </thead>
1058
+ <tbody>
1059
+ ${[...new Map(reads.map((r) => [String(r['token']), r])).values()].map((read) => {
1060
+ const name = String(read['token']);
1061
+ const token = resolved.get(name);
1062
+ const definition = token &&
1063
+ typeof token.definition === 'string' &&
1064
+ token.definition.startsWith('{')
1065
+ ? token.definition
1066
+ : undefined;
1067
+ return html `<tr>
1068
+ <td data-label="Token">
1069
+ <code>${name}</code>
1070
+ ${token?.cssVariable ? html `<small><code>${token.cssVariable}</code></small>` : nothing}
1071
+ </td>
1072
+ <td data-label="Reference">${String(read['kind'] ?? 'declared')}</td>
1073
+ <td data-label="Read through">${String(read['readThrough'] ?? 'Unknown')}</td>
1074
+ <td data-label="Value">
1075
+ ${token && !('missing' in token) && token.value !== undefined
1076
+ ? html `<code>${String(token.value)}</code
1077
+ >${definition ? html ` <small><code>${definition}</code></small>` : nothing}`
1078
+ : 'Unknown'}
1079
+ </td>
1080
+ </tr>`;
1081
+ })}
1082
+ </tbody>
1083
+ </table>
1084
+ </div>`)}`;
1085
+ }
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}`;
1100
+ }
1101
+ #source(page) {
1102
+ const definition = isRecord(page.enrichment?.['definition'])
1103
+ ? page.enrichment['definition']
1104
+ : undefined;
1105
+ 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>`;
1118
+ }
1119
+ }
1120
+ //# sourceMappingURL=component-detail.js.map