@obvi/blueprint 1.0.9

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.
@@ -0,0 +1,416 @@
1
+ // Optional behavior for TOC current state and reading progress.
2
+ export function initializeBlueprintRuntime(doc = document, win = window) {
3
+ const root = doc.documentElement
4
+ if (root.dataset.blueprintRuntime === 'ready') return
5
+ root.dataset.blueprintRuntime = 'ready'
6
+
7
+ wireSectionHeadingLinks(doc)
8
+ wireFigureDiagramSize(doc)
9
+
10
+ // The shell becomes the scroll container only while it is fixed (wide
11
+ // layout). At ≤700px the CSS reverts it to static and the window scrolls, so
12
+ // resolve the scroller live rather than caching it — a wide→narrow resize
13
+ // otherwise leaves the scroll listener bound to an element that no longer
14
+ // scrolls, silently freezing reading-progress and TOC highlighting.
15
+ const shell = doc.querySelector('.bp-shell')
16
+ const resolveScroller = () =>
17
+ shell && win.getComputedStyle(shell).position === 'fixed' ? shell : null
18
+ let shellScroller = resolveScroller()
19
+ const scrollTop = () => (shellScroller ? shellScroller.scrollTop : win.scrollY)
20
+ const scrollHeight = () =>
21
+ shellScroller ? shellScroller.scrollHeight : root.scrollHeight
22
+ const clientHeight = () =>
23
+ shellScroller ? shellScroller.clientHeight : win.innerHeight
24
+
25
+ const progress = doc.querySelector('.scroll-progress')
26
+ // Only the fixed rails track an active entry. A full-width .bp-contents
27
+ // index (typically parked at the top of a document) has no "current" entry,
28
+ // so it stays a static index and is intentionally excluded here.
29
+ const entries = [...doc.querySelectorAll('.bp-sidebar a[href^="#"], .bp-toc a[href^="#"], .sidebar a[href^="#"]')]
30
+ .map((link) => ({
31
+ link,
32
+ section: doc.getElementById(decodeURIComponent(link.hash.slice(1))),
33
+ }))
34
+ .filter(({ section }) => section)
35
+ // Unique target sections in DOCUMENT order — independent of the order links
36
+ // appear across a sidebar and one or more inline .bp-contents blocks.
37
+ const sections = [...new Set(entries.map(({ section }) => section))].sort((a, b) =>
38
+ a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
39
+ )
40
+ let scheduled = false
41
+ let scrollingTo = null
42
+
43
+ // Position each contents list's gliding marker + read-so-far fill to the
44
+ // active entry. Pure progressive enhancement: with JS off the aria-current
45
+ // rail still marks the active section.
46
+ const placeMarker = (link) => {
47
+ const container = link.closest('.bp-sidebar, .bp-toc, .sidebar')
48
+ // Top-level list is normally a direct child, but may sit inside a wrapper
49
+ // (e.g. a sliding panel); fall back to the first list in the container.
50
+ const list = container?.querySelector(':scope > ul') ?? container?.querySelector('ul')
51
+ if (!list) return
52
+ list.style.setProperty('--bp-toc-y', `${link.offsetTop}px`)
53
+ list.style.setProperty('--bp-toc-h', `${link.offsetHeight}px`)
54
+ }
55
+
56
+ // Mark every entry that points at the active section, so multiple fixed
57
+ // rails on a page stay in sync, each showing exactly one current link.
58
+ const setCurrent = (section) => {
59
+ for (const item of entries) {
60
+ if (section && item.section === section) {
61
+ item.link.setAttribute('aria-current', 'location')
62
+ placeMarker(item.link)
63
+ } else {
64
+ item.link.removeAttribute('aria-current')
65
+ }
66
+ }
67
+ }
68
+ const update = () => {
69
+ scheduled = false
70
+ if (progress) {
71
+ const max = scrollHeight() - clientHeight()
72
+ progress.style.transform = `scaleX(${max > 0 ? scrollTop() / max : 0})`
73
+ }
74
+ if (sections.length === 0) return
75
+ if (scrollingTo) {
76
+ const top = scrollingTo.getBoundingClientRect().top
77
+ if (top <= win.innerHeight * 0.35 && top >= -8) scrollingTo = null
78
+ else {
79
+ setCurrent(scrollingTo)
80
+ return
81
+ }
82
+ }
83
+ if (scrollTop() + clientHeight() >= scrollHeight() - 2) {
84
+ setCurrent(sections.at(-1))
85
+ return
86
+ }
87
+ const threshold = win.innerHeight * 0.35
88
+ setCurrent(
89
+ sections.reduce(
90
+ (current, section) => (section.getBoundingClientRect().top <= threshold ? section : current),
91
+ sections[0]
92
+ )
93
+ )
94
+ }
95
+ const scheduleUpdate = () => {
96
+ if (scheduled) return
97
+ scheduled = true
98
+ win.requestAnimationFrame(update)
99
+ }
100
+ const updateFromHash = () => {
101
+ const match = entries.find(({ link }) => link.hash === win.location.hash)
102
+ if (match) {
103
+ scrollingTo = match.section
104
+ setCurrent(match.section)
105
+ return
106
+ }
107
+ scrollingTo = null
108
+ scheduleUpdate()
109
+ }
110
+
111
+ const navLinkSelector =
112
+ '.bp-sidebar a[href^="#"], .bp-toc a[href^="#"], .sidebar a[href^="#"], .bp-contents a[href^="#"]'
113
+ const prefersReducedMotion = () => win.matchMedia('(prefers-reduced-motion: reduce)').matches
114
+
115
+ const scrollToSection = (section) => {
116
+ scrollingTo = section
117
+ section.scrollIntoView({
118
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
119
+ block: 'start',
120
+ })
121
+ const finish = () => {
122
+ if (scrollingTo === section) scrollingTo = null
123
+ }
124
+ if ('onscrollend' in win) {
125
+ const scrollTarget = shellScroller ?? win
126
+ scrollTarget.addEventListener('scrollend', finish, { once: true })
127
+ } else {
128
+ win.setTimeout(finish, prefersReducedMotion() ? 0 : 800)
129
+ }
130
+ scheduleUpdate()
131
+ }
132
+
133
+ doc.addEventListener('click', (event) => {
134
+ const link = event.target.closest?.(navLinkSelector)
135
+ if (!link?.hash) return
136
+ if (
137
+ event.defaultPrevented ||
138
+ event.button !== 0 ||
139
+ event.metaKey ||
140
+ event.ctrlKey ||
141
+ event.shiftKey ||
142
+ event.altKey
143
+ ) {
144
+ return
145
+ }
146
+ const section = doc.getElementById(decodeURIComponent(link.hash.slice(1)))
147
+ if (!section) return
148
+ event.preventDefault()
149
+ if (win.location.hash !== link.hash) win.history.pushState(null, '', link.hash)
150
+ if (entries.some(({ section: target }) => target === section)) setCurrent(section)
151
+ scrollToSection(section)
152
+ })
153
+
154
+ // Keep the scroll listener on whichever element currently scrolls, moving it
155
+ // when a resize flips the shell between fixed and static.
156
+ let scrollSource = shellScroller ?? win
157
+ scrollSource.addEventListener('scroll', scheduleUpdate, { passive: true })
158
+ const syncScrollSource = () => {
159
+ shellScroller = resolveScroller()
160
+ const nextSource = shellScroller ?? win
161
+ if (nextSource !== scrollSource) {
162
+ scrollSource.removeEventListener('scroll', scheduleUpdate, { passive: true })
163
+ nextSource.addEventListener('scroll', scheduleUpdate, { passive: true })
164
+ scrollSource = nextSource
165
+ }
166
+ }
167
+
168
+ win.addEventListener('hashchange', updateFromHash)
169
+ win.addEventListener('popstate', updateFromHash)
170
+ win.addEventListener('resize', () => {
171
+ syncScrollSource()
172
+ scheduleUpdate()
173
+ }, { passive: true })
174
+ updateFromHash()
175
+ }
176
+
177
+ // Section heading permalink — pixelarticons in the main-column gutter; link on
178
+ // heading hover, copy on icon hover, check after a successful copy.
179
+ const HEADING_LINK_ICON =
180
+ 'M4 6h7v2H4zm0 10h7v2H4zM2 8h2v8H2zm18-2h-7v2h7zm0 10h-7v2h7zm2-8h-2v8h2zM7 11h10v2H7z'
181
+ const HEADING_COPY_ICON =
182
+ 'M8 6h12v2H8zM4 2h12v2H4zm2 6h2v12H6zM2 4h2v12H2zm6 16h12v2H8zM20 8h2v12h-2zm-4-4h2v2h-2zM4 16h2v2H4z'
183
+ const HEADING_CHECK_ICON =
184
+ 'M10 18H8v-2h2v2Zm-2-2H6v-2h2v2Zm4-2v2h-2v-2h2Zm-6 0H4v-2h2v2Zm8 0h-2v-2h2v2Zm2-2h-2v-2h2v2Zm2-2h-2V8h2v2Zm2-2h-2V6h2v2Z'
185
+ const headingLinkTimers = new WeakMap()
186
+
187
+ function createHeadingPixelIcon(doc, name, pathD) {
188
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
189
+ svg.setAttribute('viewBox', '0 0 24 24')
190
+ svg.setAttribute('width', '16')
191
+ svg.setAttribute('height', '16')
192
+ svg.setAttribute('fill', 'currentColor')
193
+ svg.setAttribute('aria-hidden', 'true')
194
+ svg.classList.add('bp-heading-link__icon', `bp-heading-link__icon--${name}`)
195
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
196
+ path.setAttribute('d', pathD)
197
+ svg.append(path)
198
+ return svg
199
+ }
200
+
201
+ function createHeadingLinkButton(doc, label) {
202
+ const button = doc.createElement('button')
203
+ button.type = 'button'
204
+ button.className = 'bp-heading-link'
205
+ button.setAttribute('aria-label', `Copy link to ${label}`)
206
+
207
+ const icons = doc.createElement('span')
208
+ icons.className = 'bp-heading-link__icons'
209
+ icons.append(
210
+ createHeadingPixelIcon(doc, 'link', HEADING_LINK_ICON),
211
+ createHeadingPixelIcon(doc, 'copy', HEADING_COPY_ICON),
212
+ createHeadingPixelIcon(doc, 'check', HEADING_CHECK_ICON)
213
+ )
214
+ button.append(icons)
215
+ return button
216
+ }
217
+
218
+ async function copySectionUrl(url) {
219
+ if (!globalThis.isSecureContext || !navigator.clipboard?.writeText) {
220
+ throw new Error('The Clipboard API requires a secure context')
221
+ }
222
+ await navigator.clipboard.writeText(url)
223
+ }
224
+
225
+ function showHeadingLinkState(button, state, label) {
226
+ const previousTimer = headingLinkTimers.get(button)
227
+ if (previousTimer) clearTimeout(previousTimer)
228
+
229
+ const heading = button.closest('h2')
230
+
231
+ if (state === 'idle') {
232
+ delete button.dataset.bpCopyState
233
+ button.setAttribute('aria-label', `Copy link to ${label}`)
234
+ headingLinkTimers.delete(button)
235
+ if (heading && !heading.matches(':hover') && !heading.matches(':focus-within')) {
236
+ delete heading.dataset.headingLink
237
+ }
238
+ return
239
+ }
240
+
241
+ button.dataset.bpCopyState = state
242
+ button.setAttribute(
243
+ 'aria-label',
244
+ state === 'copied' ? 'Link copied' : 'Copy failed'
245
+ )
246
+ if (heading) heading.dataset.headingLink = 'visible'
247
+
248
+ headingLinkTimers.set(
249
+ button,
250
+ setTimeout(() => showHeadingLinkState(button, 'idle', label), 1800)
251
+ )
252
+ }
253
+
254
+ function wireFigureDiagramSize(doc) {
255
+ for (const svg of doc.querySelectorAll('figure svg[viewBox]')) {
256
+ const { width } = svg.viewBox.baseVal
257
+ if (width > 0) svg.style.setProperty('--bp-diagram-w', `${width}px`)
258
+ }
259
+ }
260
+
261
+ function wireSectionHeadingLinks(doc, win = doc.defaultView) {
262
+ for (const section of doc.querySelectorAll('section[id]')) {
263
+ const heading =
264
+ section.querySelector(':scope > h2:first-child') ??
265
+ section.querySelector(':scope > hgroup:first-child > h2')
266
+ if (!heading || heading.querySelector(':scope > .bp-heading-row')) continue
267
+
268
+ const label = heading.textContent.trim()
269
+ heading.replaceChildren()
270
+
271
+ const row = doc.createElement('span')
272
+ row.className = 'bp-heading-row'
273
+
274
+ const button = createHeadingLinkButton(doc, label)
275
+
276
+ const title = doc.createElement('span')
277
+ title.className = 'bp-heading-title'
278
+ title.textContent = label
279
+
280
+ row.append(button, title)
281
+ heading.append(row)
282
+
283
+ let hideTimer = 0
284
+ const showLink = () => {
285
+ win.clearTimeout(hideTimer)
286
+ heading.dataset.headingLink = 'visible'
287
+ }
288
+ const scheduleHide = (event) => {
289
+ const next = event?.relatedTarget
290
+ if (next instanceof Node && heading.contains(next)) return
291
+ hideTimer = win.setTimeout(() => {
292
+ if (button.dataset.bpCopyState) return
293
+ if (heading.matches(':hover') || button.matches(':hover')) return
294
+ delete heading.dataset.headingLink
295
+ }, 120)
296
+ }
297
+
298
+ heading.addEventListener('mouseenter', showLink)
299
+ heading.addEventListener('mouseleave', scheduleHide)
300
+ button.addEventListener('mouseenter', showLink)
301
+ button.addEventListener('mouseleave', scheduleHide)
302
+ heading.addEventListener('focusin', showLink)
303
+ heading.addEventListener('focusout', (event) => {
304
+ const next = event.relatedTarget
305
+ if (next instanceof Node && heading.contains(next)) return
306
+ if (!button.dataset.bpCopyState) scheduleHide(event)
307
+ })
308
+
309
+ button.addEventListener('click', (event) => {
310
+ event.preventDefault()
311
+ const url = new URL(`#${section.id}`, win?.location.href ?? '').href
312
+ copySectionUrl(url)
313
+ .then(() => showHeadingLinkState(button, 'copied', label))
314
+ .catch(() => showHeadingLinkState(button, 'error', label))
315
+ })
316
+ }
317
+ }
318
+
319
+ // ---------------------------------------------------------------------
320
+ // <bp-callout> — typed callout (drafting corner-tick family).
321
+ //
322
+ // Authoring shrinks to a single element; the component expands to the
323
+ // .bp-callout structure (corner ticks come from blueprint.css) with the
324
+ // matching type icon and label. Light DOM, so the stylesheet applies
325
+ // directly and the raw .bp-callout markup stays equally valid.
326
+ //
327
+ // <bp-callout type="invariant">
328
+ // The fence token is monotonic and never reused.
329
+ // </bp-callout>
330
+ //
331
+ // type: locked | invariant | ref (alias: reference). Default: locked.
332
+ // label: overrides the caption text. icon="none" drops the glyph.
333
+ // ---------------------------------------------------------------------
334
+ const CALLOUT_ICONS = {
335
+ locked:
336
+ 'M5 8h14v2H5zm0 12h14v2H5zM3 10h2v10H3zm16 0h2v10h-2zM7 4h2v4H7zm2-2h6v2H9zm6 2h2v4h-2z',
337
+ invariant:
338
+ 'M4 2h16v2H4zM2 4h2v10H2zm18 0h2v10h-2zM4 14h2v2H4zm2 2h2v2H6zm4 4h4v2h-4zm10-6h-2v2h2zm-2 2h-2v2h2zm-2 2h-2v2h2zm-6 0H8v2h2z',
339
+ ref:
340
+ 'M4 2h16v2H4zm2 5h2v2H6zm4 0h8v2h-8zm-4 4h2v2H6zm4 0h8v2h-8zm-4 4h2v2H6zm4 0h8v2h-8zm-6 5h16v2H4zM2 4h2v16H2zm18 0h2v16h-2z',
341
+ }
342
+ const CALLOUT_TYPES = {
343
+ locked: { mod: 'locked', label: 'Locked', icon: 'locked' },
344
+ invariant: { mod: 'invariant', label: 'Invariant', icon: 'invariant' },
345
+ ref: { mod: 'ref', label: 'Reference', icon: 'ref' },
346
+ reference: { mod: 'ref', label: 'Reference', icon: 'ref' },
347
+ }
348
+ // Author content that already carries its own block boxes passes straight
349
+ // through; anything else (bare text, inline runs) gets wrapped in a <p>.
350
+ const CALLOUT_BLOCK = new Set([
351
+ 'P', 'UL', 'OL', 'DL', 'DIV', 'PRE', 'BLOCKQUOTE', 'TABLE', 'FIGURE',
352
+ 'SECTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BP-SOURCE', 'BP-CITE',
353
+ ])
354
+
355
+ function createCalloutIcon(doc, pathD) {
356
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
357
+ svg.setAttribute('viewBox', '0 0 24 24')
358
+ svg.setAttribute('fill', 'currentColor')
359
+ svg.setAttribute('aria-hidden', 'true')
360
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
361
+ path.setAttribute('d', pathD)
362
+ svg.append(path)
363
+ return svg
364
+ }
365
+
366
+ class BlueprintCalloutElement extends HTMLElement {
367
+ #rendered = false
368
+
369
+ connectedCallback() {
370
+ if (this.#rendered) return
371
+ this.#rendered = true
372
+
373
+ const doc = this.ownerDocument
374
+ const key = (this.getAttribute('type') || 'locked').toLowerCase()
375
+ const spec = CALLOUT_TYPES[key] ?? CALLOUT_TYPES.locked
376
+ const label = this.getAttribute('label') ?? spec.label
377
+
378
+ const aside = doc.createElement('aside')
379
+ aside.className = `bp-callout bp-callout--${spec.mod}`
380
+
381
+ const tag = doc.createElement('span')
382
+ tag.className = 'bp-ctag'
383
+ const iconPath = CALLOUT_ICONS[spec.icon]
384
+ if (iconPath && this.getAttribute('icon') !== 'none') {
385
+ tag.append(createCalloutIcon(doc, iconPath))
386
+ }
387
+ if (label) tag.append(doc.createTextNode(label))
388
+ aside.append(tag)
389
+
390
+ const body = [...this.childNodes]
391
+ const hasBlock = body.some(
392
+ (node) => node.nodeType === 1 && CALLOUT_BLOCK.has(node.tagName)
393
+ )
394
+ if (hasBlock) {
395
+ aside.append(...body)
396
+ } else {
397
+ const p = doc.createElement('p')
398
+ p.append(...body)
399
+ if (p.childNodes.length) aside.append(p)
400
+ }
401
+
402
+ this.replaceChildren(aside)
403
+ }
404
+ }
405
+
406
+ if (typeof customElements !== 'undefined' && !customElements.get('bp-callout')) {
407
+ customElements.define('bp-callout', BlueprintCalloutElement)
408
+ }
409
+
410
+ if (typeof document !== 'undefined') {
411
+ if (document.readyState === 'loading') {
412
+ document.addEventListener('DOMContentLoaded', () => initializeBlueprintRuntime(), { once: true })
413
+ } else {
414
+ initializeBlueprintRuntime()
415
+ }
416
+ }