@obvi/blueprint 1.0.10 → 1.1.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.
package/dist/blueprint.js CHANGED
@@ -1,3 +1,950 @@
1
+ // Document chrome — site nav, reading progress, contents rail, and footer
2
+ // metadata are injected at runtime so authored pages stay semantic (<main> only).
3
+ // Chrome glyphs are Iconoir (MIT) line icons, stored as raw path data and
4
+ // drawn at runtime so the framework ships zero icon-pack dependencies. Each
5
+ // stroke inherits currentColor, so the ink-scale tokens theme them for free.
6
+ const SIDEBAR_COLLAPSE_ICON = [
7
+ 'M19 21L5 21C3.89543 21 3 20.1046 3 19L3 5C3 3.89543 3.89543 3 5 3L19 3C20.1046 3 21 3.89543 21 5L21 19C21 20.1046 20.1046 21 19 21Z',
8
+ 'M7.25 10L5.5 12L7.25 14',
9
+ 'M9.5 21V3',
10
+ ]
11
+ const SIDEBAR_EXPAND_ICON = [
12
+ 'M19 21L5 21C3.89543 21 3 20.1046 3 19L3 5C3 3.89543 3.89543 3 5 3L19 3C20.1046 3 21 3.89543 21 5L21 19C21 20.1046 20.1046 21 19 21Z',
13
+ 'M9.5 21V3',
14
+ 'M5.5 10L7.25 12L5.5 14',
15
+ ]
16
+ const SEARCH_ICON = [
17
+ 'M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z',
18
+ 'M17 17L21 21',
19
+ ]
20
+ const DOC_CHEVRON_ICON = 'M6 9L12 15L18 9'
21
+ const THEME_MOON_ICON =
22
+ 'M3 11.5066C3 16.7497 7.25034 21 12.4934 21C16.2209 21 19.4466 18.8518 21 15.7259C12.4934 15.7259 8.27411 11.5066 8.27411 3C5.14821 4.55344 3 7.77915 3 11.5066Z'
23
+ const THEME_BULB_ICON = [
24
+ 'M9 18H15',
25
+ 'M10 21H14',
26
+ 'M9.00082 15C9.00098 13 8.50098 12.5 7.50082 11.5C6.50067 10.5 6.02422 9.48689 6.00082 8C5.95284 4.95029 8.00067 3 12.0008 3C16.001 3 18.0488 4.95029 18.0008 8C17.9774 9.48689 17.5007 10.5 16.5008 11.5C15.501 12.5 15.001 13 15.0008 15',
27
+ ]
28
+ const BLUEPRINT_LOGO_OUTLINE =
29
+ 'M7.3 19H12.7C14.3802 19 15.2202 19 15.862 18.673C16.4265 18.3854 16.8854 17.9265 17.173 17.362C17.5 16.7202 17.5 15.8802 17.5 14.2V9.1075C17.5 8.3982 17.5 8.04355 17.4222 7.70852C17.3531 7.41144 17.2392 7.12663 17.0843 6.86389C16.9096 6.56761 16.665 6.31078 16.1759 5.79716L13.0259 2.48966C12.5028 1.94045 12.2413 1.66585 11.9324 1.46921C11.6585 1.29489 11.3582 1.16618 11.0431 1.08809C10.6876 1 10.3084 1 9.55 1H7.3C5.61984 1 4.77976 1 4.13803 1.32698C3.57354 1.6146 3.1146 2.07354 2.82698 2.63803C2.5 3.27976 2.5 4.11984 2.5 5.8V14.2C2.5 15.8802 2.5 16.7202 2.82698 17.362C3.1146 17.9265 3.57354 18.3854 4.13803 18.673C4.77976 19 5.61984 19 7.3 19Z'
30
+ const BLUEPRINT_LOGO_FILL = 'M16 16H5V6L16 16ZM6.5 14.5H11.5L6.5 9.5V14.5Z'
31
+
32
+ function metaSelector({ name, property } = {}) {
33
+ if (property) return `meta[property="${property}"]`
34
+ if (name) return `meta[name="${name}"]`
35
+ return null
36
+ }
37
+
38
+ function readMetaContent(doc, target) {
39
+ const selector = metaSelector(target)
40
+ return selector ? doc.querySelector(selector)?.content?.trim() ?? '' : ''
41
+ }
42
+
43
+ function readMetaContentAll(doc, target) {
44
+ const selector = metaSelector(target)
45
+ if (!selector) return []
46
+ return [...doc.querySelectorAll(selector)]
47
+ .map((meta) => meta.content?.trim())
48
+ .filter(Boolean)
49
+ }
50
+
51
+ function readMetaJson(doc, name) {
52
+ const raw = readMetaContent(doc, { name })
53
+ if (!raw) return null
54
+ try {
55
+ return JSON.parse(raw)
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ function formatPublishedDate(iso) {
62
+ if (!iso) return null
63
+ const day = iso.includes('T') ? iso.split('T')[0] : iso
64
+ const date = new Date(day.includes('T') ? day : `${day}T12:00:00`)
65
+ if (Number.isNaN(date.getTime())) return null
66
+ return {
67
+ iso: day,
68
+ label: date.toLocaleDateString(undefined, {
69
+ day: 'numeric',
70
+ month: 'long',
71
+ year: 'numeric',
72
+ timeZone: 'UTC',
73
+ }),
74
+ }
75
+ }
76
+
77
+ function pageBasename(href, win) {
78
+ try {
79
+ return new URL(href, win.location.href).pathname.split('/').pop() || 'index.html'
80
+ } catch {
81
+ return href.split('/').pop() || 'index.html'
82
+ }
83
+ }
84
+
85
+ function isCurrentHref(href, win) {
86
+ const current = win.location.pathname.split('/').pop() || 'index.html'
87
+ return pageBasename(href, win) === current
88
+ }
89
+
90
+ function createChromeSvg(doc, pathD, size = 16) {
91
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
92
+ svg.setAttribute('viewBox', '0 0 24 24')
93
+ svg.setAttribute('fill', 'none')
94
+ svg.setAttribute('stroke', 'currentColor')
95
+ svg.setAttribute('stroke-width', '1.5')
96
+ svg.setAttribute('stroke-linecap', 'round')
97
+ svg.setAttribute('stroke-linejoin', 'round')
98
+ svg.setAttribute('width', String(size))
99
+ svg.setAttribute('height', String(size))
100
+ svg.setAttribute('aria-hidden', 'true')
101
+ for (const d of Array.isArray(pathD) ? pathD : [pathD]) {
102
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
103
+ path.setAttribute('d', d)
104
+ svg.append(path)
105
+ }
106
+ return svg
107
+ }
108
+
109
+ function createBlueprintLogo(doc) {
110
+ const svg = createChromeSvg(doc, BLUEPRINT_LOGO_OUTLINE, 20)
111
+ svg.setAttribute('viewBox', '0 0 20 20')
112
+ svg.classList.add('site-nav__logo')
113
+ const fill = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
114
+ fill.setAttribute('d', BLUEPRINT_LOGO_FILL)
115
+ fill.setAttribute('fill', 'currentColor')
116
+ fill.setAttribute('stroke', 'none')
117
+ svg.append(fill)
118
+ return svg
119
+ }
120
+
121
+ function injectScrollProgress(doc) {
122
+ if (doc.querySelector('.scroll-progress')) return null
123
+ const bar = doc.createElement('div')
124
+ bar.className = 'scroll-progress'
125
+ bar.setAttribute('aria-hidden', 'true')
126
+ return bar
127
+ }
128
+
129
+ function injectSiteNav(doc, win) {
130
+ if (doc.querySelector('.site-nav')) return null
131
+ const links = readMetaJson(doc, 'bp:site-nav')
132
+ if (!links?.length) return null
133
+
134
+ const nav = doc.createElement('nav')
135
+ nav.className = 'site-nav'
136
+ nav.setAttribute('aria-label', 'Site')
137
+
138
+ const brandLabel =
139
+ readMetaContent(doc, { property: 'og:site_name' }) ||
140
+ doc.title.split('—')[0]?.trim() ||
141
+ 'Home'
142
+ const brandHref = readMetaContent(doc, { name: 'bp:site-home' }) || 'index.html'
143
+
144
+ const brand = doc.createElement('a')
145
+ brand.className = 'site-nav__brand'
146
+ brand.href = brandHref
147
+ brand.setAttribute('aria-label', `${brandLabel} home`)
148
+
149
+ const logo = createBlueprintLogo(doc)
150
+
151
+ const wordmark = doc.createElement('span')
152
+ wordmark.className = 'site-nav__wordmark'
153
+ wordmark.textContent = brandLabel
154
+
155
+ brand.append(logo, wordmark)
156
+
157
+ const list = doc.createElement('ul')
158
+ list.className = 'site-nav__links'
159
+
160
+ for (const item of links) {
161
+ const li = doc.createElement('li')
162
+ const link = doc.createElement('a')
163
+ link.href = item.href
164
+ link.textContent = item.label
165
+ if (item.current || isCurrentHref(item.href, win)) {
166
+ link.setAttribute('aria-current', 'page')
167
+ }
168
+ li.append(link)
169
+ list.append(li)
170
+ }
171
+
172
+ nav.append(brand, list)
173
+
174
+ if (readMetaContent(doc, { name: 'bp:site-theme' }) === 'nav') {
175
+ const theme = doc.createElement('button')
176
+ theme.className = 'toggle site-nav__theme'
177
+ theme.type = 'button'
178
+ theme.id = 'themeToggle'
179
+ theme.setAttribute('aria-label', 'Toggle light and dark theme')
180
+ theme.textContent = 'Theme'
181
+ nav.append(theme)
182
+ }
183
+
184
+ return nav
185
+ }
186
+
187
+ function injectSidebarToggle(doc) {
188
+ if (doc.querySelector('.sidebar-toggle')) return null
189
+ const toggle = doc.createElement('button')
190
+ toggle.className = 'sidebar-toggle'
191
+ toggle.type = 'button'
192
+ toggle.setAttribute('aria-controls', 'bp-contents-rail')
193
+ toggle.setAttribute('aria-expanded', 'true')
194
+ toggle.setAttribute('aria-label', 'Hide contents sidebar')
195
+
196
+ const icons = doc.createElement('span')
197
+ icons.className = 'sidebar-toggle__icons'
198
+ icons.setAttribute('aria-hidden', 'true')
199
+
200
+ const collapse = doc.createElement('span')
201
+ collapse.className =
202
+ 'sidebar-toggle__icon sidebar-toggle__icon--collapse bp-transition-opacity bp-duration-slow bp-ease-in-out'
203
+ collapse.append(createChromeSvg(doc, SIDEBAR_COLLAPSE_ICON))
204
+
205
+ const expand = doc.createElement('span')
206
+ expand.className =
207
+ 'sidebar-toggle__icon sidebar-toggle__icon--expand bp-transition-opacity bp-duration-slow bp-ease-in-out'
208
+ expand.append(createChromeSvg(doc, SIDEBAR_EXPAND_ICON))
209
+
210
+ icons.append(collapse, expand)
211
+ toggle.append(icons)
212
+ return toggle
213
+ }
214
+
215
+ function buildDocSwitcher(doc, win) {
216
+ const collections = readMetaJson(doc, 'bp:collections')
217
+ if (!collections?.length) return null
218
+
219
+ const details = doc.createElement('details')
220
+ details.className = 'doc-switcher'
221
+
222
+ const summary = doc.createElement('summary')
223
+ summary.className = 'doc-switcher__current sidebar-field'
224
+ summary.setAttribute('aria-label', 'Switch blueprint collection')
225
+
226
+ const current =
227
+ collections.find((item) => item.current || isCurrentHref(item.href, win)) ??
228
+ collections[0]
229
+ const collectionMeta = readMetaContent(doc, { name: 'bp:collection-meta' })
230
+
231
+ const label = doc.createElement('span')
232
+ label.className = 'doc-switcher__label'
233
+
234
+ const title = doc.createElement('span')
235
+ title.className = 'doc-switcher__title'
236
+ title.textContent = current.title
237
+
238
+ const meta = doc.createElement('span')
239
+ meta.className = 'doc-switcher__meta'
240
+ meta.textContent = collectionMeta || current.desc || ''
241
+
242
+ label.append(title)
243
+ if (meta.textContent) label.append(meta)
244
+
245
+ const chevron = doc.createElement('span')
246
+ chevron.className =
247
+ 'doc-switcher__chevron bp-transition-transform bp-duration-normal bp-ease-in-out'
248
+ chevron.setAttribute('aria-hidden', 'true')
249
+ chevron.append(createChromeSvg(doc, DOC_CHEVRON_ICON))
250
+
251
+ summary.append(label, chevron)
252
+
253
+ const menu = doc.createElement('div')
254
+ menu.className = 'doc-switcher__menu'
255
+ menu.setAttribute('role', 'menu')
256
+
257
+ for (const item of collections) {
258
+ const link = doc.createElement('a')
259
+ link.href = item.href
260
+ link.setAttribute('role', 'menuitem')
261
+ if (item.current || isCurrentHref(item.href, win)) {
262
+ link.className = 'is-current'
263
+ link.setAttribute('aria-current', 'page')
264
+ }
265
+
266
+ const option = doc.createElement('span')
267
+ option.className = 'doc-switcher__option'
268
+
269
+ const name = doc.createElement('span')
270
+ name.className = 'doc-switcher__name'
271
+ name.textContent = item.title
272
+
273
+ option.append(name)
274
+ if (item.desc) {
275
+ const desc = doc.createElement('span')
276
+ desc.className = 'doc-switcher__desc'
277
+ desc.textContent = item.desc
278
+ option.append(desc)
279
+ }
280
+
281
+ const go = doc.createElement('span')
282
+ go.className = 'doc-switcher__go bp-transition-opacity'
283
+ go.setAttribute('aria-hidden', 'true')
284
+ go.append(createChromeSvg(doc, DOC_CHEVRON_ICON, 14))
285
+
286
+ link.append(option, go)
287
+ menu.append(link)
288
+ }
289
+
290
+ details.append(summary, menu)
291
+ return details
292
+ }
293
+
294
+ function buildSidebarFooter(doc) {
295
+ const footer = doc.createElement('div')
296
+ footer.className = 'sidebar-footer'
297
+
298
+ const theme = doc.createElement('button')
299
+ theme.className = 'theme-toggle'
300
+ theme.type = 'button'
301
+ theme.setAttribute('role', 'switch')
302
+ theme.setAttribute('aria-checked', 'false')
303
+ theme.setAttribute('aria-label', 'Switch to dark theme')
304
+
305
+ const thumb = doc.createElement('span')
306
+ thumb.className =
307
+ 'theme-toggle__thumb bp-transition-transform bp-duration-normal bp-ease-in-out'
308
+ thumb.setAttribute('aria-hidden', 'true')
309
+
310
+ const moon = doc.createElement('span')
311
+ moon.className = 'theme-toggle__icon theme-toggle__icon--moon'
312
+ moon.setAttribute('aria-hidden', 'true')
313
+ moon.append(createChromeSvg(doc, THEME_MOON_ICON, 14))
314
+
315
+ const bulb = doc.createElement('span')
316
+ bulb.className = 'theme-toggle__icon theme-toggle__icon--bulb'
317
+ bulb.setAttribute('aria-hidden', 'true')
318
+ bulb.append(createChromeSvg(doc, THEME_BULB_ICON, 14))
319
+
320
+ theme.append(thumb, moon, bulb)
321
+
322
+ const meta = doc.createElement('div')
323
+ meta.className = 'sidebar-meta'
324
+
325
+ const authors = readMetaContentAll(doc, { property: 'article:author' })
326
+ const published = formatPublishedDate(
327
+ readMetaContent(doc, { property: 'article:published_time' })
328
+ )
329
+
330
+ if (authors.length) {
331
+ const item = doc.createElement('div')
332
+ item.className = 'sidebar-meta__item'
333
+ const label = doc.createElement('p')
334
+ label.className = 'bp-meta'
335
+ label.textContent = authors.length > 1 ? 'Authors' : 'Author'
336
+ const value = doc.createElement('p')
337
+ value.className = 'bp-note'
338
+ value.textContent = authors.join(', ')
339
+ item.append(label, value)
340
+ meta.append(item)
341
+ }
342
+
343
+ if (published) {
344
+ const item = doc.createElement('div')
345
+ item.className = 'sidebar-meta__item'
346
+ const label = doc.createElement('p')
347
+ label.className = 'bp-meta'
348
+ label.textContent = 'Date'
349
+ const value = doc.createElement('p')
350
+ value.className = 'bp-note'
351
+ const time = doc.createElement('time')
352
+ time.dateTime = published.iso
353
+ time.textContent = published.label
354
+ value.append(time)
355
+ item.append(label, value)
356
+ meta.append(item)
357
+ }
358
+
359
+ footer.append(theme)
360
+ if (meta.childNodes.length) footer.append(meta)
361
+ return footer
362
+ }
363
+
364
+ function injectSidebar(doc, win) {
365
+ if (doc.querySelector('.bp-sidebar')) return null
366
+
367
+ const nav = doc.createElement('nav')
368
+ nav.className = 'bp-sidebar'
369
+ nav.id = 'bp-contents-rail'
370
+ nav.setAttribute('aria-label', 'Contents')
371
+
372
+ const panel = doc.createElement('div')
373
+ panel.className = 'bp-sidebar__panel'
374
+
375
+ const search = doc.createElement('div')
376
+ search.className = 'sidebar-search'
377
+ // The trigger is a button (it opens a dialog, it is not a text field): the
378
+ // field carries the chrome (border/radius/surface) and the magnifier + ⌘K
379
+ // hint sit inside it beside the label. Pressing it — or ⌘K / Ctrl+K — opens
380
+ // the command palette; see wireSearchPalette().
381
+ const searchField = doc.createElement('button')
382
+ searchField.type = 'button'
383
+ searchField.className =
384
+ 'sidebar-search__field sidebar-field bp-transition-colors bp-ease'
385
+ searchField.setAttribute('aria-haspopup', 'dialog')
386
+ searchField.setAttribute('aria-keyshortcuts', 'Meta+K Control+K')
387
+ searchField.setAttribute('aria-label', 'Search (⌘K)')
388
+ const searchIcon = createChromeSvg(doc, SEARCH_ICON, 14)
389
+ searchIcon.classList.add('sidebar-search__icon')
390
+ const searchLabel = doc.createElement('span')
391
+ searchLabel.className = 'sidebar-search__label'
392
+ searchLabel.textContent = 'Search'
393
+ const searchHint = doc.createElement('span')
394
+ searchHint.className = 'sidebar-search__hint'
395
+ searchHint.setAttribute('aria-hidden', 'true')
396
+ const searchHintCmd = doc.createElement('kbd')
397
+ searchHintCmd.textContent = '⌘'
398
+ const searchHintKey = doc.createElement('kbd')
399
+ searchHintKey.textContent = 'K'
400
+ searchHint.append(searchHintCmd, searchHintKey)
401
+ searchField.append(searchIcon, searchLabel, searchHint)
402
+ search.append(searchField)
403
+
404
+ panel.append(search)
405
+
406
+ const switcher = buildDocSwitcher(doc, win)
407
+ if (switcher) panel.append(switcher)
408
+
409
+ const list = doc.createElement('ul')
410
+ panel.append(list)
411
+
412
+ nav.append(panel, buildSidebarFooter(doc))
413
+ return nav
414
+ }
415
+
416
+ function syncThemeSwitch(doc, button) {
417
+ const dark = doc.documentElement.dataset.obviousTheme === 'dark'
418
+ button.setAttribute('aria-checked', String(dark))
419
+ button.setAttribute(
420
+ 'aria-label',
421
+ dark ? 'Switch to light theme' : 'Switch to dark theme'
422
+ )
423
+ }
424
+
425
+ // A reader's explicit pick is persisted here so the page reopens in their
426
+ // theme — mirrors the bp-sidebar collapse key.
427
+ const THEME_KEY = 'bp-theme'
428
+
429
+ function readStoredTheme(win) {
430
+ try {
431
+ const value = win.localStorage.getItem(THEME_KEY)
432
+ return value === 'dark' || value === 'light' ? value : null
433
+ } catch {
434
+ return null
435
+ }
436
+ }
437
+
438
+ function writeStoredTheme(win, value) {
439
+ try {
440
+ win.localStorage.setItem(THEME_KEY, value)
441
+ } catch {}
442
+ }
443
+
444
+ function systemPrefersDark(win) {
445
+ try {
446
+ return win.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
447
+ } catch {
448
+ return false
449
+ }
450
+ }
451
+
452
+ // Opt-in (head meta `bp:theme` = "auto"): when no explicit pick exists the page
453
+ // follows the OS color scheme. Off by default, so an author/bridge-set
454
+ // data-obvious-theme is left untouched.
455
+ function followsSystemTheme(doc) {
456
+ return readMetaContent(doc, { name: 'bp:theme' }).toLowerCase() === 'auto'
457
+ }
458
+
459
+ function themePrefersReducedMotion(win) {
460
+ try {
461
+ return win.matchMedia('(prefers-reduced-motion: reduce)').matches
462
+ } catch {
463
+ return false
464
+ }
465
+ }
466
+
467
+ function setTheme(doc, theme) {
468
+ doc.documentElement.dataset.obviousTheme = theme
469
+ for (const button of doc.querySelectorAll('.theme-toggle')) {
470
+ syncThemeSwitch(doc, button)
471
+ }
472
+ }
473
+
474
+ // Flip with a single compositor crossfade (View Transitions API). Timing is
475
+ // owned by ::view-transition-* rules in blueprint.css. If the update callback
476
+ // is deferred (some embedded hosts), apply immediately so the toggle never
477
+ // desyncs from persistence; instant when reduced motion is on or VT is missing.
478
+ function setThemeAnimated(doc, win, theme) {
479
+ const apply = () => setTheme(doc, theme)
480
+ if (themePrefersReducedMotion(win) || typeof doc.startViewTransition !== 'function') {
481
+ apply()
482
+ return
483
+ }
484
+ const root = doc.documentElement
485
+ let applied = false
486
+ const clearSwitching = () => {
487
+ delete root.dataset.bpThemeSwitching
488
+ }
489
+ const runUpdate = () => {
490
+ applied = true
491
+ apply()
492
+ }
493
+ root.dataset.bpThemeSwitching = ''
494
+ try {
495
+ let transition
496
+ try {
497
+ transition = doc.startViewTransition({ update: runUpdate, types: ['bp-theme'] })
498
+ } catch {
499
+ transition = doc.startViewTransition(runUpdate)
500
+ }
501
+ if (!applied) runUpdate()
502
+ if (transition?.finished) {
503
+ transition.finished.finally(clearSwitching).catch(clearSwitching)
504
+ } else {
505
+ clearSwitching()
506
+ }
507
+ } catch {
508
+ clearSwitching()
509
+ apply()
510
+ }
511
+ }
512
+
513
+ // Resolve the theme at load: a persisted pick always wins; otherwise, when the
514
+ // page opts into bp:theme=auto, follow the OS scheme. No animation on load.
515
+ function resolveInitialTheme(doc, win) {
516
+ const stored = readStoredTheme(win)
517
+ if (stored) {
518
+ setTheme(doc, stored)
519
+ return
520
+ }
521
+ if (followsSystemTheme(doc)) {
522
+ setTheme(doc, systemPrefersDark(win) ? 'dark' : 'light')
523
+ }
524
+ }
525
+
526
+ function wireThemeToggle(doc, win) {
527
+ const root = doc.documentElement
528
+ const toggleTheme = () => {
529
+ const next = root.dataset.obviousTheme === 'dark' ? 'light' : 'dark'
530
+ setThemeAnimated(doc, win, next)
531
+ writeStoredTheme(win, next)
532
+ }
533
+
534
+ for (const button of doc.querySelectorAll('.theme-toggle')) {
535
+ if (button.dataset.bpThemeWired) continue
536
+ button.dataset.bpThemeWired = 'true'
537
+ syncThemeSwitch(doc, button)
538
+ button.addEventListener('click', toggleTheme)
539
+ }
540
+
541
+ const navTheme = doc.getElementById('themeToggle')
542
+ if (navTheme && !navTheme.dataset.bpThemeWired) {
543
+ navTheme.dataset.bpThemeWired = 'true'
544
+ navTheme.addEventListener('click', toggleTheme)
545
+ }
546
+
547
+ // Track the OS scheme live, but only until the reader makes an explicit pick
548
+ // (a persisted value), which wins thereafter.
549
+ if (followsSystemTheme(doc) && win.matchMedia) {
550
+ const query = win.matchMedia('(prefers-color-scheme: dark)')
551
+ query.addEventListener?.('change', (event) => {
552
+ if (readStoredTheme(win)) return
553
+ setThemeAnimated(doc, win, event.matches ? 'dark' : 'light')
554
+ })
555
+ }
556
+ }
557
+
558
+ function wireSidebarCollapse(doc, win) {
559
+ const root = doc.documentElement
560
+ const toggle = doc.querySelector('.sidebar-toggle')
561
+ if (!toggle || toggle.dataset.bpSidebarWired) return
562
+ toggle.dataset.bpSidebarWired = 'true'
563
+
564
+ const KEY = 'bp-sidebar'
565
+ const read = () => {
566
+ try {
567
+ return win.localStorage.getItem(KEY)
568
+ } catch {
569
+ return null
570
+ }
571
+ }
572
+ const write = (value) => {
573
+ try {
574
+ win.localStorage.setItem(KEY, value)
575
+ } catch {}
576
+ }
577
+ const apply = (collapsed) => {
578
+ root.dataset.sidebar = collapsed ? 'collapsed' : 'open'
579
+ toggle.setAttribute('aria-expanded', String(!collapsed))
580
+ toggle.setAttribute(
581
+ 'aria-label',
582
+ collapsed ? 'Show contents sidebar' : 'Hide contents sidebar'
583
+ )
584
+ }
585
+
586
+ apply(read() === 'collapsed')
587
+ win.requestAnimationFrame(() => {
588
+ root.classList.add('bp-sidebar-animate')
589
+ })
590
+
591
+ toggle.addEventListener('click', () => {
592
+ const collapsed = root.dataset.sidebar !== 'collapsed'
593
+ apply(collapsed)
594
+ write(collapsed ? 'collapsed' : 'open')
595
+ })
596
+ }
597
+
598
+ // Search command palette. The sidebar trigger — and ⌘K / Ctrl+K from anywhere —
599
+ // opens a centered dialog over a scrim. It is wired to the document's own
600
+ // headings: the rail's section links populate the list, the input filters them
601
+ // by a case-insensitive substring, ↑/↓ move the selection, and Enter / click
602
+ // jump to the section and close. Richer search (snippets, ranking, a built
603
+ // index, cross-document scope) is deferred to the search-logic owner; this is
604
+ // the open/close + jump-to-section surface.
605
+ function wireSearchPalette(doc, win) {
606
+ const trigger = doc.querySelector('.sidebar-search__field')
607
+ if (!trigger || trigger.dataset.bpSearchWired) return
608
+ trigger.dataset.bpSearchWired = 'true'
609
+
610
+ const isMacLike = /Mac|iPhone|iPad/.test(win.navigator?.platform || '')
611
+ if (!isMacLike) {
612
+ const cmd = trigger.querySelector('.sidebar-search__hint kbd')
613
+ if (cmd) cmd.textContent = 'Ctrl'
614
+ trigger.setAttribute('aria-label', 'Search (Ctrl+K)')
615
+ }
616
+
617
+ let overlay = null
618
+ let panel = null
619
+ let input = null
620
+ let results = null
621
+ let footStatus = null
622
+ let entries = []
623
+ let visible = []
624
+ let selected = 0
625
+ let returnFocus = null
626
+ let prevOverflow = ''
627
+
628
+ // Mirror the document's own structure from the rendered rail, so the palette
629
+ // stays portable (no per-document hardcoding). Each top-level entry carries
630
+ // its group eyebrow as the breadcrumb; nested entries fold the parent in too.
631
+ const collectEntries = () => {
632
+ const list =
633
+ doc.querySelector('.bp-sidebar .bp-sidebar__panel > ul') ??
634
+ doc.querySelector('.bp-toc > ul')
635
+ if (!list) return []
636
+ const out = []
637
+ let group = ''
638
+ for (const li of list.children) {
639
+ if (li.classList.contains('bp-nav-group')) {
640
+ group = (li.textContent || '').trim()
641
+ continue
642
+ }
643
+ const link = li.querySelector(':scope > a[href^="#"]')
644
+ if (!link) continue
645
+ const title = (link.textContent || '').trim()
646
+ out.push({ href: link.getAttribute('href') || '', title, crumb: group })
647
+ for (const sub of li.querySelectorAll(':scope > ul > li > a[href^="#"]')) {
648
+ out.push({
649
+ href: sub.getAttribute('href') || '',
650
+ title: (sub.textContent || '').trim(),
651
+ crumb: [group, title].filter(Boolean).join(' › '),
652
+ })
653
+ }
654
+ }
655
+ return out
656
+ }
657
+
658
+ const keyHint = (caps, labelText) => {
659
+ const span = doc.createElement('span')
660
+ span.className = 'docs-search__key'
661
+ for (const cap of caps) {
662
+ const kbd = doc.createElement('kbd')
663
+ kbd.textContent = cap
664
+ span.append(kbd)
665
+ }
666
+ span.append(doc.createTextNode(` ${labelText}`))
667
+ return span
668
+ }
669
+
670
+ const highlight = (text, q) => {
671
+ if (!q) return [doc.createTextNode(text)]
672
+ const idx = text.toLowerCase().indexOf(q)
673
+ if (idx < 0) return [doc.createTextNode(text)]
674
+ const mark = doc.createElement('mark')
675
+ mark.textContent = text.slice(idx, idx + q.length)
676
+ return [
677
+ doc.createTextNode(text.slice(0, idx)),
678
+ mark,
679
+ doc.createTextNode(text.slice(idx + q.length)),
680
+ ]
681
+ }
682
+
683
+ const buildRow = (entry, index, q) => {
684
+ const row = doc.createElement('a')
685
+ row.className = 'docs-search__row'
686
+ row.id = `docs-search-row-${index}`
687
+ row.setAttribute('role', 'option')
688
+ row.href = entry.href
689
+ row.dataset.index = String(index)
690
+ row.addEventListener('click', () => close())
691
+ row.addEventListener('mousemove', () => {
692
+ if (selected !== index) {
693
+ selected = index
694
+ applySelection()
695
+ }
696
+ })
697
+
698
+ const mainCol = doc.createElement('span')
699
+ mainCol.className = 'docs-search__row-main'
700
+ if (entry.crumb) {
701
+ const crumb = doc.createElement('span')
702
+ crumb.className = 'docs-search__crumb'
703
+ crumb.textContent = entry.crumb
704
+ mainCol.append(crumb)
705
+ }
706
+ const title = doc.createElement('span')
707
+ title.className = 'docs-search__title'
708
+ const hash = doc.createElement('span')
709
+ hash.className = 'docs-search__hash'
710
+ hash.setAttribute('aria-hidden', 'true')
711
+ hash.textContent = '#'
712
+ title.append(hash, ...highlight(entry.title, q))
713
+ mainCol.append(title)
714
+
715
+ const enter = doc.createElement('kbd')
716
+ enter.className = 'docs-search__enter'
717
+ enter.textContent = '↵'
718
+
719
+ row.append(mainCol, enter)
720
+ return row
721
+ }
722
+
723
+ const buildEmpty = (query) => {
724
+ const wrap = doc.createElement('div')
725
+ wrap.className = 'docs-search__empty'
726
+ const iconWrap = doc.createElement('span')
727
+ iconWrap.className = 'docs-search__empty-icon'
728
+ iconWrap.append(createChromeSvg(doc, SEARCH_ICON, 28))
729
+ const title = doc.createElement('span')
730
+ title.className = 'docs-search__empty-title'
731
+ title.textContent = `No results for “${(query || '').trim()}”`
732
+ const note = doc.createElement('span')
733
+ note.className = 'docs-search__empty-note'
734
+ note.textContent = 'Try a different term, or check your spelling.'
735
+ wrap.append(iconWrap, title, note)
736
+ return wrap
737
+ }
738
+
739
+ const applySelection = () => {
740
+ const rows = results.querySelectorAll('.docs-search__row[role="option"]')
741
+ let activeId = ''
742
+ rows.forEach((row, index) => {
743
+ const isSel = index === selected
744
+ row.setAttribute('aria-selected', String(isSel))
745
+ if (isSel) {
746
+ activeId = row.id
747
+ row.scrollIntoView({ block: 'nearest' })
748
+ }
749
+ })
750
+ if (activeId) input.setAttribute('aria-activedescendant', activeId)
751
+ else input.removeAttribute('aria-activedescendant')
752
+ }
753
+
754
+ const move = (delta) => {
755
+ if (!visible.length) return
756
+ selected = (selected + delta + visible.length) % visible.length
757
+ applySelection()
758
+ }
759
+
760
+ const render = (query) => {
761
+ const q = (query || '').trim().toLowerCase()
762
+ visible = q
763
+ ? entries.filter((entry) => entry.title.toLowerCase().includes(q))
764
+ : entries
765
+ selected = 0
766
+ results.replaceChildren()
767
+
768
+ if (visible.length === 0) {
769
+ results.append(buildEmpty(query))
770
+ footStatus.textContent = '0 results'
771
+ input.removeAttribute('aria-activedescendant')
772
+ return
773
+ }
774
+
775
+ const group = doc.createElement('div')
776
+ group.className = 'docs-search__group'
777
+ group.append(doc.createTextNode(q ? 'Results' : 'On this page'))
778
+ const count = doc.createElement('span')
779
+ count.className = 'docs-search__group-count'
780
+ count.textContent = String(visible.length)
781
+ group.append(count)
782
+ results.append(group)
783
+
784
+ visible.forEach((entry, index) => results.append(buildRow(entry, index, q)))
785
+ applySelection()
786
+ footStatus.textContent = q
787
+ ? `${visible.length} result${visible.length === 1 ? '' : 's'}`
788
+ : 'Jump to a section'
789
+ }
790
+
791
+ const onKeydown = (event) => {
792
+ if (event.key === 'Escape') {
793
+ event.preventDefault()
794
+ close()
795
+ } else if (event.key === 'ArrowDown') {
796
+ event.preventDefault()
797
+ move(1)
798
+ } else if (event.key === 'ArrowUp') {
799
+ event.preventDefault()
800
+ move(-1)
801
+ } else if (event.key === 'Enter') {
802
+ const row = results.querySelector(
803
+ `.docs-search__row[data-index="${selected}"]`
804
+ )
805
+ if (row) {
806
+ event.preventDefault()
807
+ row.click()
808
+ }
809
+ } else if (event.key === 'Tab') {
810
+ const focusable = panel.querySelectorAll(
811
+ 'a[href], button, input, [tabindex]:not([tabindex="-1"])'
812
+ )
813
+ const first = focusable[0]
814
+ const last = focusable[focusable.length - 1]
815
+ if (
816
+ first &&
817
+ last &&
818
+ (event.shiftKey ? doc.activeElement === first : doc.activeElement === last)
819
+ ) {
820
+ event.preventDefault()
821
+ ;(event.shiftKey ? last : first).focus()
822
+ }
823
+ }
824
+ }
825
+
826
+ const build = () => {
827
+ overlay = doc.createElement('div')
828
+ overlay.className = 'docs-search-overlay'
829
+ overlay.addEventListener('click', (event) => {
830
+ if (event.target === overlay) close()
831
+ })
832
+
833
+ panel = doc.createElement('div')
834
+ panel.className = 'docs-search'
835
+ panel.setAttribute('role', 'dialog')
836
+ panel.setAttribute('aria-modal', 'true')
837
+ panel.setAttribute('aria-label', 'Search')
838
+
839
+ const field = doc.createElement('div')
840
+ field.className = 'docs-search__field'
841
+ const icon = createChromeSvg(doc, SEARCH_ICON, 16)
842
+ icon.classList.add('docs-search__icon')
843
+ input = doc.createElement('input')
844
+ input.className = 'docs-search__input'
845
+ input.type = 'search'
846
+ input.placeholder = 'Search the docs…'
847
+ input.setAttribute('role', 'combobox')
848
+ input.setAttribute('aria-autocomplete', 'list')
849
+ input.setAttribute('aria-controls', 'docs-search-listbox')
850
+ input.setAttribute('aria-expanded', 'false')
851
+ input.setAttribute('aria-label', 'Search the docs')
852
+ input.autocomplete = 'off'
853
+ input.spellcheck = false
854
+ const esc = doc.createElement('span')
855
+ esc.className = 'docs-search__esc'
856
+ esc.textContent = 'esc'
857
+ field.append(icon, input, esc)
858
+
859
+ results = doc.createElement('div')
860
+ results.className = 'docs-search__results'
861
+ results.id = 'docs-search-listbox'
862
+ results.setAttribute('role', 'listbox')
863
+ results.setAttribute('aria-label', 'Search results')
864
+
865
+ const foot = doc.createElement('div')
866
+ foot.className = 'docs-search__foot'
867
+ footStatus = doc.createElement('span')
868
+ const keys = doc.createElement('span')
869
+ keys.className = 'docs-search__keys'
870
+ keys.append(
871
+ keyHint(['↑', '↓'], 'navigate'),
872
+ keyHint(['↵'], 'open'),
873
+ keyHint(['esc'], 'close')
874
+ )
875
+ foot.append(footStatus, keys)
876
+
877
+ panel.append(field, results, foot)
878
+ overlay.append(panel)
879
+ ;(doc.body || doc.documentElement).append(overlay)
880
+
881
+ input.addEventListener('input', () => render(input.value))
882
+ }
883
+
884
+ const open = () => {
885
+ if (!overlay) build()
886
+ if (overlay.classList.contains('is-open')) return
887
+ entries = collectEntries()
888
+ returnFocus = doc.activeElement
889
+ prevOverflow = doc.documentElement.style.overflow
890
+ doc.documentElement.style.overflow = 'hidden'
891
+ input.value = ''
892
+ render('')
893
+ overlay.classList.add('is-open')
894
+ input.setAttribute('aria-expanded', 'true')
895
+ doc.addEventListener('keydown', onKeydown)
896
+ input.focus({ preventScroll: true })
897
+ win.requestAnimationFrame(() => input.focus({ preventScroll: true }))
898
+ }
899
+
900
+ function close() {
901
+ if (!overlay || !overlay.classList.contains('is-open')) return
902
+ overlay.classList.remove('is-open')
903
+ input.setAttribute('aria-expanded', 'false')
904
+ input.removeAttribute('aria-activedescendant')
905
+ doc.documentElement.style.overflow = prevOverflow ?? ''
906
+ doc.removeEventListener('keydown', onKeydown)
907
+ if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus()
908
+ }
909
+
910
+ trigger.addEventListener('click', open)
911
+
912
+ doc.addEventListener('keydown', (event) => {
913
+ if ((event.metaKey || event.ctrlKey) && (event.key === 'k' || event.key === 'K')) {
914
+ event.preventDefault()
915
+ if (overlay && overlay.classList.contains('is-open')) close()
916
+ else open()
917
+ }
918
+ })
919
+ }
920
+
921
+ function injectDocumentChrome(doc, win = doc.defaultView, navigationTree = []) {
922
+ if (doc.body?.dataset.bpChromeReady === 'true') return
923
+ const main = doc.querySelector('main')
924
+ if (!main) return
925
+
926
+ const hasNavigation = navigationTree.length > 0
927
+ const sidebar = hasNavigation ? injectSidebar(doc, win) : null
928
+ const chrome = [
929
+ injectSiteNav(doc, win),
930
+ hasNavigation ? injectScrollProgress(doc) : null,
931
+ sidebar ? injectSidebarToggle(doc) : null,
932
+ sidebar,
933
+ ].filter(Boolean)
934
+
935
+ if (chrome.length > 0) {
936
+ main.before(...chrome)
937
+ }
938
+
939
+ if (sidebar) {
940
+ doc.documentElement.dataset.bpDocument = ''
941
+ }
942
+
943
+ wireThemeToggle(doc, win)
944
+ wireSidebarCollapse(doc, win)
945
+ doc.body.dataset.bpChromeReady = 'true'
946
+ }
947
+
1
948
  // Optional behavior for TOC current state and reading progress.
2
949
  import { registerBlueprintChoiceElements } from './blueprint-choices.js'
3
950
 
@@ -8,17 +955,28 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
8
955
  if (root.dataset.blueprintRuntime === 'ready') return
9
956
  root.dataset.blueprintRuntime = 'ready'
10
957
 
11
- wireSectionHeadingLinks(doc)
958
+ // Resolve the theme before chrome is injected so the toggle renders in the
959
+ // correct state and the page doesn't repaint a second time.
960
+ resolveInitialTheme(doc, win)
961
+ const navigationTree = ensureDocumentNavTree(doc)
962
+ injectDocumentChrome(doc, win, navigationTree)
963
+ generateNavigationFromHeadings(doc, navigationTree)
964
+ wireSectionHeadingLinks(doc, win)
965
+ wireSearchPalette(doc, win)
12
966
  wireFigureDiagramSize(doc)
13
967
 
14
- // The shell becomes the scroll container only while it is fixed (wide
968
+ // <main> becomes the scroll container only while it is fixed (wide
15
969
  // layout). At ≤700px the CSS reverts it to static and the window scrolls, so
16
970
  // resolve the scroller live rather than caching it — a wide→narrow resize
17
971
  // otherwise leaves the scroll listener bound to an element that no longer
18
972
  // scrolls, silently freezing reading-progress and TOC highlighting.
19
- const shell = doc.querySelector('.bp-shell')
973
+ const main = doc.querySelector('main')
20
974
  const resolveScroller = () =>
21
- shell && win.getComputedStyle(shell).position === 'fixed' ? shell : null
975
+ main &&
976
+ root.dataset.bpDocument !== undefined &&
977
+ win.getComputedStyle(main).position === 'fixed'
978
+ ? main
979
+ : null
22
980
  let shellScroller = resolveScroller()
23
981
  const scrollTop = () => (shellScroller ? shellScroller.scrollTop : win.scrollY)
24
982
  const scrollHeight = () =>
@@ -27,10 +985,23 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
27
985
  shellScroller ? shellScroller.clientHeight : win.innerHeight
28
986
 
29
987
  const progress = doc.querySelector('.scroll-progress')
988
+ const isPrimaryNavLink = (link) => {
989
+ const listItem = link.closest('li')
990
+ const list = listItem?.parentElement
991
+ if (!list || !listItem) return true
992
+ const container = link.closest('.bp-sidebar, .bp-toc, .sidebar')
993
+ if (!container) return true
994
+ const topList =
995
+ container.querySelector(':scope > .bp-sidebar__panel > ul') ??
996
+ container.querySelector(':scope > ul')
997
+ return list === topList
998
+ }
30
999
  // Only the fixed rails track an active entry. A full-width .bp-contents
31
1000
  // index (typically parked at the top of a document) has no "current" entry,
32
- // so it stays a static index and is intentionally excluded here.
1001
+ // so it stays a static index and is intentionally excluded here. Sub-nav
1002
+ // links nest one level beneath a primary entry and are click targets only.
33
1003
  const entries = [...doc.querySelectorAll('.bp-sidebar a[href^="#"], .bp-toc a[href^="#"], .sidebar a[href^="#"]')]
1004
+ .filter(isPrimaryNavLink)
34
1005
  .map((link) => ({
35
1006
  link,
36
1007
  section: doc.getElementById(decodeURIComponent(link.hash.slice(1))),
@@ -76,19 +1047,24 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
76
1047
  progress.style.transform = `scaleX(${max > 0 ? scrollTop() / max : 0})`
77
1048
  }
78
1049
  if (sections.length === 0) return
1050
+ const threshold = win.innerHeight * 0.35
79
1051
  if (scrollingTo) {
80
- const top = scrollingTo.getBoundingClientRect().top
81
- if (top <= win.innerHeight * 0.35 && top >= -8) scrollingTo = null
82
- else {
1052
+ const rect = scrollingTo.getBoundingClientRect()
1053
+ const top = rect.top
1054
+ const visible = rect.bottom > 0 && top < win.innerHeight
1055
+ if (top <= threshold && top >= -8) {
1056
+ scrollingTo = null
1057
+ } else if (visible) {
83
1058
  setCurrent(scrollingTo)
84
1059
  return
1060
+ } else {
1061
+ scrollingTo = null
85
1062
  }
86
1063
  }
87
1064
  if (scrollTop() + clientHeight() >= scrollHeight() - 2) {
88
1065
  setCurrent(sections.at(-1))
89
1066
  return
90
1067
  }
91
- const threshold = win.innerHeight * 0.35
92
1068
  setCurrent(
93
1069
  sections.reduce(
94
1070
  (current, section) => (section.getBoundingClientRect().top <= threshold ? section : current),
@@ -101,11 +1077,18 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
101
1077
  scheduled = true
102
1078
  win.requestAnimationFrame(update)
103
1079
  }
1080
+ const prefersReducedMotion = () => themePrefersReducedMotion(win)
104
1081
  const updateFromHash = () => {
105
1082
  const match = entries.find(({ link }) => link.hash === win.location.hash)
106
1083
  if (match) {
107
1084
  scrollingTo = match.section
108
1085
  setCurrent(match.section)
1086
+ const top = match.section.getBoundingClientRect().top
1087
+ const threshold = win.innerHeight * 0.35
1088
+ if (top > threshold || top < -8) {
1089
+ match.section.scrollIntoView({ block: 'start' })
1090
+ }
1091
+ scheduleUpdate()
109
1092
  return
110
1093
  }
111
1094
  scrollingTo = null
@@ -114,7 +1097,6 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
114
1097
 
115
1098
  const navLinkSelector =
116
1099
  '.bp-sidebar a[href^="#"], .bp-toc a[href^="#"], .sidebar a[href^="#"], .bp-contents a[href^="#"]'
117
- const prefersReducedMotion = () => win.matchMedia('(prefers-reduced-motion: reduce)').matches
118
1100
 
119
1101
  const scrollToSection = (section) => {
120
1102
  scrollingTo = section
@@ -178,14 +1160,17 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
178
1160
  updateFromHash()
179
1161
  }
180
1162
 
181
- // Section heading permalink — pixelarticons in the main-column gutter; link on
182
- // heading hover, copy on icon hover, check after a successful copy.
183
- const HEADING_LINK_ICON =
184
- 'M4 6h7v2H4zm0 10h7v2H4zM2 8h2v8H2zm18-2h-7v2h7zm0 10h-7v2h7zm2-8h-2v8h2zM7 11h10v2H7z'
185
- const HEADING_COPY_ICON =
186
- 'M8 6h12v2H8zM4 2h12v2H4zm2 6h2v12H6zM2 4h2v12H2zm6 16h12v2H8zM20 8h2v12h-2zm-4-4h2v2h-2zM4 16h2v2H4z'
187
- const HEADING_CHECK_ICON =
188
- 'M10 18H8v-2h2v2Zm-2-2H6v-2h2v2Zm4-2v2h-2v-2h2Zm-6 0H4v-2h2v2Zm8 0h-2v-2h2v2Zm2-2h-2v-2h2v2Zm2-2h-2V8h2v2Zm2-2h-2V6h2v2Z'
1163
+ // Section heading permalink — Iconoir line glyphs in the main-column gutter;
1164
+ // link on heading hover, copy on icon hover, check after a successful copy.
1165
+ const HEADING_LINK_ICON = [
1166
+ 'M14 11.9976C14 9.5059 11.683 7 8.85714 7C8.52241 7 7.41904 7.00001 7.14286 7.00001C4.30254 7.00001 2 9.23752 2 11.9976C2 14.376 3.70973 16.3664 6 16.8714C6.36756 16.9525 6.75006 16.9952 7.14286 16.9952',
1167
+ 'M10 11.9976C10 14.4893 12.317 16.9952 15.1429 16.9952C15.4776 16.9952 16.581 16.9952 16.8571 16.9952C19.6975 16.9952 22 14.7577 22 11.9976C22 9.6192 20.2903 7.62884 18 7.12383C17.6324 7.04278 17.2499 6.99999 16.8571 6.99999',
1168
+ ]
1169
+ const HEADING_COPY_ICON = [
1170
+ 'M19.4 20H9.6C9.26863 20 9 19.7314 9 19.4V9.6C9 9.26863 9.26863 9 9.6 9H19.4C19.7314 9 20 9.26863 20 9.6V19.4C20 19.7314 19.7314 20 19.4 20Z',
1171
+ 'M15 9V4.6C15 4.26863 14.7314 4 14.4 4H4.6C4.26863 4 4 4.26863 4 4.6V14.4C4 14.7314 4.26863 15 4.6 15H9',
1172
+ ]
1173
+ const HEADING_CHECK_ICON = 'M5 13L9 17L19 7'
189
1174
  const headingLinkTimers = new WeakMap()
190
1175
 
191
1176
  function createHeadingPixelIcon(doc, name, pathD) {
@@ -193,12 +1178,18 @@ function createHeadingPixelIcon(doc, name, pathD) {
193
1178
  svg.setAttribute('viewBox', '0 0 24 24')
194
1179
  svg.setAttribute('width', '16')
195
1180
  svg.setAttribute('height', '16')
196
- svg.setAttribute('fill', 'currentColor')
1181
+ svg.setAttribute('fill', 'none')
1182
+ svg.setAttribute('stroke', 'currentColor')
1183
+ svg.setAttribute('stroke-width', '1.5')
1184
+ svg.setAttribute('stroke-linecap', 'round')
1185
+ svg.setAttribute('stroke-linejoin', 'round')
197
1186
  svg.setAttribute('aria-hidden', 'true')
198
1187
  svg.classList.add('bp-heading-link__icon', `bp-heading-link__icon--${name}`)
199
- const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
200
- path.setAttribute('d', pathD)
201
- svg.append(path)
1188
+ for (const d of Array.isArray(pathD) ? pathD : [pathD]) {
1189
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
1190
+ path.setAttribute('d', d)
1191
+ svg.append(path)
1192
+ }
202
1193
  return svg
203
1194
  }
204
1195
 
@@ -230,7 +1221,7 @@ function showHeadingLinkState(button, state, label) {
230
1221
  const previousTimer = headingLinkTimers.get(button)
231
1222
  if (previousTimer) clearTimeout(previousTimer)
232
1223
 
233
- const heading = button.closest('h2')
1224
+ const heading = button.closest('h1, h2, h3, h4, h5, h6')
234
1225
 
235
1226
  if (state === 'idle') {
236
1227
  delete button.dataset.bpCopyState
@@ -262,24 +1253,321 @@ function wireFigureDiagramSize(doc) {
262
1253
  }
263
1254
  }
264
1255
 
265
- function wireSectionHeadingLinks(doc, win = doc.defaultView) {
266
- for (const section of doc.querySelectorAll('section[id]')) {
267
- const heading =
268
- section.querySelector(':scope > h2:first-child') ??
269
- section.querySelector(':scope > hgroup:first-child > h2')
270
- if (!heading || heading.querySelector(':scope > .bp-heading-row')) continue
1256
+ const SIDEBAR_HEADING_SELECTOR =
1257
+ 'h2[data-sidebar], h3[data-sidebar], h4[data-sidebar], h5[data-sidebar], h6[data-sidebar]'
1258
+ const NAV_CONTENT_ROOT = 'main, [data-bp-nav-root]'
271
1259
 
272
- const label = heading.textContent.trim()
273
- heading.replaceChildren()
1260
+ function slugifyHeadingId(value) {
1261
+ return value
1262
+ .trim()
1263
+ .toLowerCase()
1264
+ .replace(/['']/g, '')
1265
+ .replace(/[^a-z0-9]+/g, '-')
1266
+ .replace(/^-+|-+$/g, '')
1267
+ }
274
1268
 
275
- const row = doc.createElement('span')
276
- row.className = 'bp-heading-row'
1269
+ function isSectionLeadHeading(heading) {
1270
+ const section = heading.closest('section')
1271
+ if (!section) return false
1272
+ return (
1273
+ heading === section.querySelector(':scope > h2:first-child') ||
1274
+ heading === section.querySelector(':scope > hgroup:first-child > h2')
1275
+ )
1276
+ }
1277
+
1278
+ function ensureUniqueHeadingId(doc, base, heading) {
1279
+ let candidate = base || 'section'
1280
+ let suffix = 2
1281
+ while (doc.getElementById(candidate) && doc.getElementById(candidate) !== heading) {
1282
+ candidate = `${base}-${suffix}`
1283
+ suffix += 1
1284
+ }
1285
+ return candidate
1286
+ }
1287
+
1288
+ function resolveHeadingId(doc, heading) {
1289
+ if (heading.id) return heading.id
1290
+
1291
+ const section = heading.closest('section[id]')
1292
+ if (section && isSectionLeadHeading(heading)) {
1293
+ // `<section id>` is the single anchoring contract: leave the id on the
1294
+ // section and point navigation, permalinks, and scroll-spy at it.
1295
+ return section.id
1296
+ }
1297
+
1298
+ const base = slugifyHeadingId(
1299
+ heading.getAttribute('data-sidebar') || heading.textContent || 'section'
1300
+ )
1301
+ heading.id = ensureUniqueHeadingId(doc, base, heading)
1302
+ return heading.id
1303
+ }
1304
+
1305
+ function getSidebarLabel(heading) {
1306
+ return heading.getAttribute('data-sidebar')?.trim() || heading.textContent.trim()
1307
+ }
1308
+
1309
+ // A repeated <header> in the nav root (every one after the masthead) is a
1310
+ // navigation GROUP divider: it reuses the masthead structure (eyebrow + h1 +
1311
+ // optional <bp-subheader>) and its title becomes a quiet eyebrow above the
1312
+ // sections that follow, in both the fixed rail and the inline contents.
1313
+ function findNavMasthead(root) {
1314
+ return root.querySelector(':scope > header')
1315
+ }
1316
+
1317
+ function getGroupLabel(header) {
1318
+ const explicit = header.getAttribute('data-sidebar')?.trim()
1319
+ if (explicit) return explicit
1320
+ const heading = header.querySelector('h1, h2, h3, h4, h5, h6')
1321
+ if (heading?.textContent.trim()) return heading.textContent.trim()
1322
+ const eyebrow = header.querySelector('bp-eyebrow')
1323
+ if (eyebrow?.textContent.trim()) return eyebrow.textContent.trim()
1324
+ return header.textContent.trim() || 'Section'
1325
+ }
1326
+
1327
+ function resolveGroupId(doc, header) {
1328
+ if (header.id) return header.id
1329
+ const base = slugifyHeadingId(getGroupLabel(header))
1330
+ header.id = ensureUniqueHeadingId(doc, base, header)
1331
+ return header.id
1332
+ }
1333
+
1334
+ // Collect group dividers (nav-root <header>s after the masthead) and every
1335
+ // data-sidebar heading in a single document-ordered pass.
1336
+ function collectNavNodes(doc) {
1337
+ const root = doc.querySelector(NAV_CONTENT_ROOT) ?? doc.body
1338
+ const masthead = findNavMasthead(root)
1339
+ const selector = `:scope > header, ${SIDEBAR_HEADING_SELECTOR}`
1340
+ return [...root.querySelectorAll(selector)].filter((node) => node !== masthead)
1341
+ }
1342
+
1343
+ function buildSidebarTree(nodes, doc) {
1344
+ const tree = []
1345
+ let currentGroup = null
1346
+ let currentPrimary = null
1347
+
1348
+ for (const node of nodes) {
1349
+ if (node.tagName === 'HEADER') {
1350
+ currentGroup = {
1351
+ type: 'group',
1352
+ label: getGroupLabel(node),
1353
+ id: resolveGroupId(doc, node),
1354
+ children: [],
1355
+ }
1356
+ currentPrimary = null
1357
+ tree.push(currentGroup)
1358
+ continue
1359
+ }
1360
+
1361
+ const level = Number(node.tagName.slice(1))
1362
+ const item = {
1363
+ type: 'section',
1364
+ heading: node,
1365
+ label: getSidebarLabel(node),
1366
+ id: node.id || resolveHeadingId(doc, node),
1367
+ children: [],
1368
+ }
1369
+
1370
+ if (level === 2) {
1371
+ currentPrimary = item
1372
+ ;(currentGroup ? currentGroup.children : tree).push(item)
1373
+ continue
1374
+ }
1375
+
1376
+ if (currentPrimary) {
1377
+ currentPrimary.children.push(item)
1378
+ continue
1379
+ }
1380
+
1381
+ ;(currentGroup ? currentGroup.children : tree).push(item)
1382
+ }
1383
+
1384
+ return tree
1385
+ }
1386
+
1387
+ function createNavLink(doc, { id, label }) {
1388
+ const link = doc.createElement('a')
1389
+ link.href = `#${id}`
1390
+ link.textContent = label
1391
+ return link
1392
+ }
1393
+
1394
+ // Group eyebrow + its section entries stay siblings in one flat list so the
1395
+ // gliding pill, auto-numbering, scroll-spy, and search keep working unchanged;
1396
+ // the group row carries no link and is skipped by the numbering counter.
1397
+ function createNavGroupRow(doc, group, labelClass) {
1398
+ const li = doc.createElement('li')
1399
+ li.className = 'bp-nav-group'
1400
+ const label = doc.createElement('span')
1401
+ label.className = labelClass
1402
+ label.textContent = group.label
1403
+ li.append(label)
1404
+ return li
1405
+ }
1406
+
1407
+ function renderNavTree(doc, tree) {
1408
+ const fragment = doc.createDocumentFragment()
1409
+
1410
+ const appendSection = (section) => {
1411
+ const li = doc.createElement('li')
1412
+ li.append(createNavLink(doc, section))
1413
+
1414
+ if (section.children?.length > 0) {
1415
+ const subList = doc.createElement('ul')
1416
+ for (const child of section.children) {
1417
+ const subItem = doc.createElement('li')
1418
+ subItem.append(createNavLink(doc, child))
1419
+ subList.append(subItem)
1420
+ }
1421
+ li.append(subList)
1422
+ }
1423
+
1424
+ fragment.append(li)
1425
+ }
1426
+
1427
+ for (const item of tree) {
1428
+ if (item.type === 'group') {
1429
+ fragment.append(createNavGroupRow(doc, item, 'bp-nav-group__label'))
1430
+ item.children.forEach(appendSection)
1431
+ continue
1432
+ }
1433
+ appendSection(item)
1434
+ }
1435
+
1436
+ return fragment
1437
+ }
1438
+
1439
+ function renderContentsTree(doc, tree) {
1440
+ const fragment = doc.createDocumentFragment()
1441
+
1442
+ const appendSection = (section) => {
1443
+ const li = doc.createElement('li')
1444
+ li.append(createNavLink(doc, section))
1445
+ fragment.append(li)
1446
+ }
1447
+
1448
+ for (const item of tree) {
1449
+ if (item.type === 'group') {
1450
+ const li = createNavGroupRow(doc, item, 'bp-contents-group__label')
1451
+ li.className = 'bp-contents-group'
1452
+ fragment.append(li)
1453
+ item.children.forEach(appendSection)
1454
+ continue
1455
+ }
1456
+ appendSection(item)
1457
+ }
1458
+
1459
+ return fragment
1460
+ }
1461
+
1462
+ function findSidebarNavLists(doc) {
1463
+ const lists = []
1464
+
1465
+ for (const nav of doc.querySelectorAll('.bp-sidebar:not([data-bp-nav-manual])')) {
1466
+ const list =
1467
+ nav.querySelector(':scope > .bp-sidebar__panel > ul') ??
1468
+ nav.querySelector(':scope > ul')
1469
+ if (list) lists.push(list)
1470
+ }
1471
+
1472
+ for (const nav of doc.querySelectorAll('.bp-toc:not([data-bp-nav-manual])')) {
1473
+ const list = nav.querySelector(':scope > ul')
1474
+ if (list) lists.push(list)
1475
+ }
1476
+
1477
+ return lists
1478
+ }
1479
+
1480
+ function ensureDocumentNavTree(doc) {
1481
+ const nodes = collectNavNodes(doc)
1482
+ if (nodes.length === 0) return []
1483
+
1484
+ for (const node of nodes) {
1485
+ if (node.tagName === 'HEADER') resolveGroupId(doc, node)
1486
+ else resolveHeadingId(doc, node)
1487
+ }
1488
+
1489
+ return buildSidebarTree(nodes, doc)
1490
+ }
1491
+
1492
+ function populateBpTocElement(element, doc, tree) {
1493
+ if (element.dataset.bpTocReady === 'true') return
1494
+ if (!tree?.length) return
1495
+
1496
+ element.dataset.bpTocReady = 'true'
1497
+
1498
+ const label =
1499
+ element.getAttribute('label')?.trim() ||
1500
+ element.textContent.trim() ||
1501
+ 'Contents'
1502
+ const ariaLabel = element.getAttribute('aria-label')?.trim() || label
1503
+
1504
+ const nav = doc.createElement('nav')
1505
+ nav.className = 'bp-contents'
1506
+ nav.setAttribute('aria-label', ariaLabel)
1507
+
1508
+ const eyebrow = doc.createElement('p')
1509
+ eyebrow.className = 'bp-eyebrow'
1510
+ eyebrow.textContent = label
1511
+
1512
+ const list = doc.createElement('ol')
1513
+ list.append(renderContentsTree(doc, tree))
1514
+
1515
+ nav.append(eyebrow, list)
1516
+ element.replaceChildren(nav)
1517
+ }
1518
+
1519
+ function populateAllBpToc(doc, tree) {
1520
+ for (const element of doc.querySelectorAll('bp-toc')) {
1521
+ populateBpTocElement(element, doc, tree)
1522
+ }
1523
+ }
1524
+
1525
+ function generateNavigationFromHeadings(doc, tree = ensureDocumentNavTree(doc)) {
1526
+ if (tree.length === 0) return
1527
+
1528
+ const navFragment = renderNavTree(doc, tree)
1529
+
1530
+ for (const list of findSidebarNavLists(doc)) {
1531
+ list.replaceChildren(navFragment.cloneNode(true))
1532
+ }
1533
+
1534
+ populateAllBpToc(doc, tree)
1535
+ }
1536
+
1537
+ function formatHeadingEyebrow(number) {
1538
+ return String(number).padStart(2, '0')
1539
+ }
1540
+
1541
+ function wireSectionHeadingLinks(doc, win = doc.defaultView) {
1542
+ const root = doc.querySelector(NAV_CONTENT_ROOT) ?? doc.body
1543
+ let primaryNumber = 0
1544
+
1545
+ for (const heading of root.querySelectorAll(SIDEBAR_HEADING_SELECTOR)) {
1546
+ if (heading.querySelector(':scope > .bp-heading-row')) continue
1547
+
1548
+ const level = Number(heading.tagName.slice(1))
1549
+ const label = getSidebarLabel(heading)
1550
+ const titleNodes = [...heading.childNodes]
1551
+ const id = resolveHeadingId(doc, heading)
1552
+
1553
+ heading.replaceChildren()
1554
+
1555
+ if (level === 2) {
1556
+ primaryNumber += 1
1557
+ const eyebrow = doc.createElement('span')
1558
+ eyebrow.className = 'bp-heading-eyebrow'
1559
+ eyebrow.textContent = formatHeadingEyebrow(primaryNumber)
1560
+ heading.append(eyebrow)
1561
+ }
1562
+
1563
+ const row = doc.createElement('span')
1564
+ row.className = 'bp-heading-row'
277
1565
 
278
1566
  const button = createHeadingLinkButton(doc, label)
279
1567
 
280
1568
  const title = doc.createElement('span')
281
1569
  title.className = 'bp-heading-title'
282
- title.textContent = label
1570
+ title.append(...titleNodes)
283
1571
 
284
1572
  row.append(button, title)
285
1573
  heading.append(row)
@@ -312,7 +1600,7 @@ function wireSectionHeadingLinks(doc, win = doc.defaultView) {
312
1600
 
313
1601
  button.addEventListener('click', (event) => {
314
1602
  event.preventDefault()
315
- const url = new URL(`#${section.id}`, win?.location.href ?? '').href
1603
+ const url = new URL(`#${id}`, win?.location.href ?? '').href
316
1604
  copySectionUrl(url)
317
1605
  .then(() => showHeadingLinkState(button, 'copied', label))
318
1606
  .catch(() => showHeadingLinkState(button, 'error', label))
@@ -367,6 +1655,25 @@ function createCalloutIcon(doc, pathD) {
367
1655
  return svg
368
1656
  }
369
1657
 
1658
+ // ---------------------------------------------------------------------
1659
+ // <bp-toc> — inline table of contents (full-width .bp-contents family).
1660
+ //
1661
+ // Place the element wherever the index should appear; it expands to a
1662
+ // numbered, multi-column nav on load from h2 headings marked data-sidebar.
1663
+ // Sub-sections appear in the fixed rail only; the inline block stays flat.
1664
+ //
1665
+ // <bp-toc label="Contents"></bp-toc>
1666
+ //
1667
+ // label: eyebrow text (default: Contents). aria-label overrides the nav label.
1668
+ // ---------------------------------------------------------------------
1669
+ class BlueprintTocElement extends HTMLElement {
1670
+ connectedCallback() {
1671
+ const doc = this.ownerDocument
1672
+ if (doc.documentElement.dataset.blueprintRuntime !== 'ready') return
1673
+ populateBpTocElement(this, doc, ensureDocumentNavTree(doc))
1674
+ }
1675
+ }
1676
+
370
1677
  class BlueprintCalloutElement extends HTMLElement {
371
1678
  #rendered = false
372
1679
 
@@ -407,10 +1714,1141 @@ class BlueprintCalloutElement extends HTMLElement {
407
1714
  }
408
1715
  }
409
1716
 
1717
+ // ---------------------------------------------------------------------
1718
+ // <bp-mock> — a drafting "browser frame" for UX mockups.
1719
+ //
1720
+ // Wraps a low-fidelity component sketch in an ink chrome frame (window
1721
+ // ticks + a mono URL/title). The frame is light-DOM and the authored
1722
+ // children are moved into a scalable canvas, so the mock is contained by
1723
+ // construction — no shadow DOM. Two optional behaviours:
1724
+ // • viewport="1280" draws the canvas at that pixel width and scales it
1725
+ // to fit the frame (crisp 1:1 when the frame is wide enough or when
1726
+ // expanded), so a desktop layout can be mocked inside a narrow column.
1727
+ // • expandable an expand control lifts the frame into a near-
1728
+ // fullscreen overlay (Esc / the control dismiss; focus is restored).
1729
+ //
1730
+ // <bp-mock label="app.example.com" viewport="1280"> … </bp-mock>
1731
+ //
1732
+ // label: mono caption in the chrome bar. viewport: canvas width in px.
1733
+ // expandable="false" drops the expand control.
1734
+ // ---------------------------------------------------------------------
1735
+ const MOCK_EXPAND_ICON = [
1736
+ 'M9 9L4 4M4 4V8M4 4H8',
1737
+ 'M15 9L20 4M20 4V8M20 4H16',
1738
+ 'M9 15L4 20M4 20V16M4 20H8',
1739
+ 'M15 15L20 20M20 20V16M20 20H16',
1740
+ ]
1741
+ const MOCK_COLLAPSE_ICON = [
1742
+ 'M20 20L15 15M15 15V19M15 15H19',
1743
+ 'M4 20L9 15M9 15V19M9 15H5',
1744
+ 'M20 4L15 9M15 9V5M15 9H19',
1745
+ 'M4 4L9 9M9 9V5M9 9H5',
1746
+ ]
1747
+
1748
+ class BlueprintMockElement extends HTMLElement {
1749
+ #rendered = false
1750
+ #ro = null
1751
+ #wrap = null
1752
+ #stage = null
1753
+ #canvas = null
1754
+ #btn = null
1755
+ #naturalWidth = 0
1756
+ #onKeydown = null
1757
+ #returnFocus = null
1758
+ #prevOverflow = ''
1759
+
1760
+ connectedCallback() {
1761
+ if (this.#rendered) return
1762
+ this.#rendered = true
1763
+
1764
+ const doc = this.ownerDocument
1765
+ const label = this.getAttribute('label') || ''
1766
+ const vw = Number.parseInt(this.getAttribute('viewport') || '', 10)
1767
+ this.#naturalWidth = Number.isFinite(vw) && vw > 0 ? vw : 0
1768
+ const expandable = this.getAttribute('expandable') !== 'false'
1769
+
1770
+ const wrap = doc.createElement('div')
1771
+ wrap.className = 'bp-mock'
1772
+ wrap.setAttribute('role', 'group')
1773
+ if (label) wrap.setAttribute('aria-label', `Mockup: ${label}`)
1774
+
1775
+ const frame = doc.createElement('div')
1776
+ frame.className = 'bp-mock__frame'
1777
+
1778
+ const bar = doc.createElement('div')
1779
+ bar.className = 'bp-mock__bar'
1780
+
1781
+ const ticks = doc.createElement('span')
1782
+ ticks.className = 'bp-mock__ticks'
1783
+ ticks.setAttribute('aria-hidden', 'true')
1784
+ ticks.append(doc.createElement('i'), doc.createElement('i'), doc.createElement('i'))
1785
+ bar.append(ticks)
1786
+
1787
+ const cap = doc.createElement('span')
1788
+ cap.className = 'bp-mock__label'
1789
+ cap.textContent = label
1790
+ bar.append(cap)
1791
+
1792
+ if (expandable) {
1793
+ const btn = doc.createElement('button')
1794
+ btn.type = 'button'
1795
+ btn.className = 'bp-mock__btn'
1796
+ btn.setAttribute('aria-expanded', 'false')
1797
+ btn.setAttribute('aria-label', 'Expand mockup')
1798
+ btn.append(createChromeSvg(doc, MOCK_EXPAND_ICON, 14))
1799
+ btn.addEventListener('click', () => this.#toggle())
1800
+ bar.append(btn)
1801
+ this.#btn = btn
1802
+ }
1803
+
1804
+ const stage = doc.createElement('div')
1805
+ stage.className = 'bp-mock__stage'
1806
+
1807
+ const canvas = doc.createElement('div')
1808
+ canvas.className = 'bp-mock__canvas'
1809
+ if (this.#naturalWidth) canvas.style.width = `${this.#naturalWidth}px`
1810
+ canvas.append(...this.childNodes)
1811
+ stage.append(canvas)
1812
+
1813
+ frame.append(bar, stage)
1814
+ wrap.append(frame)
1815
+ this.replaceChildren(wrap)
1816
+
1817
+ this.#wrap = wrap
1818
+ this.#stage = stage
1819
+ this.#canvas = canvas
1820
+
1821
+ if (this.#naturalWidth && typeof ResizeObserver !== 'undefined') {
1822
+ this.#ro = new ResizeObserver(() => this.#relayout())
1823
+ this.#ro.observe(stage)
1824
+ this.#ro.observe(canvas)
1825
+ this.#relayout()
1826
+ }
1827
+ }
1828
+
1829
+ disconnectedCallback() {
1830
+ this.#ro?.disconnect()
1831
+ if (this.#onKeydown) this.ownerDocument.removeEventListener('keydown', this.#onKeydown)
1832
+ }
1833
+
1834
+ // Scale the fixed-width canvas down to whatever width the stage offers,
1835
+ // then collapse the stage to the scaled height so there is no dead space.
1836
+ #relayout() {
1837
+ if (!this.#naturalWidth || !this.#stage) return
1838
+ const avail = this.#stage.clientWidth
1839
+ if (!avail) return
1840
+ const scale = Math.min(1, avail / this.#naturalWidth)
1841
+ this.#canvas.style.setProperty('--bp-mock-scale', String(scale))
1842
+ this.#stage.style.height = `${Math.round(this.#canvas.offsetHeight * scale)}px`
1843
+ }
1844
+
1845
+ #toggle() {
1846
+ const doc = this.ownerDocument
1847
+ const expanded = this.#wrap.classList.toggle('is-expanded')
1848
+ if (this.#btn) {
1849
+ this.#btn.setAttribute('aria-expanded', String(expanded))
1850
+ this.#btn.setAttribute('aria-label', expanded ? 'Collapse mockup' : 'Expand mockup')
1851
+ this.#btn.replaceChildren(
1852
+ createChromeSvg(doc, expanded ? MOCK_COLLAPSE_ICON : MOCK_EXPAND_ICON, 14)
1853
+ )
1854
+ }
1855
+ if (expanded) {
1856
+ this.#returnFocus = doc.activeElement
1857
+ // The page scrolls, so freeze the background behind the overlay.
1858
+ this.#prevOverflow = doc.documentElement.style.overflow
1859
+ doc.documentElement.style.overflow = 'hidden'
1860
+ this.#onKeydown = (event) => {
1861
+ if (event.key === 'Escape') this.#toggle()
1862
+ }
1863
+ doc.addEventListener('keydown', this.#onKeydown)
1864
+ this.#btn?.focus()
1865
+ } else {
1866
+ doc.documentElement.style.overflow = this.#prevOverflow ?? ''
1867
+ if (this.#onKeydown) {
1868
+ doc.removeEventListener('keydown', this.#onKeydown)
1869
+ this.#onKeydown = null
1870
+ }
1871
+ if (this.#returnFocus && typeof this.#returnFocus.focus === 'function') {
1872
+ this.#returnFocus.focus()
1873
+ }
1874
+ }
1875
+ // The stage width changes with the overlay; rescale on the next frame.
1876
+ if (typeof requestAnimationFrame === 'function') {
1877
+ requestAnimationFrame(() => this.#relayout())
1878
+ } else {
1879
+ this.#relayout()
1880
+ }
1881
+ }
1882
+ }
1883
+
1884
+ // ---------------------------------------------------------------------
1885
+ // <bp-gallery> — an elegant grid of images that expands to a lightbox.
1886
+ //
1887
+ // Wraps author-provided images (screenshots, generated mockups, or user
1888
+ // uploads) in a tidy responsive grid. Each thumbnail wears a bluescale
1889
+ // treatment — desaturated and washed in the illustration ramp — until it
1890
+ // is hovered or focused, when its true color is revealed. Activating a
1891
+ // thumbnail lifts the full-color image into a near-fullscreen lightbox
1892
+ // over a drafting scrim (Esc / the close control dismiss; arrow keys and
1893
+ // the prev/next controls page between images; focus is restored).
1894
+ //
1895
+ // <bp-gallery label="Concept explorations">
1896
+ // <figure>
1897
+ // <img src="hero.png" alt="Landing hero" />
1898
+ // <figcaption>Landing hero</figcaption>
1899
+ // </figure>
1900
+ // <img src="settings.png" alt="Settings panel" />
1901
+ // </bp-gallery>
1902
+ //
1903
+ // label: optional mono caption for the gallery as a whole (accessible
1904
+ // name + lightbox kicker). Captions come from a child <figcaption> when
1905
+ // present, otherwise the image's alt text. The component is light-DOM and
1906
+ // expands to documented .bp-gallery* markup styled by blueprint.css.
1907
+ // ---------------------------------------------------------------------
1908
+ const GALLERY_ZOOM_ICON = [
1909
+ 'M8 11H11M14 11H11M11 11V8M11 11V14',
1910
+ 'M17 17L21 21',
1911
+ 'M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z',
1912
+ ]
1913
+ const GALLERY_CLOSE_ICON =
1914
+ 'M6.75827 17.2426L12.0009 12M17.2435 6.75736L12.0009 12M12.0009 12L6.75827 6.75736M12.0009 12L17.2435 17.2426'
1915
+ const GALLERY_PREV_ICON = 'M15 6L9 12L15 18'
1916
+ const GALLERY_NEXT_ICON = 'M9 6L15 12L9 18'
1917
+
1918
+ class BlueprintGalleryElement extends HTMLElement {
1919
+ #rendered = false
1920
+ /** @type {{ src: string, alt: string, caption: string }[]} */
1921
+ #slides = []
1922
+ #label = ''
1923
+ #box = null
1924
+ #boxImg = null
1925
+ #boxCaption = null
1926
+ #boxCounter = null
1927
+ #boxKicker = null
1928
+ #index = 0
1929
+ #onKeydown = null
1930
+ #returnFocus = null
1931
+ #prevOverflow = ''
1932
+
1933
+ connectedCallback() {
1934
+ if (this.#rendered) return
1935
+ this.#rendered = true
1936
+
1937
+ const doc = this.ownerDocument
1938
+ this.#label = this.getAttribute('label') || ''
1939
+
1940
+ // Collect the authored images and their captions before replacing the
1941
+ // light-DOM children. A <figcaption> wins; otherwise fall back to alt.
1942
+ for (const img of this.querySelectorAll('img')) {
1943
+ const figure = img.closest('figure')
1944
+ const cap = figure?.querySelector('figcaption')
1945
+ const caption = (cap?.textContent || img.getAttribute('alt') || '').trim()
1946
+ this.#slides.push({
1947
+ src: img.getAttribute('src') || '',
1948
+ alt: (img.getAttribute('alt') || caption || '').trim(),
1949
+ caption,
1950
+ })
1951
+ }
1952
+
1953
+ const wrap = doc.createElement('div')
1954
+ wrap.className = 'bp-gallery'
1955
+ wrap.setAttribute('role', 'group')
1956
+ if (this.#label) wrap.setAttribute('aria-label', `Gallery: ${this.#label}`)
1957
+
1958
+ const grid = doc.createElement('div')
1959
+ grid.className = 'bp-gallery__grid'
1960
+
1961
+ this.#slides.forEach((slide, index) => {
1962
+ const item = doc.createElement('button')
1963
+ item.type = 'button'
1964
+ item.className = 'bp-gallery__item'
1965
+ item.setAttribute(
1966
+ 'aria-label',
1967
+ slide.caption ? `View image: ${slide.caption}` : `View image ${index + 1}`
1968
+ )
1969
+ item.addEventListener('click', () => this.#open(index))
1970
+
1971
+ const thumb = doc.createElement('span')
1972
+ thumb.className = 'bp-gallery__thumb'
1973
+
1974
+ const img = doc.createElement('img')
1975
+ img.className = 'bp-gallery__img'
1976
+ img.src = slide.src
1977
+ img.alt = slide.alt
1978
+ img.loading = 'lazy'
1979
+ img.decoding = 'async'
1980
+
1981
+ // The bluescale wash: a blue veil that color-blends the grayscale
1982
+ // thumbnail, faded away on hover/focus to reveal true color.
1983
+ const wash = doc.createElement('span')
1984
+ wash.className = 'bp-gallery__wash'
1985
+ wash.setAttribute('aria-hidden', 'true')
1986
+
1987
+ const zoom = doc.createElement('span')
1988
+ zoom.className = 'bp-gallery__zoom'
1989
+ zoom.setAttribute('aria-hidden', 'true')
1990
+ zoom.append(createChromeSvg(doc, GALLERY_ZOOM_ICON, 16))
1991
+
1992
+ thumb.append(img, wash, zoom)
1993
+ item.append(thumb)
1994
+
1995
+ if (slide.caption) {
1996
+ const cap = doc.createElement('span')
1997
+ cap.className = 'bp-gallery__caption'
1998
+ cap.textContent = slide.caption
1999
+ item.append(cap)
2000
+ }
2001
+
2002
+ grid.append(item)
2003
+ })
2004
+
2005
+ wrap.append(grid)
2006
+ this.replaceChildren(wrap)
2007
+ }
2008
+
2009
+ disconnectedCallback() {
2010
+ if (this.#onKeydown) this.ownerDocument.removeEventListener('keydown', this.#onKeydown)
2011
+ this.#box?.remove()
2012
+ this.#box = null
2013
+ }
2014
+
2015
+ #ensureBox() {
2016
+ if (this.#box) return
2017
+ const doc = this.ownerDocument
2018
+
2019
+ const box = doc.createElement('div')
2020
+ box.className = 'bp-lightbox'
2021
+ box.setAttribute('role', 'dialog')
2022
+ box.setAttribute('aria-modal', 'true')
2023
+ box.setAttribute('aria-label', this.#label ? `${this.#label} — image viewer` : 'Image viewer')
2024
+ box.addEventListener('click', (event) => {
2025
+ if (event.target === box) this.#close()
2026
+ })
2027
+
2028
+ const close = doc.createElement('button')
2029
+ close.type = 'button'
2030
+ close.className = 'bp-lightbox__close'
2031
+ close.setAttribute('aria-label', 'Close image viewer')
2032
+ close.append(createChromeSvg(doc, GALLERY_CLOSE_ICON, 18))
2033
+ close.addEventListener('click', () => this.#close())
2034
+
2035
+ const prev = doc.createElement('button')
2036
+ prev.type = 'button'
2037
+ prev.className = 'bp-lightbox__nav bp-lightbox__nav--prev'
2038
+ prev.setAttribute('aria-label', 'Previous image')
2039
+ prev.append(createChromeSvg(doc, GALLERY_PREV_ICON, 22))
2040
+ prev.addEventListener('click', () => this.#step(-1))
2041
+
2042
+ const next = doc.createElement('button')
2043
+ next.type = 'button'
2044
+ next.className = 'bp-lightbox__nav bp-lightbox__nav--next'
2045
+ next.setAttribute('aria-label', 'Next image')
2046
+ next.append(createChromeSvg(doc, GALLERY_NEXT_ICON, 22))
2047
+ next.addEventListener('click', () => this.#step(1))
2048
+
2049
+ const figure = doc.createElement('figure')
2050
+ figure.className = 'bp-lightbox__figure'
2051
+
2052
+ const img = doc.createElement('img')
2053
+ img.className = 'bp-lightbox__img'
2054
+ img.alt = ''
2055
+
2056
+ const meta = doc.createElement('figcaption')
2057
+ meta.className = 'bp-lightbox__meta'
2058
+
2059
+ const kicker = doc.createElement('span')
2060
+ kicker.className = 'bp-lightbox__kicker'
2061
+ kicker.textContent = this.#label
2062
+
2063
+ const counter = doc.createElement('span')
2064
+ counter.className = 'bp-lightbox__counter'
2065
+
2066
+ const caption = doc.createElement('span')
2067
+ caption.className = 'bp-lightbox__caption'
2068
+
2069
+ meta.append(kicker, counter, caption)
2070
+ figure.append(img, meta)
2071
+ box.append(close, prev, figure, next)
2072
+
2073
+ const host = doc.body || doc.documentElement
2074
+ host.append(box)
2075
+
2076
+ this.#box = box
2077
+ this.#boxImg = img
2078
+ this.#boxCaption = caption
2079
+ this.#boxCounter = counter
2080
+ this.#boxKicker = kicker
2081
+ }
2082
+
2083
+ #render() {
2084
+ const slide = this.#slides[this.#index]
2085
+ if (!slide) return
2086
+ this.#boxImg.src = slide.src
2087
+ this.#boxImg.alt = slide.alt
2088
+ this.#boxCaption.textContent = slide.caption
2089
+ this.#boxCaption.hidden = !slide.caption
2090
+ this.#boxKicker.hidden = !this.#label
2091
+ const many = this.#slides.length > 1
2092
+ this.#boxCounter.textContent = many ? `${this.#index + 1} / ${this.#slides.length}` : ''
2093
+ this.#boxCounter.hidden = !many
2094
+ this.#box.classList.toggle('is-single', !many)
2095
+ }
2096
+
2097
+ #open(index) {
2098
+ if (!this.#slides.length) return
2099
+ this.#ensureBox()
2100
+ this.#index = index
2101
+ this.#render()
2102
+
2103
+ const doc = this.ownerDocument
2104
+ this.#returnFocus = doc.activeElement
2105
+ this.#prevOverflow = doc.documentElement.style.overflow
2106
+ doc.documentElement.style.overflow = 'hidden'
2107
+ this.#box.classList.add('is-open')
2108
+
2109
+ this.#onKeydown = (event) => {
2110
+ if (event.key === 'Escape') this.#close()
2111
+ else if (event.key === 'ArrowRight') this.#step(1)
2112
+ else if (event.key === 'ArrowLeft') this.#step(-1)
2113
+ }
2114
+ doc.addEventListener('keydown', this.#onKeydown)
2115
+ this.#box.querySelector('.bp-lightbox__close')?.focus()
2116
+ }
2117
+
2118
+ #step(delta) {
2119
+ if (this.#slides.length < 2) return
2120
+ const count = this.#slides.length
2121
+ this.#index = (this.#index + delta + count) % count
2122
+ this.#render()
2123
+ }
2124
+
2125
+ #close() {
2126
+ if (!this.#box) return
2127
+ const doc = this.ownerDocument
2128
+ this.#box.classList.remove('is-open')
2129
+ doc.documentElement.style.overflow = this.#prevOverflow ?? ''
2130
+ if (this.#onKeydown) {
2131
+ doc.removeEventListener('keydown', this.#onKeydown)
2132
+ this.#onKeydown = null
2133
+ }
2134
+ if (this.#returnFocus && typeof this.#returnFocus.focus === 'function') {
2135
+ this.#returnFocus.focus()
2136
+ }
2137
+ }
2138
+ }
2139
+
2140
+ // Presentational text-role elements. No behavior — styled by tag name in
2141
+ // blueprint.css (lede paragraph, quiet aside, muted mono micro-label). They
2142
+ // exist as elements so authors compose with markup instead of utility classes.
2143
+ class BlueprintSubheaderElement extends HTMLElement {}
2144
+ class BlueprintNoteElement extends HTMLElement {}
2145
+ class BlueprintEyebrowElement extends HTMLElement {}
2146
+
2147
+ // ---------------------------------------------------------------------
2148
+ // <bp-table> — an opt-in enhanced data table.
2149
+ //
2150
+ // A light-DOM wrapper around a plain author <table>; the <table> keeps its
2151
+ // base styling and the expanded form stays a valid stored document. On
2152
+ // upgrade the runtime wraps the table in a .bp-table-x scroll shell so
2153
+ // horizontal overflow is contained without making <bp-table> the sticky
2154
+ // header's scroll ancestor; wide / hover / sticky / compact are pure CSS
2155
+ // (attribute selectors in blueprint.css). The runtime's other job is the
2156
+ // value-specific work CSS cannot do: scan each column's <tbody> data cells
2157
+ // and, when they all read as numbers, end-align that column's <td> AND its
2158
+ // <th scope="col"> by stamping a .bp-col-num class.
2159
+ //
2160
+ // <bp-table sticky density="compact" wide><table>…</table></bp-table>
2161
+ //
2162
+ // numeric: omit to auto-detect; `off` disables alignment; an explicit
2163
+ // 1-based list ("2,3") forces those columns. sticky pins the header + key
2164
+ // column; density="compact" tightens cells; wide breaks past the measure.
2165
+ // ---------------------------------------------------------------------
2166
+
2167
+ // A cell reads as numeric: optional sign / currency, digits with optional
2168
+ // grouping and decimals, optional percent.
2169
+ const TABLE_NUMERIC = /^[+-]?[$€£¥]?\s?\d[\d,]*(\.\d+)?\s?%?$/
2170
+ // Dash / "n/a" placeholders count as empty (not as text), so a numeric
2171
+ // column with a few gaps still resolves as numeric.
2172
+ const TABLE_BLANK = /^(—|–|-|n\/?a)$/i
2173
+
2174
+ class BlueprintTableElement extends HTMLElement {
2175
+ static get observedAttributes() {
2176
+ return ['numeric']
2177
+ }
2178
+
2179
+ #observer = null
2180
+ #applying = false
2181
+
2182
+ connectedCallback() {
2183
+ this.#ensureScrollShell()
2184
+ this.#apply()
2185
+ if (typeof MutationObserver !== 'undefined' && !this.#observer) {
2186
+ // Re-detect when the author's table content changes. Our own class
2187
+ // edits are attribute mutations, which this observer ignores, so it
2188
+ // cannot loop; the flag is belt-and-suspenders.
2189
+ this.#observer = new MutationObserver(() => {
2190
+ if (!this.#applying) this.#apply()
2191
+ })
2192
+ this.#observer.observe(this, { childList: true, subtree: true, characterData: true })
2193
+ }
2194
+ }
2195
+
2196
+ disconnectedCallback() {
2197
+ this.#observer?.disconnect()
2198
+ this.#observer = null
2199
+ }
2200
+
2201
+ attributeChangedCallback() {
2202
+ if (this.isConnected) {
2203
+ this.#ensureScrollShell()
2204
+ this.#apply()
2205
+ }
2206
+ }
2207
+
2208
+ // Wrap the author's <table> in a horizontal scroll shell. Sticky header
2209
+ // cells must stick to the page scrollport, not to <bp-table> — so overflow-x
2210
+ // lives here, on .bp-table-x, instead of on the host.
2211
+ #ensureScrollShell() {
2212
+ const table = this.querySelector(':scope > table')
2213
+ if (!table) return
2214
+ let shell = this.querySelector(':scope > .bp-table-x')
2215
+ if (!shell) {
2216
+ shell = document.createElement('div')
2217
+ shell.className = 'bp-table-x'
2218
+ this.insertBefore(shell, table)
2219
+ }
2220
+ if (table.parentElement !== shell) shell.append(table)
2221
+ }
2222
+
2223
+ // Resolve which 1-based columns end-align, then stamp .bp-col-num on the
2224
+ // matching cells across thead / tbody / tfoot.
2225
+ #apply() {
2226
+ const table = this.querySelector('table')
2227
+ if (!table) return
2228
+ this.#applying = true
2229
+ try {
2230
+ for (const el of this.querySelectorAll('.bp-col-num')) {
2231
+ el.classList.remove('bp-col-num')
2232
+ }
2233
+ const attr = (this.getAttribute('numeric') || '').trim().toLowerCase()
2234
+ if (attr === 'off') return
2235
+ let columns
2236
+ if (attr) {
2237
+ columns = new Set(
2238
+ attr.split(',').map((n) => Number.parseInt(n, 10)).filter((n) => n > 0)
2239
+ )
2240
+ if (!columns.size) columns = this.#detect(table)
2241
+ } else {
2242
+ columns = this.#detect(table)
2243
+ }
2244
+ if (columns.size) this.#mark(table, columns)
2245
+ } finally {
2246
+ this.#applying = false
2247
+ }
2248
+ }
2249
+
2250
+ // A column is numeric when every non-blank <td> in it parses as a number.
2251
+ // <th> cells (the key column) are skipped but still advance the column
2252
+ // index so it stays aligned with the header row.
2253
+ #detect(table) {
2254
+ const tally = new Map()
2255
+ for (const body of table.tBodies) {
2256
+ for (const row of body.rows) {
2257
+ let col = 0
2258
+ for (const cell of row.cells) {
2259
+ const span = cell.colSpan || 1
2260
+ col += 1
2261
+ if (cell.tagName === 'TH') {
2262
+ col += span - 1
2263
+ continue
2264
+ }
2265
+ const text = cell.textContent.trim()
2266
+ if (!text || TABLE_BLANK.test(text)) continue
2267
+ const rec = tally.get(col) || { total: 0, numeric: 0 }
2268
+ rec.total += 1
2269
+ if (TABLE_NUMERIC.test(text)) rec.numeric += 1
2270
+ tally.set(col, rec)
2271
+ col += span - 1
2272
+ }
2273
+ }
2274
+ }
2275
+ const columns = new Set()
2276
+ for (const [col, rec] of tally) {
2277
+ if (rec.total > 0 && rec.numeric === rec.total) columns.add(col)
2278
+ }
2279
+ return columns
2280
+ }
2281
+
2282
+ // Stamp .bp-col-num on every cell whose 1-based column index is numeric.
2283
+ #mark(table, columns) {
2284
+ const stamp = (rows) => {
2285
+ for (const row of rows) {
2286
+ let col = 0
2287
+ for (const cell of row.cells) {
2288
+ const span = cell.colSpan || 1
2289
+ col += 1
2290
+ for (let i = 0; i < span; i += 1) {
2291
+ if (columns.has(col + i)) {
2292
+ cell.classList.add('bp-col-num')
2293
+ break
2294
+ }
2295
+ }
2296
+ col += span - 1
2297
+ }
2298
+ }
2299
+ }
2300
+ if (table.tHead) stamp(table.tHead.rows)
2301
+ for (const body of table.tBodies) stamp(body.rows)
2302
+ if (table.tFoot) stamp(table.tFoot.rows)
2303
+ }
2304
+ }
2305
+
2306
+ if (typeof customElements !== 'undefined' && !customElements.get('bp-toc')) {
2307
+ customElements.define('bp-toc', BlueprintTocElement)
2308
+ }
2309
+
410
2310
  if (typeof customElements !== 'undefined' && !customElements.get('bp-callout')) {
411
2311
  customElements.define('bp-callout', BlueprintCalloutElement)
412
2312
  }
413
2313
 
2314
+ if (typeof customElements !== 'undefined' && !customElements.get('bp-mock')) {
2315
+ customElements.define('bp-mock', BlueprintMockElement)
2316
+ }
2317
+
2318
+ if (typeof customElements !== 'undefined' && !customElements.get('bp-gallery')) {
2319
+ customElements.define('bp-gallery', BlueprintGalleryElement)
2320
+ }
2321
+
2322
+ if (typeof customElements !== 'undefined' && !customElements.get('bp-table')) {
2323
+ customElements.define('bp-table', BlueprintTableElement)
2324
+ }
2325
+
2326
+ if (typeof customElements !== 'undefined') {
2327
+ for (const [name, ctor] of [
2328
+ ['bp-subheader', BlueprintSubheaderElement],
2329
+ ['bp-note', BlueprintNoteElement],
2330
+ ['bp-eyebrow', BlueprintEyebrowElement],
2331
+ ]) {
2332
+ if (!customElements.get(name)) customElements.define(name, ctor)
2333
+ }
2334
+ }
2335
+
2336
+ // ---------------------------------------------------------------------
2337
+ // <bp-workplan> — a live "work plan" surface.
2338
+ //
2339
+ // Visualizes a set of typed, dependent tasks that resolve into waves of
2340
+ // work, rendered four ways (list · gantt · kanban · swimlanes). It is a
2341
+ // frame client: it reads its plan from a `.plan` property, the global
2342
+ // `window.__WORKPLAN__`, or a `{ type: 'workplan:update', plan }`
2343
+ // postMessage — so a host frame drives it and the author writes no task
2344
+ // data. With no plan it paints a "waiting for a plan" state.
2345
+ //
2346
+ // <bp-workplan mode="gantt" header="…" subheader="…"></bp-workplan>
2347
+ //
2348
+ // mode: list|gantt|kanban|swimlanes pins a layout; omit it for the
2349
+ // built-in view selector. header/subheader override the plan's titles.
2350
+ // unit: hours|days sets the gantt axis. ignore-global opts out of the
2351
+ // window.__WORKPLAN__ source (postMessage only).
2352
+ // ---------------------------------------------------------------------
2353
+ ;(() => {
2354
+ const VIEW_OPTIONS = [
2355
+ ['list', 'List'],
2356
+ ['gantt', 'Gantt'],
2357
+ ['kanban', 'Kanban'],
2358
+ ['swimlanes', 'Swimlanes'],
2359
+ ]
2360
+
2361
+ // Waves + readiness + a longest-path schedule, all derived (not authored).
2362
+ function derive(plan) {
2363
+ const byId = new Map(plan.tasks.map((t) => [t.id, t]))
2364
+ const waveMemo = new Map()
2365
+ const startMemo = new Map()
2366
+
2367
+ const wave = (id, trail = new Set()) => {
2368
+ if (waveMemo.has(id)) return waveMemo.get(id)
2369
+ const deps = byId.get(id)?.dependsOn ?? []
2370
+ let w = 0
2371
+ for (const d of deps) {
2372
+ if (!byId.has(d) || trail.has(d)) continue
2373
+ w = Math.max(w, wave(d, new Set([...trail, id])) + 1)
2374
+ }
2375
+ waveMemo.set(id, w)
2376
+ return w
2377
+ }
2378
+
2379
+ const start = (id, trail = new Set()) => {
2380
+ if (startMemo.has(id)) return startMemo.get(id)
2381
+ const deps = byId.get(id)?.dependsOn ?? []
2382
+ let s = 0
2383
+ for (const d of deps) {
2384
+ const dep = byId.get(d)
2385
+ if (!dep || trail.has(d)) continue
2386
+ s = Math.max(s, start(d, new Set([...trail, id])) + (dep.effort ?? 1))
2387
+ }
2388
+ startMemo.set(id, s)
2389
+ return s
2390
+ }
2391
+
2392
+ return plan.tasks.map((t) => {
2393
+ const deps = t.dependsOn ?? []
2394
+ const blockedBy = deps.filter((d) => byId.get(d)?.status !== 'done')
2395
+ const ready = blockedBy.length === 0
2396
+ const s = start(t.id)
2397
+ return {
2398
+ ...t,
2399
+ wave: wave(t.id),
2400
+ ready,
2401
+ locked: !ready && t.status !== 'done',
2402
+ start: s,
2403
+ finish: s + (t.effort ?? 1),
2404
+ blockedBy,
2405
+ }
2406
+ })
2407
+ }
2408
+
2409
+ function phase(t) {
2410
+ if (t.status === 'done') return 'done'
2411
+ if (t.locked) return 'blocked'
2412
+ if (t.status === 'active') return 'active'
2413
+ return 'ready'
2414
+ }
2415
+
2416
+ const esc = (s) =>
2417
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
2418
+
2419
+ const TYPE_LABEL = {
2420
+ research: 'Research',
2421
+ write: 'Write',
2422
+ review: 'Review',
2423
+ design: 'Design',
2424
+ code: 'Code',
2425
+ }
2426
+
2427
+ const CI_LABEL = {
2428
+ running: 'Tests running',
2429
+ failed: 'CI failed',
2430
+ passed: 'CI passed',
2431
+ approved: 'Approved',
2432
+ }
2433
+
2434
+ const UNIT_SUFFIX = { hours: 'h', days: 'd' }
2435
+ const UNIT_AXIS = { hours: 'Hours', days: 'Days' }
2436
+
2437
+ // 16×16 glyphs, drawn in currentColor; UI chrome stays on the ink scale.
2438
+ const TYPE_ICON = {
2439
+ research: '<circle cx="7" cy="7" r="4.2"/><path d="M10 10l4 4"/>',
2440
+ write: '<path d="M3 13l8.5-8.5 2.5 2.5L5.5 15.5 2.5 16z"/><path d="M11 5l2.5 2.5"/>',
2441
+ review: '<path d="M2 8s2.4-4 6-4 6 4 6 4-2.4 4-6 4-6-4-6-4z"/><circle cx="8" cy="8" r="1.8"/>',
2442
+ design: '<path d="M8 2v12"/><path d="M8 2l4.5 10.5a5 5 0 01-9 0z"/>',
2443
+ code: '<path d="M6 4L2 8l4 4"/><path d="M10 4l4 4-4 4"/>',
2444
+ }
2445
+
2446
+ const CI_ICON = {
2447
+ running: '<circle cx="8" cy="8" r="5.4" stroke-dasharray="3 3"/>',
2448
+ failed: '<circle cx="8" cy="8" r="6"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5"/>',
2449
+ passed: '<circle cx="8" cy="8" r="6"/><path d="M5 8.2l2 2 4-4.4"/>',
2450
+ approved: '<path d="M3 8.4l2.2 2.2 4.4-4.8"/><path d="M7 8.4l2.2 2.2 4.4-4.8"/>',
2451
+ }
2452
+
2453
+ // 8-point spark — the mark for automated (agent) work.
2454
+ const AGENT_ICON = '<path d="M8 1.5v13M1.5 8h13M3.7 3.7l8.6 8.6M12.3 3.7l-8.6 8.6"/>'
2455
+
2456
+ const svgMark = (paths, cls) =>
2457
+ `<svg class="${cls}" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`
2458
+
2459
+ const typeBadge = (t) =>
2460
+ `<span class="wp-type wp-type--${t}">${svgMark(TYPE_ICON[t], 'wp-type__glyph')}<span class="wp-type__label">${TYPE_LABEL[t]}</span></span>`
2461
+
2462
+ const phaseDot = (p) => `<span class="wp-dot wp-dot--${p}" role="img" aria-label="${p}"></span>`
2463
+
2464
+ // Inline (no pill) PR status: glyph for running/failed, positive ink for
2465
+ // passed/approved, mono throughout. `compact` drops the diff stat.
2466
+ function prStatus(pr, compact = false) {
2467
+ const needsGlyph = pr.ci === 'running' || pr.ci === 'failed'
2468
+ const diff =
2469
+ !compact && pr.additions != null
2470
+ ? `<span class="wp-pr__diff"><span class="wp-pr__add">+${pr.additions}</span> <span class="wp-pr__del">\u2212${pr.deletions ?? 0}</span></span>`
2471
+ : ''
2472
+ return (
2473
+ `<span class="wp-pr wp-pr--${pr.ci}" title="${esc(`PR #${pr.number} — ${CI_LABEL[pr.ci]}`)}">` +
2474
+ (needsGlyph ? svgMark(CI_ICON[pr.ci], 'wp-pr__glyph') : '') +
2475
+ `<span class="wp-pr__num">#${pr.number}</span>` +
2476
+ `<span class="wp-pr__ci">${esc(CI_LABEL[pr.ci])}</span>` +
2477
+ diff +
2478
+ `</span>`
2479
+ )
2480
+ }
2481
+
2482
+ function initials(name) {
2483
+ const parts = name.trim().split(/\s+/)
2484
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
2485
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
2486
+ }
2487
+
2488
+ // Agent → spark mark (square); human → initials monogram (circle).
2489
+ function ownerMark(owner) {
2490
+ if (!owner) return ''
2491
+ if (owner.kind === 'agent') {
2492
+ return `<span class="wp-owner wp-owner--agent" title="${esc(owner.name)} (agent)">${svgMark(AGENT_ICON, 'wp-owner__glyph')}</span>`
2493
+ }
2494
+ return `<span class="wp-owner wp-owner--human" title="${esc(owner.name)}">${esc(initials(owner.name))}</span>`
2495
+ }
2496
+
2497
+ function waitLabel(t, byId) {
2498
+ const names = t.blockedBy.map((d) => byId.get(d)?.name ?? d)
2499
+ return `Waiting on ${names.join(', ')}`
2500
+ }
2501
+
2502
+ function waitNote(t, byId) {
2503
+ if (!t.locked) return ''
2504
+ const label = waitLabel(t, byId)
2505
+ return `<span class="wp-wait" title="${esc(label)}">${esc(label)}</span>`
2506
+ }
2507
+
2508
+ function groupByWave(tasks) {
2509
+ const map = new Map()
2510
+ for (const t of tasks) {
2511
+ const arr = map.get(t.wave) ?? []
2512
+ arr.push(t)
2513
+ map.set(t.wave, arr)
2514
+ }
2515
+ return [...map.entries()].sort((a, b) => a[0] - b[0])
2516
+ }
2517
+
2518
+ function renderList(tasks, byId) {
2519
+ const blocks = groupByWave(tasks)
2520
+ .map(
2521
+ ([w, items]) => `
2522
+ <div class="wp-wave">
2523
+ <div class="wp-wave__head">
2524
+ <span class="wp-wave__no">Wave ${w + 1}</span>
2525
+ <span class="wp-wave__count">${items.length} task${items.length === 1 ? '' : 's'}</span>
2526
+ </div>
2527
+ <div class="wp-list">
2528
+ ${items
2529
+ .map((t) => {
2530
+ const p = phase(t)
2531
+ return `
2532
+ <div class="wp-row wp-row--${p}" data-locked="${t.locked}">
2533
+ ${phaseDot(p)}
2534
+ ${typeBadge(t.type)}
2535
+ <span class="wp-row__main">
2536
+ <span class="wp-row__name">${esc(t.name)}</span>
2537
+ ${waitNote(t, byId)}
2538
+ </span>
2539
+ <span class="wp-row__pr">${t.pr ? prStatus(t.pr) : ''}</span>
2540
+ ${ownerMark(t.owner)}
2541
+ </div>`
2542
+ })
2543
+ .join('')}
2544
+ </div>
2545
+ </div>`
2546
+ )
2547
+ .join('')
2548
+ return `<div class="wp-view wp-view--list">${blocks}</div>`
2549
+ }
2550
+
2551
+ function renderKanban(tasks, byId) {
2552
+ const cols = [
2553
+ { key: 'blocked', title: 'Blocked' },
2554
+ { key: 'ready', title: 'Ready' },
2555
+ { key: 'active', title: 'In progress' },
2556
+ { key: 'done', title: 'Done' },
2557
+ ]
2558
+ const columns = cols
2559
+ .map(({ key, title }) => {
2560
+ const items = tasks.filter((t) => phase(t) === key)
2561
+ return `
2562
+ <div class="wp-col wp-col--${key}">
2563
+ <div class="wp-col__head">
2564
+ <span class="wp-col__title">${title}</span>
2565
+ <span class="wp-col__count">${items.length}</span>
2566
+ </div>
2567
+ <div class="wp-col__body">
2568
+ ${
2569
+ items.length === 0
2570
+ ? '<div class="wp-col__empty">No tasks</div>'
2571
+ : items
2572
+ .map(
2573
+ (t) => `
2574
+ <div class="wp-card wp-card--${key}" data-locked="${t.locked}"${t.locked ? ` title="${esc(waitLabel(t, byId))}"` : ''}>
2575
+ <div class="wp-card__top">
2576
+ ${typeBadge(t.type)}
2577
+ ${phaseDot(phase(t))}
2578
+ </div>
2579
+ <div class="wp-card__name">${esc(t.name)}</div>
2580
+ ${t.pr ? `<div class="wp-card__pr">${prStatus(t.pr, true)}</div>` : ''}
2581
+ <div class="wp-card__foot">
2582
+ <span class="wp-card__wave">Wave ${t.wave + 1}</span>
2583
+ ${ownerMark(t.owner)}
2584
+ </div>
2585
+ </div>`
2586
+ )
2587
+ .join('')
2588
+ }
2589
+ </div>
2590
+ </div>`
2591
+ })
2592
+ .join('')
2593
+ return `<div class="wp-view wp-view--kanban">${columns}</div>`
2594
+ }
2595
+
2596
+ function renderSwimlanes(tasks) {
2597
+ const order = ['research', 'design', 'write', 'code', 'review']
2598
+ const maxWave = Math.max(0, ...tasks.map((t) => t.wave))
2599
+ const header =
2600
+ `<div class="wp-swim__corner">Type \u00d7 wave</div>` +
2601
+ Array.from({ length: maxWave + 1 }, (_, w) => `<div class="wp-swim__whead">Wave ${w + 1}</div>`).join('')
2602
+ const lanes = order
2603
+ .filter((type) => tasks.some((t) => t.type === type))
2604
+ .map((type) => {
2605
+ const cells = Array.from({ length: maxWave + 1 }, (_, w) => {
2606
+ const items = tasks.filter((t) => t.type === type && t.wave === w)
2607
+ return `<div class="wp-swim__cell">${items
2608
+ .map((t) => {
2609
+ const p = phase(t)
2610
+ return `<div class="wp-chip wp-chip--${p}" data-locked="${t.locked}" title="${esc(t.name)}">
2611
+ <div class="wp-chip__head">
2612
+ ${phaseDot(p)}
2613
+ <span class="wp-chip__name">${esc(t.name)}</span>
2614
+ ${ownerMark(t.owner)}
2615
+ </div>
2616
+ ${t.pr ? `<div class="wp-chip__pr">${prStatus(t.pr, true)}</div>` : ''}
2617
+ </div>`
2618
+ })
2619
+ .join('')}</div>`
2620
+ }).join('')
2621
+ return `<div class="wp-swim__lanehead">${typeBadge(type)}</div>${cells}`
2622
+ })
2623
+ .join('')
2624
+ return `<div class="wp-view wp-view--swim" style="--wp-waves:${maxWave + 1}">${header}${lanes}</div>`
2625
+ }
2626
+
2627
+ function renderGantt(tasks, unit) {
2628
+ const span = Math.max(1, ...tasks.map((t) => t.finish))
2629
+ const suffix = UNIT_SUFFIX[unit]
2630
+ const axis = Array.from(
2631
+ { length: span + 1 },
2632
+ (_, d) => `<span class="wp-gantt__tick" style="--at:${d}">${d}</span>`
2633
+ ).join('')
2634
+ const rows = [...tasks]
2635
+ .sort((a, b) => a.start - b.start || a.wave - b.wave)
2636
+ .map((t) => {
2637
+ const p = phase(t)
2638
+ const pct = (n) => (n / span) * 100
2639
+ return `
2640
+ <div class="wp-gantt__row wp-gantt__row--${p}" data-locked="${t.locked}">
2641
+ <div class="wp-gantt__label">
2642
+ ${typeBadge(t.type)}
2643
+ <span class="wp-gantt__name">${esc(t.name)}</span>
2644
+ ${ownerMark(t.owner)}
2645
+ </div>
2646
+ <div class="wp-gantt__track">
2647
+ <div class="wp-gantt__bar wp-gantt__bar--${p}" style="left:${pct(t.start)}%;width:${pct(t.finish - t.start)}%">
2648
+ ${phaseDot(p)}
2649
+ <span class="wp-gantt__bar-label">${t.pr ? `#${t.pr.number}` : `${t.effort ?? 1}${suffix}`}</span>
2650
+ </div>
2651
+ </div>
2652
+ </div>`
2653
+ })
2654
+ .join('')
2655
+ return `<div class="wp-view wp-view--gantt">
2656
+ <div class="wp-gantt__row wp-gantt__row--axis">
2657
+ <div class="wp-gantt__label wp-gantt__label--axis">${UNIT_AXIS[unit]} \u2192</div>
2658
+ <div class="wp-gantt__track wp-gantt__track--axis" style="--span:${span}">${axis}</div>
2659
+ </div>
2660
+ ${rows}
2661
+ </div>`
2662
+ }
2663
+
2664
+ function summarize(tasks) {
2665
+ return {
2666
+ total: tasks.length,
2667
+ done: tasks.filter((t) => t.status === 'done').length,
2668
+ active: tasks.filter((t) => phase(t) === 'active').length,
2669
+ blocked: tasks.filter((t) => t.locked).length,
2670
+ waves: new Set(tasks.map((t) => t.wave)).size,
2671
+ }
2672
+ }
2673
+
2674
+ function renderEmpty() {
2675
+ return `<div class="wp-empty" role="status">
2676
+ <svg viewBox="0 0 48 48" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="wp-empty__art">
2677
+ <rect x="7" y="9" width="34" height="30" rx="2"/>
2678
+ <path d="M7 17h34"/>
2679
+ <path d="M13 24h10M13 30h16" stroke-dasharray="2 3"/>
2680
+ <circle cx="33" cy="29" r="6"/>
2681
+ <path d="M33 26v3l2 2"/>
2682
+ </svg>
2683
+ <div class="wp-empty__title">Waiting for a plan</div>
2684
+ <div class="wp-empty__hint">No work plan has been received yet. The host frame will post one over <span class="wp-mono">postMessage</span>.</div>
2685
+ </div>`
2686
+ }
2687
+
2688
+ const MESSAGE_TYPE = 'workplan:update'
2689
+ const GLOBAL_KEY = '__WORKPLAN__'
2690
+ const MODES = new Set(['list', 'gantt', 'kanban', 'swimlanes'])
2691
+
2692
+ class BlueprintWorkplanElement extends HTMLElement {
2693
+ static get observedAttributes() {
2694
+ return ['mode', 'unit', 'header', 'subheader']
2695
+ }
2696
+
2697
+ #plan = null
2698
+ #root = null
2699
+ #activeView = 'list'
2700
+ #onMessage = (e) => this.#handleMessage(e)
2701
+ #onClick = (e) => this.#handleClick(e)
2702
+
2703
+ set plan(value) {
2704
+ this.#plan = value
2705
+ this.#render()
2706
+ }
2707
+ get plan() {
2708
+ return this.#plan
2709
+ }
2710
+
2711
+ get mode() {
2712
+ const m = this.getAttribute('mode')
2713
+ return MODES.has(m) ? m : null
2714
+ }
2715
+
2716
+ get unit() {
2717
+ return this.getAttribute('unit') === 'days' ? 'days' : 'hours'
2718
+ }
2719
+
2720
+ connectedCallback() {
2721
+ if (!this.#root) {
2722
+ this.#root = this.ownerDocument.createElement('div')
2723
+ this.#root.className = 'bp-workplan'
2724
+ this.#root.addEventListener('click', this.#onClick)
2725
+ this.replaceChildren(this.#root)
2726
+ }
2727
+ if (!this.#plan && !this.hasAttribute('ignore-global')) {
2728
+ const fromGlobal = this.ownerDocument.defaultView?.[GLOBAL_KEY]
2729
+ if (fromGlobal) this.#plan = fromGlobal
2730
+ }
2731
+ this.ownerDocument.defaultView?.addEventListener('message', this.#onMessage)
2732
+ this.#render()
2733
+ }
2734
+
2735
+ disconnectedCallback() {
2736
+ this.ownerDocument.defaultView?.removeEventListener('message', this.#onMessage)
2737
+ }
2738
+
2739
+ attributeChangedCallback() {
2740
+ this.#render()
2741
+ }
2742
+
2743
+ #handleMessage(e) {
2744
+ const data = e.data
2745
+ if (!data || data.type !== MESSAGE_TYPE || !data.plan) return
2746
+ this.#plan = data.plan
2747
+ this.#render()
2748
+ }
2749
+
2750
+ // Delegated: the toolbar's buttons live in re-rendered markup, so we
2751
+ // listen on the persistent root and read intent off data-attributes.
2752
+ #handleClick(e) {
2753
+ const target = e.target
2754
+ const btn = target instanceof Element ? target.closest('[data-wp-view],[data-wp-unit]') : null
2755
+ if (!btn || !this.#root?.contains(btn)) return
2756
+ const view = btn.getAttribute('data-wp-view')
2757
+ if (view) {
2758
+ this.#activeView = view
2759
+ this.#render()
2760
+ return
2761
+ }
2762
+ const unit = btn.getAttribute('data-wp-unit')
2763
+ if (unit) this.setAttribute('unit', unit)
2764
+ }
2765
+
2766
+ get #view() {
2767
+ return this.mode ?? this.#activeView
2768
+ }
2769
+
2770
+ #toolbar(view, showModes) {
2771
+ const modes = showModes
2772
+ ? `<div class="wp-modes" role="tablist" aria-label="Choose a view">` +
2773
+ VIEW_OPTIONS.map(
2774
+ ([v, label]) =>
2775
+ `<button class="wp-modes__btn" type="button" role="tab" aria-selected="${v === view}" data-wp-view="${v}">${label}</button>`
2776
+ ).join('') +
2777
+ `</div>`
2778
+ : ''
2779
+ const unit =
2780
+ view === 'gantt'
2781
+ ? `<div class="wp-modes wp-modes--unit" role="group" aria-label="Time unit">` +
2782
+ ['hours', 'days']
2783
+ .map(
2784
+ (u) =>
2785
+ `<button class="wp-modes__btn" type="button" aria-selected="${u === this.unit}" data-wp-unit="${u}">${UNIT_AXIS[u]}</button>`
2786
+ )
2787
+ .join('') +
2788
+ `</div>`
2789
+ : ''
2790
+ return modes || unit ? `<div class="wp-toolbar">${modes}${unit}</div>` : ''
2791
+ }
2792
+
2793
+ #render() {
2794
+ if (!this.#root) return
2795
+ const plan = this.#plan
2796
+ const hasData = !!plan && Array.isArray(plan.tasks) && plan.tasks.length > 0
2797
+ const view = this.#view
2798
+ const showModes = this.mode === null
2799
+
2800
+ const title = this.getAttribute('header') ?? plan?.title ?? ''
2801
+ const subtitle = this.getAttribute('subheader') ?? plan?.subtitle ?? ''
2802
+
2803
+ let stats = ''
2804
+ let body = ''
2805
+ if (hasData) {
2806
+ this.#root.dataset.state = 'ready'
2807
+ const derived = derive(plan)
2808
+ const byId = new Map(plan.tasks.map((t) => [t.id, t]))
2809
+ const counts = summarize(derived)
2810
+ stats =
2811
+ `<div class="wp-stats">` +
2812
+ `<div class="wp-stat"><span class="wp-stat__k">Done</span><span class="wp-stat__v">${counts.done}/${counts.total}</span></div>` +
2813
+ `<div class="wp-stat"><span class="wp-stat__k">Active</span><span class="wp-stat__v">${counts.active}</span></div>` +
2814
+ `<div class="wp-stat"><span class="wp-stat__k">Blocked</span><span class="wp-stat__v">${counts.blocked}</span></div>` +
2815
+ `<div class="wp-stat"><span class="wp-stat__k">Waves</span><span class="wp-stat__v">${counts.waves}</span></div>` +
2816
+ `</div>`
2817
+ body =
2818
+ view === 'gantt'
2819
+ ? renderGantt(derived, this.unit)
2820
+ : view === 'kanban'
2821
+ ? renderKanban(derived, byId)
2822
+ : view === 'swimlanes'
2823
+ ? renderSwimlanes(derived)
2824
+ : renderList(derived, byId)
2825
+ } else {
2826
+ this.#root.dataset.state = 'empty'
2827
+ body = renderEmpty()
2828
+ }
2829
+
2830
+ const head =
2831
+ title || subtitle || stats
2832
+ ? `<div class="wp-head">` +
2833
+ `<div class="wp-head__title">` +
2834
+ (title ? `<div class="wp-head__name">${esc(title)}</div>` : '') +
2835
+ (subtitle ? `<div class="wp-head__sub">${esc(subtitle)}</div>` : '') +
2836
+ `</div>` +
2837
+ stats +
2838
+ `</div>`
2839
+ : ''
2840
+
2841
+ const toolbar = hasData ? this.#toolbar(view, showModes) : ''
2842
+
2843
+ this.#root.innerHTML = head + toolbar + `<div class="wp-canvas">${body}</div>`
2844
+ }
2845
+ }
2846
+
2847
+ if (typeof customElements !== 'undefined' && !customElements.get('bp-workplan')) {
2848
+ customElements.define('bp-workplan', BlueprintWorkplanElement)
2849
+ }
2850
+ })()
2851
+
414
2852
  if (typeof document !== 'undefined') {
415
2853
  if (document.readyState === 'loading') {
416
2854
  document.addEventListener('DOMContentLoaded', () => initializeBlueprintRuntime(), { once: true })