@obvi/blueprint 1.0.10 → 1.1.0

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