@uniweb/kit 0.9.34 → 0.9.35

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,12 +1,13 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.34",
3
+ "version": "0.9.35",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
8
8
  "./xref": "./src/xref/index.js",
9
- "./theme-tokens.css": "./src/theme-tokens.css"
9
+ "./theme-tokens.css": "./src/theme-tokens.css",
10
+ "./prose-tokens.css": "./src/prose-tokens.css"
10
11
  },
11
12
  "files": [
12
13
  "src",
@@ -39,8 +40,8 @@
39
40
  "fuse.js": "^7.0.0",
40
41
  "shiki": "^3.0.0",
41
42
  "tailwind-merge": "^3.6.0",
42
- "@uniweb/core": "0.7.27",
43
- "@uniweb/scene": "0.1.2"
43
+ "@uniweb/scene": "0.1.2",
44
+ "@uniweb/core": "0.7.27"
44
45
  },
45
46
  "peerDependencies": {
46
47
  "react": "^19.0.0",
@@ -41,8 +41,15 @@ import { useActiveRoute } from './useActiveRoute.js'
41
41
 
42
42
  /**
43
43
  * Pull headings out of the page's own content — available during prerender.
44
+ *
45
+ * Skips the headings the semantic parser claimed as the section's own
46
+ * title/pretitle/subtitle. In this framework a leading `###` is a pretitle and
47
+ * the `##` after the title is a subtitle — structure, not body headings — so a
48
+ * typed section would otherwise open every contents rail with two entries no
49
+ * reader recognises as sections of the article. An untyped document has none of
50
+ * those fields set, and everything in it counts.
44
51
  */
45
- function headingsFromContent(page, levels) {
52
+ export function headingsFromContent(page, levels = [2, 3]) {
46
53
  const blocks = page?.getBodyBlocks?.() ?? []
47
54
  const found = []
48
55
 
@@ -50,13 +57,23 @@ function headingsFromContent(page, levels) {
50
57
  const nodes = block?.rawContent?.content
51
58
  if (!Array.isArray(nodes)) continue
52
59
 
60
+ const parsed = block.parsedContent || {}
61
+ const claimed = new Set(
62
+ [parsed.title, parsed.pretitle, parsed.subtitle]
63
+ .flat()
64
+ .filter(Boolean)
65
+ .map(value => String(value).trim())
66
+ )
67
+
53
68
  for (const node of nodes) {
54
69
  if (node?.type !== 'heading') continue
55
70
  const level = node.attrs?.level ?? 1
56
71
  if (!levels.includes(level)) continue
57
72
 
58
73
  const text = nodeText(node).trim()
59
- if (text) found.push({ id: headingId(text), text, level })
74
+ if (!text || claimed.has(text)) continue
75
+
76
+ found.push({ id: headingId(text), text, level })
60
77
  }
61
78
  }
62
79
 
package/src/index.js CHANGED
@@ -124,7 +124,6 @@ export {
124
124
  // Styled Components (Tailwind-based)
125
125
  // ============================================================================
126
126
 
127
- export { SidebarLayout } from './styled/SidebarLayout/index.js'
128
127
  export { Section, Render } from './styled/Section/index.js'
129
128
  export { Prose } from './styled/Prose/index.jsx'
130
129
  export { Article } from './styled/Article/index.jsx'
@@ -0,0 +1,73 @@
1
+ /*
2
+ * Tailwind Typography, wired to the theme.
3
+ *
4
+ * Usage — after the plugin, so these win on equal specificity:
5
+ *
6
+ * @plugin "@tailwindcss/typography";
7
+ * @import "@uniweb/kit/prose-tokens.css";
8
+ *
9
+ * `prose` ships its own greys, which means long-form body copy is the one part
10
+ * of a foundation that ignores the site's theme.yml unless someone bridges it
11
+ * by hand. Everyone does bridge it by hand, identically, and the bridge is easy
12
+ * to get subtly wrong — so it lives here.
13
+ *
14
+ * After this import, a prose container answers to the site like everything else
15
+ * and flips with the visitor's scheme on its own. Do not add a `prose-invert`
16
+ * or a `dark:` variant: the tokens have already changed by the time the dark
17
+ * scheme is active, and `prose-invert` would override this with fixed greys.
18
+ *
19
+ * For the same reason, do not add a palette modifier — `prose-gray`,
20
+ * `prose-slate`, `prose-neutral`. Those re-declare every variable below.
21
+ *
22
+ * ── One container ──
23
+ *
24
+ * These variables are inherited, so a `prose` container nested inside another
25
+ * silently resets the lot for its subtree. Two containers is always a bug, and
26
+ * a quiet one: the outer looks right and everything inside it does not. Decide
27
+ * who owns prose — the section that renders the document is usually the better
28
+ * owner, since it then renders correctly under any layout — and make sure the
29
+ * other one only supplies column width and padding.
30
+ */
31
+
32
+ .prose {
33
+ --tw-prose-body: var(--body);
34
+ --tw-prose-headings: var(--heading);
35
+ --tw-prose-lead: var(--subtle);
36
+ --tw-prose-links: var(--link, var(--primary));
37
+ --tw-prose-bold: var(--heading);
38
+ --tw-prose-counters: var(--subtle);
39
+ --tw-prose-bullets: var(--border);
40
+ --tw-prose-hr: var(--border);
41
+ --tw-prose-quotes: var(--subtle);
42
+ --tw-prose-quote-borders: var(--primary);
43
+ --tw-prose-captions: var(--subtle);
44
+ --tw-prose-kbd: var(--heading);
45
+ --tw-prose-code: var(--heading);
46
+ --tw-prose-th-borders: var(--border);
47
+ --tw-prose-td-borders: var(--border);
48
+
49
+ /* Fenced code keeps whatever the highlighter paints — a site retunes that
50
+ through `code:` in theme.yml, not here. */
51
+ --tw-prose-pre-code: var(--code-text, var(--body));
52
+ --tw-prose-pre-bg: var(--code-surface, var(--muted));
53
+ }
54
+
55
+ /* Inline code reads as a chip on whatever surface it sits on, rather than the
56
+ plugin's fixed grey. Scoped to `:not(pre)` so fenced blocks are untouched. */
57
+ .prose :not(pre) > code {
58
+ background-color: color-mix(in oklch, var(--section), var(--heading) 6%);
59
+ border-radius: 0.25rem;
60
+ padding: 0.125rem 0.375rem;
61
+ font-weight: 500;
62
+ font-size: 0.875em;
63
+ }
64
+ .prose :not(pre) > code::before,
65
+ .prose :not(pre) > code::after {
66
+ content: none;
67
+ }
68
+
69
+ /* Anchors land clear of a fixed site header when something scrolls to them —
70
+ a contents rail, or a link with a #fragment. */
71
+ .prose :is(h1, h2, h3, h4) {
72
+ scroll-margin-top: calc(var(--header-height, 4rem) + 1rem);
73
+ }
@@ -7,14 +7,13 @@
7
7
  * For unstyled primitives, use the main '@uniweb/kit' export.
8
8
  *
9
9
  * @example
10
- * import { SidebarLayout, Section, Media } from '@uniweb/kit (styled)'
10
+ * import { Section, Prose, Media } from '@uniweb/kit (styled)'
11
11
  */
12
12
 
13
13
  // ============================================================================
14
14
  // Layout
15
15
  // ============================================================================
16
16
 
17
- export { SidebarLayout } from './SidebarLayout/index.js'
18
17
 
19
18
  // ============================================================================
20
19
  // Content Rendering
@@ -1,311 +0,0 @@
1
- import React, { useEffect } from 'react'
2
- import { cn } from '../../utils/index.js'
3
- import { useMobileMenu } from '../../hooks/useMobileMenu.js'
4
-
5
- /**
6
- * SidebarLayout Component
7
- *
8
- * A flexible layout with optional left and/or right sidebars.
9
- * On desktop, sidebars appear inline at their configured breakpoints.
10
- * On mobile, the left panel is accessible via a slide-out drawer with FAB toggle.
11
- * The right panel is hidden on mobile (common pattern: nav essential, TOC optional).
12
- *
13
- * The layout is "sandwiched" - header and footer span the full width,
14
- * with the sidebars and content area between them.
15
- *
16
- * @example
17
- * // In foundation's src/foundation.js
18
- * import { SidebarLayout } from '@uniweb/kit'
19
- *
20
- * export default {
21
- * Layout: SidebarLayout,
22
- * }
23
- *
24
- * @example
25
- * // With custom configuration
26
- * import { SidebarLayout } from '@uniweb/kit'
27
- *
28
- * function CustomLayout(props) {
29
- * return (
30
- * <SidebarLayout
31
- * {...props}
32
- * leftBreakpoint="lg"
33
- * rightBreakpoint="xl"
34
- * leftWidth="w-72"
35
- * />
36
- * )
37
- * }
38
- *
39
- * export default { Layout: CustomLayout }
40
- */
41
-
42
- /**
43
- * Hamburger menu icon
44
- */
45
- function MenuIcon({ className }) {
46
- return (
47
- <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
48
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
49
- </svg>
50
- )
51
- }
52
-
53
- /**
54
- * Close (X) icon
55
- */
56
- function CloseIcon({ className }) {
57
- return (
58
- <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
59
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
60
- </svg>
61
- )
62
- }
63
-
64
- /**
65
- * Mobile drawer component (always slides from left)
66
- */
67
- function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
68
- // Prevent body scroll when drawer is open
69
- useEffect(() => {
70
- if (isOpen) {
71
- document.body.style.overflow = 'hidden'
72
- return () => {
73
- document.body.style.overflow = ''
74
- }
75
- }
76
- }, [isOpen])
77
-
78
- // Position below header if sticky, otherwise from top
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
-
82
- return (
83
- <>
84
- {/* Backdrop */}
85
- <div
86
- className={cn(
87
- // kit-palette-ok: a modal scrim is the same black in either scheme
88
- 'fixed inset-0 bg-black/50 z-40 transition-opacity duration-300',
89
- isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
90
- )}
91
- onClick={onClose}
92
- aria-hidden="true"
93
- />
94
-
95
- {/* Drawer (always from left) */}
96
- <div
97
- className={cn(
98
- 'fixed left-0 bg-section z-50 shadow-xl',
99
- topOffset,
100
- height,
101
- width,
102
- 'transform transition-transform duration-300 ease-in-out',
103
- isOpen ? 'translate-x-0' : '-translate-x-full'
104
- )}
105
- role="dialog"
106
- aria-modal="true"
107
- aria-label="Sidebar navigation"
108
- >
109
- {/* Close button */}
110
- <button
111
- onClick={onClose}
112
- className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-muted transition-colors"
113
- aria-label="Close sidebar"
114
- >
115
- <CloseIcon className="w-5 h-5 text-subtle" />
116
- </button>
117
-
118
- {/* Drawer content */}
119
- <div className="h-full overflow-y-auto overscroll-contain">
120
- {children}
121
- </div>
122
- </div>
123
- </>
124
- )
125
- }
126
-
127
- /**
128
- * Floating action button for mobile menu (always bottom-left)
129
- */
130
- function FloatingMenuButton({ onClick }) {
131
- return (
132
- <button
133
- onClick={onClick}
134
- className={cn(
135
- 'fixed bottom-4 left-4 z-30',
136
- 'p-3 bg-primary text-primary-foreground rounded-full shadow-lg',
137
- 'hover:bg-primary/90 active:scale-95 transition-all',
138
- 'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'
139
- )}
140
- aria-label="Open navigation menu"
141
- >
142
- <MenuIcon className="w-6 h-6" />
143
- </button>
144
- )
145
- }
146
-
147
- /**
148
- * Get responsive classes for showing/hiding at breakpoint
149
- */
150
- function getBreakpointClasses(breakpoint) {
151
- const showClass = {
152
- sm: 'sm:block',
153
- md: 'md:block',
154
- lg: 'lg:block',
155
- xl: 'xl:block',
156
- }[breakpoint] || 'md:block'
157
-
158
- const hideClass = {
159
- sm: 'sm:hidden',
160
- md: 'md:hidden',
161
- lg: 'lg:hidden',
162
- xl: 'xl:hidden',
163
- }[breakpoint] || 'md:hidden'
164
-
165
- return { showClass, hideClass }
166
- }
167
-
168
- /**
169
- * SidebarLayout main component
170
- *
171
- * @param {Object} props
172
- * @param {React.ReactNode} props.header - Header content (from layout/header.md)
173
- * @param {React.ReactNode} props.body - Main body content (page sections)
174
- * @param {React.ReactNode} props.footer - Footer content (from layout/footer.md)
175
- * @param {React.ReactNode} props.left - Left panel content (from layout/left.md)
176
- * @param {React.ReactNode} props.right - Right panel content (from layout/right.md)
177
- * @param {React.ReactNode} props.leftPanel - Alias for left (backwards compatibility)
178
- * @param {React.ReactNode} props.rightPanel - Alias for right (backwards compatibility)
179
- * @param {string} [props.leftWidth='w-64'] - Tailwind width class for left sidebar
180
- * @param {string} [props.rightWidth='w-64'] - Tailwind width class for right sidebar
181
- * @param {string} [props.drawerWidth='w-72'] - Tailwind width class for mobile drawer
182
- * @param {string} [props.leftBreakpoint='md'] - Breakpoint for showing left sidebar inline
183
- * @param {string} [props.rightBreakpoint='xl'] - Breakpoint for showing right sidebar inline
184
- * @param {boolean} [props.stickyHeader=true] - Whether header sticks to top
185
- * @param {boolean} [props.stickySidebar=true] - Whether sidebars stick below header
186
- * @param {string} [props.maxWidth='max-w-7xl'] - Max width of content area
187
- * @param {string} [props.contentPadding='px-4 py-8 sm:px-6 lg:px-8'] - Padding for main content
188
- * @param {string} [props.className] - Additional classes for the root element
189
- */
190
- export function SidebarLayout({
191
- // Pre-rendered layout areas from runtime
192
- header,
193
- body,
194
- footer,
195
- left,
196
- right,
197
- leftPanel,
198
- rightPanel,
199
- // Configuration
200
- leftWidth = 'w-64',
201
- rightWidth = 'w-64',
202
- drawerWidth = 'w-72',
203
- leftBreakpoint = 'md',
204
- rightBreakpoint = 'xl',
205
- stickyHeader = true,
206
- stickySidebar = true,
207
- maxWidth = 'max-w-7xl',
208
- contentPadding = 'px-4 py-8 sm:px-6 lg:px-8',
209
- className,
210
- }) {
211
- const { isOpen, open, close } = useMobileMenu()
212
-
213
- // Resolve panel content (support both naming conventions)
214
- const leftContent = left || leftPanel
215
- const rightContent = right || rightPanel
216
-
217
- // Get breakpoint classes for each panel
218
- const leftClasses = getBreakpointClasses(leftBreakpoint)
219
- const rightClasses = getBreakpointClasses(rightBreakpoint)
220
-
221
- // Sticky positioning
222
- const headerClasses = stickyHeader
223
- ? 'sticky top-0 z-30'
224
- : ''
225
-
226
- const sidebarClasses = stickySidebar && stickyHeader
227
- ? 'sticky top-[var(--header-height,4rem)] h-[calc(100vh-var(--header-height,4rem))]'
228
- : stickySidebar
229
- ? 'sticky top-0 h-screen'
230
- : ''
231
-
232
- return (
233
- <div className={cn('min-h-screen flex flex-col bg-section', className)}>
234
- {/* Header */}
235
- {header && (
236
- <header className={cn(
237
- 'w-full border-b border-border bg-section/95 backdrop-blur supports-[backdrop-filter]:bg-section/80',
238
- headerClasses
239
- )}>
240
- {header}
241
- </header>
242
- )}
243
-
244
- {/* Mobile Drawer (left panel only) */}
245
- {leftContent && (
246
- <div className={leftClasses.hideClass}>
247
- <MobileDrawer
248
- isOpen={isOpen}
249
- onClose={close}
250
- width={drawerWidth}
251
- stickyHeader={stickyHeader}
252
- >
253
- {leftContent}
254
- </MobileDrawer>
255
- </div>
256
- )}
257
-
258
- {/* Main Content Area */}
259
- <div className={cn('flex-1 w-full mx-auto', maxWidth)}>
260
- <div className="flex">
261
- {/* Left Sidebar (desktop) */}
262
- {leftContent && (
263
- <aside className={cn(
264
- 'hidden flex-shrink-0 overflow-y-auto border-r border-border',
265
- leftClasses.showClass,
266
- leftWidth,
267
- sidebarClasses
268
- )}>
269
- {leftContent}
270
- </aside>
271
- )}
272
-
273
- {/* Main Content */}
274
- <main className="flex-1 min-w-0">
275
- <div className={contentPadding}>
276
- {body}
277
- </div>
278
- </main>
279
-
280
- {/* Right Sidebar (desktop only, hidden on mobile) */}
281
- {rightContent && (
282
- <aside className={cn(
283
- 'hidden flex-shrink-0 overflow-y-auto border-l border-border',
284
- rightClasses.showClass,
285
- rightWidth,
286
- sidebarClasses
287
- )}>
288
- {rightContent}
289
- </aside>
290
- )}
291
- </div>
292
- </div>
293
-
294
- {/* Footer */}
295
- {footer && (
296
- <footer className="w-full border-t border-border">
297
- {footer}
298
- </footer>
299
- )}
300
-
301
- {/* Mobile FAB (only if left panel exists) */}
302
- {leftContent && (
303
- <div className={leftClasses.hideClass}>
304
- <FloatingMenuButton onClick={open} />
305
- </div>
306
- )}
307
- </div>
308
- )
309
- }
310
-
311
- export default SidebarLayout
@@ -1 +0,0 @@
1
- export { SidebarLayout, default } from './SidebarLayout.jsx'