@uniweb/kit 0.9.32 → 0.9.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.32",
3
+ "version": "0.9.34",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,7 +39,7 @@
39
39
  "fuse.js": "^7.0.0",
40
40
  "shiki": "^3.0.0",
41
41
  "tailwind-merge": "^3.6.0",
42
- "@uniweb/core": "0.7.25",
42
+ "@uniweb/core": "0.7.27",
43
43
  "@uniweb/scene": "0.1.2"
44
44
  },
45
45
  "peerDependencies": {
@@ -9,6 +9,7 @@ export { useVersion } from './useVersion.js'
9
9
  export { useScrolled } from './useScrolled.js'
10
10
  export { useMobileMenu } from './useMobileMenu.js'
11
11
  export { useAccordion } from './useAccordion.js'
12
+ export { useHeadings } from './useHeadings.js'
12
13
  export { useGridLayout, getGridClasses } from './useGridLayout.js'
13
14
  export { useTheme, getThemeClasses, THEMES, THEME_NAMES } from './useTheme.js'
14
15
  export { useInView, useIsInView } from './useInView.js'
@@ -0,0 +1,201 @@
1
+ /**
2
+ * useHeadings Hook
3
+ *
4
+ * The headings of the page being read, plus which one the reader is level with.
5
+ * What a table of contents needs, without any opinion about how it looks.
6
+ *
7
+ * Every documentation shell on this framework has hand-written this, and each
8
+ * one wrote it the only way a foundation can: scan the DOM for `h2, h3` after
9
+ * the body renders. That works, but it costs a frame of timing guesswork and it
10
+ * cannot run during prerender, so the rail is missing from every served page
11
+ * until hydration.
12
+ *
13
+ * The framework can do better because it owns both ends. It renders the
14
+ * headings and stamps their ids (`headingId`), and it holds the page content
15
+ * that produced them — so the list can come from the content, before anything
16
+ * is painted, and be guaranteed to match the anchors. Only the highlight needs
17
+ * the DOM, because only scrolling does.
18
+ *
19
+ * @example
20
+ * function PageContents() {
21
+ * const { headings, activeId, scrollTo } = useHeadings()
22
+ * if (!headings.length) return null
23
+ *
24
+ * return (
25
+ * <nav aria-label="On this page">
26
+ * {headings.map(h => (
27
+ * <button key={h.id} onClick={() => scrollTo(h.id)}
28
+ * className={h.id === activeId ? 'text-primary' : 'text-subtle'}>
29
+ * {h.text}
30
+ * </button>
31
+ * ))}
32
+ * </nav>
33
+ * )
34
+ * }
35
+ */
36
+
37
+ import { useCallback, useEffect, useMemo, useState } from 'react'
38
+ import { headingId, nodeText } from '../utils/index.js'
39
+ import { useWebsite } from './useWebsite.js'
40
+ import { useActiveRoute } from './useActiveRoute.js'
41
+
42
+ /**
43
+ * Pull headings out of the page's own content — available during prerender.
44
+ */
45
+ function headingsFromContent(page, levels) {
46
+ const blocks = page?.getBodyBlocks?.() ?? []
47
+ const found = []
48
+
49
+ for (const block of blocks) {
50
+ const nodes = block?.rawContent?.content
51
+ if (!Array.isArray(nodes)) continue
52
+
53
+ for (const node of nodes) {
54
+ if (node?.type !== 'heading') continue
55
+ const level = node.attrs?.level ?? 1
56
+ if (!levels.includes(level)) continue
57
+
58
+ const text = nodeText(node).trim()
59
+ if (text) found.push({ id: headingId(text), text, level })
60
+ }
61
+ }
62
+
63
+ return found
64
+ }
65
+
66
+ /**
67
+ * Fall back to the rendered document, for content this hook cannot see — a
68
+ * foundation rendering its own markup, or anything not built from page blocks.
69
+ */
70
+ function headingsFromDom(root, levels) {
71
+ if (typeof document === 'undefined') return []
72
+
73
+ const scope = document.querySelector(root)
74
+ if (!scope) return []
75
+
76
+ const selector = levels.map(level => `h${level}`).join(', ')
77
+
78
+ return [...scope.querySelectorAll(selector)]
79
+ .map(el => {
80
+ const text = el.textContent?.trim() || ''
81
+ if (!text) return null
82
+ // Adopt the id that is there; stamp the shared one when it is missing, so
83
+ // scrollTo has something to find either way.
84
+ if (!el.id) el.id = headingId(text)
85
+ return { id: el.id, text, level: Number(el.tagName[1]) }
86
+ })
87
+ .filter(Boolean)
88
+ }
89
+
90
+ /**
91
+ * Nest a flat, ordered heading list by level. A heading deeper than the one
92
+ * before it becomes its child; anything at or above the top level starts a new
93
+ * branch. Headings that skip a level still land somewhere sensible.
94
+ */
95
+ function nest(flat) {
96
+ if (!flat.length) return []
97
+
98
+ const topLevel = Math.min(...flat.map(h => h.level))
99
+ const tree = []
100
+ let current = null
101
+
102
+ for (const heading of flat) {
103
+ const node = { ...heading, children: [] }
104
+ if (heading.level === topLevel || !current) {
105
+ tree.push(node)
106
+ current = node
107
+ } else {
108
+ current.children.push(node)
109
+ }
110
+ }
111
+
112
+ return tree
113
+ }
114
+
115
+ /**
116
+ * Height of the fixed site header, so anchors are not scrolled under it.
117
+ */
118
+ function headerOffset(explicit) {
119
+ if (typeof explicit === 'number') return explicit
120
+ if (typeof document === 'undefined') return 0
121
+
122
+ const declared = getComputedStyle(document.documentElement).getPropertyValue('--header-height')
123
+ return (parseInt(declared, 10) || 0) + 16
124
+ }
125
+
126
+ /**
127
+ * @param {Object} [options]
128
+ * @param {number[]} [options.levels=[2,3]] - Heading levels to collect
129
+ * @param {string} [options.root='main'] - Selector to scan, for the DOM fallback
130
+ * @param {number} [options.offset] - Scroll offset in px; defaults to the site
131
+ * header's height read from `--header-height`, plus a little breathing room
132
+ * @returns {{ headings: Array, activeId: string, scrollTo: (id: string) => void }}
133
+ * `headings` is nested — each entry carries `{ id, text, level, children }`
134
+ */
135
+ export function useHeadings({ levels = [2, 3], root = 'main', offset } = {}) {
136
+ const { website } = useWebsite()
137
+ const { route } = useActiveRoute()
138
+ const [domHeadings, setDomHeadings] = useState(null)
139
+ const [activeId, setActiveId] = useState('')
140
+
141
+ // The content path runs during render, so it is available server-side.
142
+ const contentHeadings = useMemo(
143
+ () => headingsFromContent(website?.activePage, levels),
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ [website?.activePage, route, levels.join()]
146
+ )
147
+
148
+ // Only reached when the content path found nothing.
149
+ useEffect(() => {
150
+ setActiveId('')
151
+ if (contentHeadings.length) {
152
+ setDomHeadings(null)
153
+ return
154
+ }
155
+ const id = requestAnimationFrame(() => setDomHeadings(headingsFromDom(root, levels)))
156
+ return () => cancelAnimationFrame(id)
157
+ // eslint-disable-next-line react-hooks/exhaustive-deps
158
+ }, [route, contentHeadings.length, root, levels.join()])
159
+
160
+ const flat = contentHeadings.length ? contentHeadings : domHeadings ?? []
161
+ const headings = useMemo(() => nest(flat), [flat])
162
+
163
+ // Which heading the reader is level with. Scroll position is the one thing
164
+ // here that only the DOM knows.
165
+ useEffect(() => {
166
+ if (!flat.length || typeof window === 'undefined') return
167
+
168
+ const gap = headerOffset(offset)
169
+
170
+ function onScroll() {
171
+ const line = window.scrollY + gap + 4
172
+ let current = ''
173
+ for (const { id } of flat) {
174
+ const el = document.getElementById(id)
175
+ if (el && el.getBoundingClientRect().top + window.scrollY <= line) current = id
176
+ }
177
+ setActiveId(current || flat[0].id)
178
+ }
179
+
180
+ window.addEventListener('scroll', onScroll, { passive: true })
181
+ onScroll()
182
+ return () => window.removeEventListener('scroll', onScroll)
183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
184
+ }, [flat.map(h => h.id).join(), offset])
185
+
186
+ const scrollTo = useCallback(
187
+ id => {
188
+ const el = typeof document !== 'undefined' && document.getElementById(id)
189
+ if (!el) return
190
+ window.scrollTo({
191
+ top: el.getBoundingClientRect().top + window.scrollY - headerOffset(offset),
192
+ behavior: 'smooth',
193
+ })
194
+ },
195
+ [offset]
196
+ )
197
+
198
+ return { headings, activeId, scrollTo }
199
+ }
200
+
201
+ export default useHeadings
package/src/index.js CHANGED
@@ -62,6 +62,7 @@ export {
62
62
  useScrolled,
63
63
  useMobileMenu,
64
64
  useAccordion,
65
+ useHeadings,
65
66
  useGridLayout,
66
67
  getGridClasses,
67
68
  useTheme,
@@ -99,6 +100,10 @@ export {
99
100
  parseIconRef,
100
101
  // Content utilities
101
102
  splitContent,
103
+ // Heading anchors — one generator, shared by the renderers that stamp them
104
+ // and by anything that links to one (see useHeadings).
105
+ headingId,
106
+ nodeText,
102
107
  // Href resolution (for foundations rendering their own links or prose HTML)
103
108
  applyBasePath,
104
109
  resolveRoute,
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React from 'react'
14
- import { cn, getChildBlockRenderer } from '../../utils/index.js'
14
+ import { cn, getChildBlockRenderer, headingId } from '../../utils/index.js'
15
15
  import { SafeHtml } from '../../components/SafeHtml/index.js'
16
16
  import { Image } from '../../components/Image/index.js'
17
17
  import { Media } from '../../components/Media/index.js'
@@ -30,13 +30,6 @@ const SIZE_CLASSES = {
30
30
  '2xl': 'prose-2xl'
31
31
  }
32
32
 
33
- function generateId(text) {
34
- return text
35
- .replace(/<[^>]*>/g, '')
36
- .toLowerCase()
37
- .replace(/[^a-z0-9]+/g, '-')
38
- .replace(/^-|-$/g, '')
39
- }
40
33
 
41
34
  const INLINE_INSET_RE = /<uniweb-inset data-ref-id="([^"]+)"><\/uniweb-inset>/g
42
35
 
@@ -92,7 +85,7 @@ function SequenceElement({ element, block }) {
92
85
  case 'heading': {
93
86
  const level = Math.min(element.level || 1, 6)
94
87
  const Tag = `h${level}`
95
- const id = generateId(element.text || '')
88
+ const id = headingId(element.text || '')
96
89
  return <Tag id={id}><SafeHtml value={element.text} as="span" /></Tag>
97
90
  }
98
91
 
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import React from 'react'
11
- import { cn, getChildBlockRenderer } from '../../utils/index.js'
11
+ import { cn, getChildBlockRenderer, headingId } from '../../utils/index.js'
12
12
  import { SafeHtml } from '../../components/SafeHtml/index.js'
13
13
  import { Image } from '../../components/Image/index.js'
14
14
  import { Media } from '../../components/Media/index.js'
@@ -33,15 +33,6 @@ function extractText(node) {
33
33
  return ''
34
34
  }
35
35
 
36
- /**
37
- * Generate ID from heading text
38
- */
39
- function generateId(text) {
40
- return text
41
- .toLowerCase()
42
- .replace(/[^a-z0-9]+/g, '-')
43
- .replace(/^-|-$/g, '')
44
- }
45
36
 
46
37
  /**
47
38
  * Render a list (ordered or unordered)
@@ -81,7 +72,7 @@ function RenderNode({ node, block, ...props }) {
81
72
  case 'heading': {
82
73
  const level = attrs?.level || 1
83
74
  const text = extractText(node)
84
- const id = generateId(text)
75
+ const id = headingId(text)
85
76
  const Tag = `h${Math.min(level, 6)}`
86
77
 
87
78
  return (
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Code Block Renderer
3
3
  *
4
- * Renders syntax-highlighted code blocks using Shiki.
5
- * Shiki is lazy-loaded only when code blocks are actually used,
6
- * and CSS variables are injected at runtime from theme.code.
4
+ * Renders syntax-highlighted code blocks using Shiki, lazy-loaded only when a
5
+ * page actually has one. A site's `theme.yml` `code:` block becomes a Shiki
6
+ * theme layered over the default, so the colours a site declares are the
7
+ * colours Shiki writes.
7
8
  *
8
9
  * @module @uniweb/kit/Section/renderers/Code
9
10
  */
@@ -16,37 +17,90 @@ import { getUniweb } from '@uniweb/core'
16
17
  let cssInjected = false
17
18
  let shikiInstance = null
18
19
  let shikiLoadPromise = null
20
+ let siteThemeLoaded = false
21
+
22
+ // What a site gets when it declares no `code:` block of its own, and the base
23
+ // every declaration layers over.
24
+ const DEFAULT_THEME = 'github-dark'
19
25
 
20
26
  /**
21
- * Map theme.code keys to Shiki CSS variable names
27
+ * Where each documented `theme.code` key lands in TextMate scope terms.
28
+ *
29
+ * Shiki colours by scope, so a declaration only reaches the output by becoming
30
+ * a scope rule. This used to map the same keys onto `--shiki-*` CSS variables,
31
+ * which could never take effect: Shiki writes its theme as an inline style on
32
+ * the <pre> and on every token span, and an inline style outranks any
33
+ * stylesheet. The `code:` block was documented, parsed, and inert.
34
+ *
35
+ * `lineNumber` and `selection` are deliberately absent. They are editor chrome,
36
+ * not token scopes, and Shiki's HTML has neither.
37
+ */
38
+ const SCOPE_MAP = {
39
+ comment: ['comment', 'punctuation.definition.comment'],
40
+ string: ['string', 'string.quoted', 'constant.other.symbol'],
41
+ keyword: ['keyword', 'storage', 'storage.type', 'keyword.control'],
42
+ operator: ['keyword.operator'],
43
+ function: ['entity.name.function', 'support.function', 'meta.function-call'],
44
+ variable: ['variable', 'variable.other.readwrite'],
45
+ number: ['constant.numeric'],
46
+ constant: ['constant.language', 'constant.character', 'support.constant'],
47
+ type: ['entity.name.type', 'entity.name.class', 'support.type', 'support.class'],
48
+ property: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key'],
49
+ tag: ['entity.name.tag'],
50
+ attribute: ['entity.other.attribute-name'],
51
+ punctuation: ['punctuation'],
52
+ }
53
+
54
+ export const SITE_CODE_THEME = 'uniweb-site-code'
55
+
56
+ /**
57
+ * Build a Shiki theme from a site's `theme.yml` `code:` block, layered over a
58
+ * base theme.
59
+ *
60
+ * Layering rather than replacing is the point. Most sites declare `background:`
61
+ * alone — "the same highlighting, on my surface" — and building a theme from
62
+ * only the declared keys would strip every syntax colour to answer that. So the
63
+ * base supplies everything, and each declared key overrides its own scopes.
64
+ *
65
+ * @param {Object} base - A resolved Shiki theme to layer over
66
+ * @param {Object} code - The site's `theme.code` declaration
67
+ * @returns {Object} A Shiki theme registration
22
68
  */
23
- const CSS_VAR_MAP = {
24
- background: '--shiki-background',
25
- foreground: '--shiki-foreground',
26
- keyword: '--shiki-token-keyword',
27
- string: '--shiki-token-string',
28
- number: '--shiki-token-constant',
29
- comment: '--shiki-token-comment',
30
- function: '--shiki-token-function',
31
- variable: '--shiki-token-variable',
32
- operator: '--shiki-token-operator',
33
- punctuation: '--shiki-token-punctuation',
34
- type: '--shiki-token-type',
35
- constant: '--shiki-token-constant',
36
- property: '--shiki-token-property',
37
- tag: '--shiki-token-tag',
38
- attribute: '--shiki-token-attribute',
39
- lineNumber: '--shiki-line-number',
40
- selection: '--shiki-selection',
69
+ export function buildCodeTheme(base, code) {
70
+ const theme = {
71
+ ...base,
72
+ name: SITE_CODE_THEME,
73
+ colors: { ...base?.colors },
74
+ // Declared scopes go last so they win over the base's own rules.
75
+ settings: [...(base?.settings ?? [])],
76
+ }
77
+
78
+ if (code?.background) {
79
+ theme.bg = code.background
80
+ theme.colors['editor.background'] = code.background
81
+ }
82
+ if (code?.foreground) {
83
+ theme.fg = code.foreground
84
+ theme.colors['editor.foreground'] = code.foreground
85
+ }
86
+
87
+ for (const [key, scope] of Object.entries(SCOPE_MAP)) {
88
+ if (code?.[key]) theme.settings.push({ scope, settings: { foreground: code[key] } })
89
+ }
90
+
91
+ return theme
41
92
  }
42
93
 
43
94
  /**
44
- * Inject CSS variables from theme.code into the document
95
+ * Layout for the highlighted block. Colour is not set here — Shiki writes the
96
+ * theme's own inline, and the theme is now the site's (see buildCodeTheme).
97
+ * What is left is spacing and the code face, so a listing is legible before a
98
+ * foundation styles it, without kit deciding what colour anything is.
45
99
  */
46
- function injectCodeThemeCSS(codeTheme) {
100
+ function injectCodeLayoutCSS() {
47
101
  if (cssInjected || typeof document === 'undefined') return
48
102
 
49
- const styleId = 'uniweb-code-theme'
103
+ const styleId = 'uniweb-code-layout'
50
104
 
51
105
  // Check if already injected (e.g., by another component instance)
52
106
  if (document.getElementById(styleId)) {
@@ -54,38 +108,15 @@ function injectCodeThemeCSS(codeTheme) {
54
108
  return
55
109
  }
56
110
 
57
- // Build CSS variables
58
- const cssVars = []
59
- for (const [key, value] of Object.entries(codeTheme || {})) {
60
- const varName = CSS_VAR_MAP[key]
61
- if (varName && value) {
62
- cssVars.push(`${varName}: ${value};`)
63
- }
64
- }
65
-
66
- // Create and inject style element
67
111
  const style = document.createElement('style')
68
112
  style.id = styleId
69
113
  style.textContent = `
70
- :root {
71
- ${cssVars.join('\n ')}
72
- }
73
-
74
- /* Code block base styles */
75
114
  .shiki {
76
- background-color: var(--shiki-background, #1e1e2e);
77
- color: var(--shiki-foreground, #cdd6f4);
78
115
  padding: 1rem;
79
116
  border-radius: 0.5rem;
80
117
  overflow-x: auto;
81
118
  }
82
119
 
83
- /* Ensure proper token colors */
84
- .shiki span {
85
- color: var(--shiki-token-foreground, inherit);
86
- }
87
-
88
- /* Code element inside shiki */
89
120
  .shiki code {
90
121
  display: block;
91
122
  font-family: var(--font-code, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace);
@@ -138,13 +169,33 @@ async function loadShiki() {
138
169
  return shikiLoadPromise
139
170
  }
140
171
 
172
+ /**
173
+ * Register the site's `code:` declaration as a theme, once, and answer with the
174
+ * theme name to highlight against. Sites that declare nothing get the default.
175
+ */
176
+ async function resolveThemeName(highlighter, codeTheme) {
177
+ if (!codeTheme || Object.keys(codeTheme).length === 0) return DEFAULT_THEME
178
+ if (siteThemeLoaded) return SITE_CODE_THEME
179
+
180
+ try {
181
+ await highlighter.loadTheme(buildCodeTheme(highlighter.getTheme(DEFAULT_THEME), codeTheme))
182
+ siteThemeLoaded = true
183
+ return SITE_CODE_THEME
184
+ } catch (error) {
185
+ console.warn('[Code] Could not apply theme.code, using the default:', error)
186
+ return DEFAULT_THEME
187
+ }
188
+ }
189
+
141
190
  /**
142
191
  * Highlight code using Shiki
143
192
  */
144
- async function highlightCode(code, language, highlighter) {
193
+ async function highlightCode(code, language, highlighter, codeTheme) {
145
194
  if (!highlighter) return null
146
195
 
147
196
  try {
197
+ const theme = await resolveThemeName(highlighter, codeTheme)
198
+
148
199
  // Load language if not already loaded
149
200
  const loadedLangs = highlighter.getLoadedLanguages()
150
201
  const lang = language?.toLowerCase() || 'plaintext'
@@ -154,16 +205,13 @@ async function highlightCode(code, language, highlighter) {
154
205
  await highlighter.loadLanguage(lang)
155
206
  } catch {
156
207
  // Language not available, fall back to plaintext
157
- return highlighter.codeToHtml(code, {
158
- lang: 'plaintext',
159
- theme: 'github-dark',
160
- })
208
+ return highlighter.codeToHtml(code, { lang: 'plaintext', theme })
161
209
  }
162
210
  }
163
211
 
164
212
  return highlighter.codeToHtml(code, {
165
213
  lang: lang === 'plaintext' ? 'text' : lang,
166
- theme: 'github-dark',
214
+ theme,
167
215
  })
168
216
  } catch (error) {
169
217
  console.warn('[Code] Highlighting failed:', error)
@@ -207,12 +255,11 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
207
255
  return aliases[l] || l
208
256
  }, [language])
209
257
 
210
- // Inject CSS on first render (if in browser)
258
+ // Inject layout CSS on first render (if in browser). Unlike the colours, this
259
+ // is wanted whether or not the site declared a `code:` block.
211
260
  useEffect(() => {
212
- if (typeof document !== 'undefined' && codeTheme) {
213
- injectCodeThemeCSS(codeTheme)
214
- }
215
- }, [codeTheme])
261
+ injectCodeLayoutCSS()
262
+ }, [])
216
263
 
217
264
  // Load Shiki and highlight code
218
265
  useEffect(() => {
@@ -223,7 +270,7 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
223
270
  if (cancelled) return
224
271
 
225
272
  if (highlighter && content) {
226
- const html = await highlightCode(content, lang, highlighter)
273
+ const html = await highlightCode(content, lang, highlighter, codeTheme)
227
274
  if (!cancelled) {
228
275
  setHighlightedHtml(html)
229
276
  }
@@ -235,7 +282,7 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
235
282
  return () => {
236
283
  cancelled = true
237
284
  }
238
- }, [content, lang])
285
+ }, [content, lang, codeTheme])
239
286
 
240
287
  // Render highlighted code or fallback
241
288
  if (highlightedHtml) {
@@ -248,18 +295,19 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
248
295
  )
249
296
  }
250
297
 
251
- // Fallback: plain code block (shown before Shiki loads or if it fails)
252
- // No loading indicator - the code content is already visible and readable.
253
- // When Shiki loads, syntax colors will appear smoothly.
298
+ // Fallback: plain code block (shown before Shiki loads or if it fails).
299
+ // No loading indicator the code is already readable, and the syntax colours
300
+ // arrive when Shiki does. Semantic tokens, so the wait looks like the site
301
+ // rather than like a fixed grey no theme asked for.
254
302
  return (
255
303
  <pre
256
304
  className={cn(
257
- 'overflow-x-auto rounded-lg bg-gray-900 p-4 text-sm',
305
+ 'overflow-x-auto rounded-lg bg-muted p-4 text-sm text-body',
258
306
  className
259
307
  )}
260
308
  {...props}
261
309
  >
262
- <code className={`language-${lang} text-gray-100`}>
310
+ <code className={`language-${lang}`}>
263
311
  {content}
264
312
  </code>
265
313
  </pre>
@@ -76,14 +76,15 @@ function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
76
76
  }, [isOpen])
77
77
 
78
78
  // Position below header if sticky, otherwise from top
79
- const topOffset = stickyHeader ? 'top-16' : 'top-0'
80
- const height = stickyHeader ? 'h-[calc(100vh-4rem)]' : 'h-screen'
79
+ const topOffset = stickyHeader ? 'top-[var(--header-height,4rem)]' : 'top-0'
80
+ const height = stickyHeader ? 'h-[calc(100vh-var(--header-height,4rem))]' : 'h-screen'
81
81
 
82
82
  return (
83
83
  <>
84
84
  {/* Backdrop */}
85
85
  <div
86
86
  className={cn(
87
+ // kit-palette-ok: a modal scrim is the same black in either scheme
87
88
  'fixed inset-0 bg-black/50 z-40 transition-opacity duration-300',
88
89
  isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
89
90
  )}
@@ -94,7 +95,7 @@ function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
94
95
  {/* Drawer (always from left) */}
95
96
  <div
96
97
  className={cn(
97
- 'fixed left-0 bg-white z-50 shadow-xl',
98
+ 'fixed left-0 bg-section z-50 shadow-xl',
98
99
  topOffset,
99
100
  height,
100
101
  width,
@@ -108,10 +109,10 @@ function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
108
109
  {/* Close button */}
109
110
  <button
110
111
  onClick={onClose}
111
- className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-gray-100 transition-colors"
112
+ className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-muted transition-colors"
112
113
  aria-label="Close sidebar"
113
114
  >
114
- <CloseIcon className="w-5 h-5 text-gray-500" />
115
+ <CloseIcon className="w-5 h-5 text-subtle" />
115
116
  </button>
116
117
 
117
118
  {/* Drawer content */}
@@ -132,7 +133,7 @@ function FloatingMenuButton({ onClick }) {
132
133
  onClick={onClick}
133
134
  className={cn(
134
135
  'fixed bottom-4 left-4 z-30',
135
- 'p-3 bg-primary text-white rounded-full shadow-lg',
136
+ 'p-3 bg-primary text-primary-foreground rounded-full shadow-lg',
136
137
  'hover:bg-primary/90 active:scale-95 transition-all',
137
138
  'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'
138
139
  )}
@@ -223,17 +224,17 @@ export function SidebarLayout({
223
224
  : ''
224
225
 
225
226
  const sidebarClasses = stickySidebar && stickyHeader
226
- ? 'sticky top-16 h-[calc(100vh-4rem)]'
227
+ ? 'sticky top-[var(--header-height,4rem)] h-[calc(100vh-var(--header-height,4rem))]'
227
228
  : stickySidebar
228
229
  ? 'sticky top-0 h-screen'
229
230
  : ''
230
231
 
231
232
  return (
232
- <div className={cn('min-h-screen flex flex-col bg-white', className)}>
233
+ <div className={cn('min-h-screen flex flex-col bg-section', className)}>
233
234
  {/* Header */}
234
235
  {header && (
235
236
  <header className={cn(
236
- 'w-full border-b border-gray-200 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80',
237
+ 'w-full border-b border-border bg-section/95 backdrop-blur supports-[backdrop-filter]:bg-section/80',
237
238
  headerClasses
238
239
  )}>
239
240
  {header}
@@ -260,7 +261,7 @@ export function SidebarLayout({
260
261
  {/* Left Sidebar (desktop) */}
261
262
  {leftContent && (
262
263
  <aside className={cn(
263
- 'hidden flex-shrink-0 overflow-y-auto border-r border-gray-200',
264
+ 'hidden flex-shrink-0 overflow-y-auto border-r border-border',
264
265
  leftClasses.showClass,
265
266
  leftWidth,
266
267
  sidebarClasses
@@ -279,7 +280,7 @@ export function SidebarLayout({
279
280
  {/* Right Sidebar (desktop only, hidden on mobile) */}
280
281
  {rightContent && (
281
282
  <aside className={cn(
282
- 'hidden flex-shrink-0 overflow-y-auto border-l border-gray-200',
283
+ 'hidden flex-shrink-0 overflow-y-auto border-l border-border',
283
284
  rightClasses.showClass,
284
285
  rightWidth,
285
286
  sidebarClasses
@@ -292,7 +293,7 @@ export function SidebarLayout({
292
293
 
293
294
  {/* Footer */}
294
295
  {footer && (
295
- <footer className="w-full border-t border-gray-200">
296
+ <footer className="w-full border-t border-border">
296
297
  {footer}
297
298
  </footer>
298
299
  )}
@@ -248,3 +248,37 @@ export function detectMediaType(url) {
248
248
  // ─────────────────────────────────────────────────────────────────
249
249
 
250
250
  export { submitForm, derivePreviewFromFormData } from './submitForm.js'
251
+
252
+ /**
253
+ * The text of a ProseMirror node, flattened.
254
+ *
255
+ * @param {Object|string} node - A ProseMirror node
256
+ * @returns {string}
257
+ */
258
+ export function nodeText(node) {
259
+ if (!node) return ''
260
+ if (typeof node === 'string') return node
261
+ if (node.text) return node.text
262
+ if (node.content) return node.content.map(nodeText).join('')
263
+ return ''
264
+ }
265
+
266
+ /**
267
+ * The anchor id for a heading.
268
+ *
269
+ * One generator, used by every renderer that stamps a heading and by anything
270
+ * that links to one. That agreement is the whole point: a table of contents
271
+ * scrolls to an id some other module produced, so a second implementation is a
272
+ * drift waiting to happen — and there were two, differing in whether they
273
+ * stripped markup first, which decided whether a heading containing a link got
274
+ * a usable anchor.
275
+ *
276
+ * @param {string} text - Heading text, plain or containing markup
277
+ * @returns {string}
278
+ */
279
+ export function headingId(text) {
280
+ return stripTags(String(text ?? ''))
281
+ .toLowerCase()
282
+ .replace(/[^a-z0-9]+/g, '-')
283
+ .replace(/^-|-$/g, '')
284
+ }