@uniweb/kit 0.9.34 → 0.9.36
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 +4 -3
- package/src/components/Asset/Asset.jsx +1 -0
- package/src/components/FileLogo/FileLogo.jsx +1 -0
- package/src/components/MediaIcon/MediaIcon.jsx +1 -0
- package/src/hooks/index.js +0 -1
- package/src/hooks/useHeadings.js +19 -2
- package/src/hooks/useScrolled.js +1 -1
- package/src/index.js +0 -5
- package/src/prose-tokens.css +73 -0
- package/src/styled/Asset/Asset.jsx +5 -4
- package/src/styled/Disclaimer/Disclaimer.jsx +10 -9
- package/src/styled/Media/Media.jsx +4 -0
- package/src/styled/Section/Render.jsx +9 -9
- package/src/styled/Section/Section.jsx +1 -1
- package/src/styled/Section/renderers/Alert.jsx +10 -10
- package/src/styled/Section/renderers/Code.jsx +93 -13
- package/src/styled/Section/renderers/Details.jsx +5 -5
- package/src/styled/Section/renderers/Divider.jsx +4 -4
- package/src/styled/Section/renderers/Table.jsx +5 -5
- package/src/styled/index.js +1 -2
- package/src/hooks/useTheme.js +0 -205
- package/src/styled/SidebarLayout/SidebarLayout.jsx +0 -311
- package/src/styled/SidebarLayout/index.js +0 -1
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.36",
|
|
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,7 +40,7 @@
|
|
|
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.
|
|
43
|
+
"@uniweb/core": "0.7.28",
|
|
43
44
|
"@uniweb/scene": "0.1.2"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
@@ -26,6 +26,7 @@ import { useWebsite } from '../../hooks/useWebsite.js'
|
|
|
26
26
|
* @param {React.ReactNode} [props.children] - Custom content (overrides default filename display)
|
|
27
27
|
*
|
|
28
28
|
* @example
|
|
29
|
+
* kit-palette-ok: a caller-supplied class in an example, not kit painting
|
|
29
30
|
* <Asset value="document.pdf" className="text-blue-600 hover:underline" />
|
|
30
31
|
*
|
|
31
32
|
* @example
|
|
@@ -124,6 +124,7 @@ function getExtension(filename) {
|
|
|
124
124
|
* <FileLogo filename="report.pdf" size="32" />
|
|
125
125
|
*
|
|
126
126
|
* @example
|
|
127
|
+
* kit-palette-ok: a caller-supplied class in an example, not kit painting
|
|
127
128
|
* <FileLogo filename="data.xlsx" className="text-green-600" />
|
|
128
129
|
*/
|
|
129
130
|
export function FileLogo({ filename, size = '24', className, ...props }) {
|
|
@@ -67,6 +67,7 @@ const SOCIAL_ICONS = {
|
|
|
67
67
|
* @param {string} [props.className] - Additional CSS classes
|
|
68
68
|
*
|
|
69
69
|
* @example
|
|
70
|
+
* kit-palette-ok: a caller-supplied class in an example, not kit painting
|
|
70
71
|
* <MediaIcon type="twitter" size="32" className="text-blue-400" />
|
|
71
72
|
*
|
|
72
73
|
* @example
|
package/src/hooks/index.js
CHANGED
|
@@ -11,7 +11,6 @@ export { useMobileMenu } from './useMobileMenu.js'
|
|
|
11
11
|
export { useAccordion } from './useAccordion.js'
|
|
12
12
|
export { useHeadings } from './useHeadings.js'
|
|
13
13
|
export { useGridLayout, getGridClasses } from './useGridLayout.js'
|
|
14
|
-
export { useTheme, getThemeClasses, THEMES, THEME_NAMES } from './useTheme.js'
|
|
15
14
|
export { useInView, useIsInView } from './useInView.js'
|
|
16
15
|
export { usePageState } from './usePageState.js'
|
|
17
16
|
export { useWebsiteState } from './useWebsiteState.js'
|
package/src/hooks/useHeadings.js
CHANGED
|
@@ -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
|
|
74
|
+
if (!text || claimed.has(text)) continue
|
|
75
|
+
|
|
76
|
+
found.push({ id: headingId(text), text, level })
|
|
60
77
|
}
|
|
61
78
|
}
|
|
62
79
|
|
package/src/hooks/useScrolled.js
CHANGED
package/src/index.js
CHANGED
|
@@ -65,10 +65,6 @@ export {
|
|
|
65
65
|
useHeadings,
|
|
66
66
|
useGridLayout,
|
|
67
67
|
getGridClasses,
|
|
68
|
-
useTheme,
|
|
69
|
-
getThemeClasses,
|
|
70
|
-
THEMES,
|
|
71
|
-
THEME_NAMES,
|
|
72
68
|
// Viewport detection
|
|
73
69
|
useInView,
|
|
74
70
|
useIsInView,
|
|
@@ -124,7 +120,6 @@ export {
|
|
|
124
120
|
// Styled Components (Tailwind-based)
|
|
125
121
|
// ============================================================================
|
|
126
122
|
|
|
127
|
-
export { SidebarLayout } from './styled/SidebarLayout/index.js'
|
|
128
123
|
export { Section, Render } from './styled/Section/index.js'
|
|
129
124
|
export { Prose } from './styled/Prose/index.jsx'
|
|
130
125
|
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
|
+
}
|
|
@@ -108,7 +108,7 @@ export const Asset = forwardRef(function Asset(
|
|
|
108
108
|
return (
|
|
109
109
|
<div
|
|
110
110
|
className={cn(
|
|
111
|
-
'relative inline-block rounded-lg overflow-hidden border border-
|
|
111
|
+
'relative inline-block rounded-lg overflow-hidden border border-border',
|
|
112
112
|
'transition-shadow hover:shadow-md',
|
|
113
113
|
className
|
|
114
114
|
)}
|
|
@@ -117,7 +117,7 @@ export const Asset = forwardRef(function Asset(
|
|
|
117
117
|
{...props}
|
|
118
118
|
>
|
|
119
119
|
{/* Preview */}
|
|
120
|
-
<div className="w-32 h-32 flex items-center justify-center bg-
|
|
120
|
+
<div className="w-32 h-32 flex items-center justify-center bg-muted">
|
|
121
121
|
{isImage && !imageError ? (
|
|
122
122
|
<Image
|
|
123
123
|
src={src}
|
|
@@ -126,12 +126,12 @@ export const Asset = forwardRef(function Asset(
|
|
|
126
126
|
onError={handleImageError}
|
|
127
127
|
/>
|
|
128
128
|
) : (
|
|
129
|
-
<FileLogo filename={filename} size="48" className="text-
|
|
129
|
+
<FileLogo filename={filename} size="48" className="text-subtle" />
|
|
130
130
|
)}
|
|
131
131
|
</div>
|
|
132
132
|
|
|
133
133
|
{/* Filename */}
|
|
134
|
-
<div className="px-2 py-1 text-xs text-
|
|
134
|
+
<div className="px-2 py-1 text-xs text-body truncate max-w-[128px]" title={filename}>
|
|
135
135
|
{filename}
|
|
136
136
|
</div>
|
|
137
137
|
|
|
@@ -141,6 +141,7 @@ export const Asset = forwardRef(function Asset(
|
|
|
141
141
|
onClick={handleDownload}
|
|
142
142
|
className={cn(
|
|
143
143
|
'absolute inset-0 flex items-center justify-center',
|
|
144
|
+
// kit-palette-ok: a scrim over a thumbnail, not a themed surface
|
|
144
145
|
'bg-black/50 text-white transition-opacity',
|
|
145
146
|
isHovered ? 'opacity-100' : 'opacity-0'
|
|
146
147
|
)}
|
|
@@ -38,6 +38,7 @@ function DisclaimerModal({ isOpen, onClose, title, content, className }) {
|
|
|
38
38
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
39
39
|
{/* Backdrop */}
|
|
40
40
|
<div
|
|
41
|
+
// kit-palette-ok: a modal scrim is the same black in either scheme
|
|
41
42
|
className="fixed inset-0 bg-black/50 transition-opacity"
|
|
42
43
|
onClick={onClose}
|
|
43
44
|
aria-hidden="true"
|
|
@@ -48,7 +49,7 @@ function DisclaimerModal({ isOpen, onClose, title, content, className }) {
|
|
|
48
49
|
<div
|
|
49
50
|
className={cn(
|
|
50
51
|
'relative w-full max-w-lg transform overflow-hidden rounded-lg',
|
|
51
|
-
'bg-
|
|
52
|
+
'bg-card shadow-xl transition-all',
|
|
52
53
|
className
|
|
53
54
|
)}
|
|
54
55
|
role="dialog"
|
|
@@ -56,13 +57,13 @@ function DisclaimerModal({ isOpen, onClose, title, content, className }) {
|
|
|
56
57
|
aria-labelledby="disclaimer-title"
|
|
57
58
|
>
|
|
58
59
|
{/* Header */}
|
|
59
|
-
<div className="flex items-center justify-between border-b border-
|
|
60
|
-
<h3 id="disclaimer-title" className="text-lg font-medium text-
|
|
60
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
61
|
+
<h3 id="disclaimer-title" className="text-lg font-medium text-heading">
|
|
61
62
|
{title}
|
|
62
63
|
</h3>
|
|
63
64
|
<button
|
|
64
65
|
onClick={onClose}
|
|
65
|
-
className="rounded-md p-1 text-
|
|
66
|
+
className="rounded-md p-1 text-subtle hover:text-heading focus:outline-none focus:ring-2 focus:ring-ring"
|
|
66
67
|
aria-label="Close"
|
|
67
68
|
>
|
|
68
69
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
@@ -81,10 +82,10 @@ function DisclaimerModal({ isOpen, onClose, title, content, className }) {
|
|
|
81
82
|
</div>
|
|
82
83
|
|
|
83
84
|
{/* Footer */}
|
|
84
|
-
<div className="border-t border-
|
|
85
|
+
<div className="border-t border-border px-4 py-3 flex justify-end">
|
|
85
86
|
<button
|
|
86
87
|
onClick={onClose}
|
|
87
|
-
className="px-4 py-2 text-sm font-medium text-
|
|
88
|
+
className="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-md hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-ring"
|
|
88
89
|
>
|
|
89
90
|
Close
|
|
90
91
|
</button>
|
|
@@ -161,8 +162,8 @@ export function Disclaimer({
|
|
|
161
162
|
onClick={handleOpen}
|
|
162
163
|
className={cn(
|
|
163
164
|
'inline-flex items-center px-3 py-1.5 text-sm font-medium',
|
|
164
|
-
'text-
|
|
165
|
-
'border border-
|
|
165
|
+
'text-primary hover:text-primary-hover',
|
|
166
|
+
'border border-primary rounded-md hover:bg-primary/10',
|
|
166
167
|
className
|
|
167
168
|
)}
|
|
168
169
|
{...props}
|
|
@@ -173,7 +174,7 @@ export function Disclaimer({
|
|
|
173
174
|
<button
|
|
174
175
|
onClick={handleOpen}
|
|
175
176
|
className={cn(
|
|
176
|
-
'text-
|
|
177
|
+
'text-primary hover:text-primary-hover underline text-sm',
|
|
177
178
|
className
|
|
178
179
|
)}
|
|
179
180
|
{...props}
|
|
@@ -152,12 +152,15 @@ function PlayButton({ onClick, className }) {
|
|
|
152
152
|
onClick={onClick}
|
|
153
153
|
className={cn(
|
|
154
154
|
'absolute inset-0 flex items-center justify-center',
|
|
155
|
+
// kit-palette-ok: a play-button scrim, the same in any theme
|
|
155
156
|
'bg-black/30 hover:bg-black/40 transition-colors',
|
|
156
157
|
'group cursor-pointer',
|
|
157
158
|
className
|
|
158
159
|
)}
|
|
159
160
|
aria-label="Play video"
|
|
160
161
|
>
|
|
162
|
+
{/* kit-palette-ok: a play control is a white disc with a dark glyph in
|
|
163
|
+
every player ever built — not a themed surface */}
|
|
161
164
|
<div className="w-16 h-16 rounded-full bg-white/90 group-hover:bg-white flex items-center justify-center transition-colors">
|
|
162
165
|
<svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
|
163
166
|
<path d="M8 5v14l11-7z" />
|
|
@@ -310,6 +313,7 @@ export function Media({
|
|
|
310
313
|
|
|
311
314
|
return (
|
|
312
315
|
<div
|
|
316
|
+
// kit-palette-ok: letterboxing behind a video is black, in any theme
|
|
313
317
|
className={cn('relative overflow-hidden bg-black', className)}
|
|
314
318
|
style={{ aspectRatio }}
|
|
315
319
|
{...props}
|
|
@@ -123,16 +123,16 @@ function RenderNode({ node, block, ...props }) {
|
|
|
123
123
|
className="w-full"
|
|
124
124
|
/>
|
|
125
125
|
) : (
|
|
126
|
-
<div className="flex items-center gap-3 p-4 bg-
|
|
127
|
-
<Icon name="download" size="24" className="text-
|
|
128
|
-
<span className="text-
|
|
126
|
+
<div className="flex items-center gap-3 p-4 bg-muted">
|
|
127
|
+
<Icon name="download" size="24" className="text-subtle" />
|
|
128
|
+
<span className="text-link group-hover:underline">
|
|
129
129
|
{filename}
|
|
130
130
|
</span>
|
|
131
131
|
</div>
|
|
132
132
|
)}
|
|
133
133
|
</Link>
|
|
134
134
|
{caption && (
|
|
135
|
-
<figcaption className="mt-2 text-sm text-
|
|
135
|
+
<figcaption className="mt-2 text-sm text-subtle text-center">
|
|
136
136
|
{caption}
|
|
137
137
|
</figcaption>
|
|
138
138
|
)}
|
|
@@ -164,7 +164,7 @@ function RenderNode({ node, block, ...props }) {
|
|
|
164
164
|
<figure className="my-4">
|
|
165
165
|
<Image src={src} alt={alt} className="rounded-lg" />
|
|
166
166
|
{caption && (
|
|
167
|
-
<figcaption className="mt-2 text-sm text-
|
|
167
|
+
<figcaption className="mt-2 text-sm text-subtle text-center">
|
|
168
168
|
{caption}
|
|
169
169
|
</figcaption>
|
|
170
170
|
)}
|
|
@@ -192,7 +192,7 @@ function RenderNode({ node, block, ...props }) {
|
|
|
192
192
|
|
|
193
193
|
case 'blockquote': {
|
|
194
194
|
return (
|
|
195
|
-
<blockquote className="border-l-4 border-
|
|
195
|
+
<blockquote className="border-l-4 border-border pl-4 italic text-subtle my-4">
|
|
196
196
|
{content?.map((child, i) => (
|
|
197
197
|
<RenderNode key={i} node={child} block={block} />
|
|
198
198
|
))}
|
|
@@ -247,7 +247,7 @@ function RenderNode({ node, block, ...props }) {
|
|
|
247
247
|
return (
|
|
248
248
|
<Link
|
|
249
249
|
to={href}
|
|
250
|
-
className="inline-block px-4 py-2 bg-
|
|
250
|
+
className="inline-block px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary-hover transition-colors my-2"
|
|
251
251
|
>
|
|
252
252
|
{label}
|
|
253
253
|
</Link>
|
|
@@ -270,10 +270,10 @@ function RenderNode({ node, block, ...props }) {
|
|
|
270
270
|
text = `<em>${text}</em>`
|
|
271
271
|
break
|
|
272
272
|
case 'code':
|
|
273
|
-
text = `<code class="px-1 py-0.5 bg-
|
|
273
|
+
text = `<code class="px-1 py-0.5 bg-muted rounded text-sm">${text}</code>`
|
|
274
274
|
break
|
|
275
275
|
case 'link':
|
|
276
|
-
text = `<a href="${mark.attrs?.href || '#'}" class="text-
|
|
276
|
+
text = `<a href="${mark.attrs?.href || '#'}" class="text-link hover:underline">${text}</a>`
|
|
277
277
|
break
|
|
278
278
|
}
|
|
279
279
|
})
|
|
@@ -62,7 +62,7 @@ const PADDING_CLASSES = {
|
|
|
62
62
|
* <Section content={blockContent} width="lg" padding="md" />
|
|
63
63
|
*
|
|
64
64
|
* @example
|
|
65
|
-
* <Section width="xl" columns="2" className="bg-
|
|
65
|
+
* <Section width="xl" columns="2" className="bg-muted">
|
|
66
66
|
* <div>Column 1</div>
|
|
67
67
|
* <div>Column 2</div>
|
|
68
68
|
* </Section>
|
|
@@ -15,39 +15,39 @@ import { SafeHtml } from '../../../components/SafeHtml/index.js'
|
|
|
15
15
|
*/
|
|
16
16
|
const ALERT_STYLES = {
|
|
17
17
|
info: {
|
|
18
|
-
container: 'bg-
|
|
18
|
+
container: 'bg-info-subtle border-info text-info',
|
|
19
19
|
icon: (
|
|
20
20
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
21
21
|
),
|
|
22
|
-
iconColor: 'text-
|
|
22
|
+
iconColor: 'text-info'
|
|
23
23
|
},
|
|
24
24
|
success: {
|
|
25
|
-
container: 'bg-
|
|
25
|
+
container: 'bg-success-subtle border-success text-success',
|
|
26
26
|
icon: (
|
|
27
27
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
28
28
|
),
|
|
29
|
-
iconColor: 'text-
|
|
29
|
+
iconColor: 'text-success'
|
|
30
30
|
},
|
|
31
31
|
warning: {
|
|
32
|
-
container: 'bg-
|
|
32
|
+
container: 'bg-warning-subtle border-warning text-warning',
|
|
33
33
|
icon: (
|
|
34
34
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
35
35
|
),
|
|
36
|
-
iconColor: 'text-
|
|
36
|
+
iconColor: 'text-warning'
|
|
37
37
|
},
|
|
38
38
|
danger: {
|
|
39
|
-
container: 'bg-
|
|
39
|
+
container: 'bg-error-subtle border-error text-error',
|
|
40
40
|
icon: (
|
|
41
41
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
42
42
|
),
|
|
43
|
-
iconColor: 'text-
|
|
43
|
+
iconColor: 'text-error'
|
|
44
44
|
},
|
|
45
45
|
error: {
|
|
46
|
-
container: 'bg-
|
|
46
|
+
container: 'bg-error-subtle border-error text-error',
|
|
47
47
|
icon: (
|
|
48
48
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
49
49
|
),
|
|
50
|
-
iconColor: 'text-
|
|
50
|
+
iconColor: 'text-error'
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -17,7 +17,7 @@ import { getUniweb } from '@uniweb/core'
|
|
|
17
17
|
let cssInjected = false
|
|
18
18
|
let shikiInstance = null
|
|
19
19
|
let shikiLoadPromise = null
|
|
20
|
-
|
|
20
|
+
const loadedDerived = new Set()
|
|
21
21
|
|
|
22
22
|
// What a site gets when it declares no `code:` block of its own, and the base
|
|
23
23
|
// every declaration layers over.
|
|
@@ -121,6 +121,24 @@ function injectCodeLayoutCSS() {
|
|
|
121
121
|
display: block;
|
|
122
122
|
font-family: var(--font-code, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace);
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
/* A site that named a light/dark pair gets CSS variables per token instead of
|
|
126
|
+
inline colour, and these are what read them. Scoped to .shiki-themes, the
|
|
127
|
+
class Shiki adds only in that mode, so a single-theme listing is untouched. */
|
|
128
|
+
.shiki-themes {
|
|
129
|
+
background-color: var(--shiki-light-bg);
|
|
130
|
+
}
|
|
131
|
+
.shiki-themes,
|
|
132
|
+
.shiki-themes span {
|
|
133
|
+
color: var(--shiki-light);
|
|
134
|
+
}
|
|
135
|
+
.scheme-dark .shiki-themes {
|
|
136
|
+
background-color: var(--shiki-dark-bg);
|
|
137
|
+
}
|
|
138
|
+
.scheme-dark .shiki-themes,
|
|
139
|
+
.scheme-dark .shiki-themes span {
|
|
140
|
+
color: var(--shiki-dark);
|
|
141
|
+
}
|
|
124
142
|
`
|
|
125
143
|
document.head.appendChild(style)
|
|
126
144
|
cssInjected = true
|
|
@@ -170,20 +188,82 @@ async function loadShiki() {
|
|
|
170
188
|
}
|
|
171
189
|
|
|
172
190
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
191
|
+
* Normalize `theme.yml`'s `code:` into the three forms a site can write.
|
|
192
|
+
*
|
|
193
|
+
* code: dracula → one bundled theme
|
|
194
|
+
* code: { light: …, dark: … } → a pair, following the visitor's scheme
|
|
195
|
+
* code: { theme?: …, <colour>: … } → a base theme with colours overridden
|
|
196
|
+
*
|
|
197
|
+
* The third is the escape hatch. Naming a theme is what most sites want — 65
|
|
198
|
+
* ship inside Shiki — and hand-picking a dozen syntax colours is a job few
|
|
199
|
+
* people can do well. Overrides exist because "that theme, on my background"
|
|
200
|
+
* is a reasonable and common ask.
|
|
201
|
+
*
|
|
202
|
+
* @param {string|Object} [code] - the `code:` declaration
|
|
203
|
+
* @returns {{ light: Side, dark?: Side }} where Side is { base, overrides }
|
|
204
|
+
*/
|
|
205
|
+
export function parseCodeConfig(code) {
|
|
206
|
+
const side = (value) => {
|
|
207
|
+
if (typeof value === 'string') return { base: value, overrides: {} }
|
|
208
|
+
const { theme, ...overrides } = value || {}
|
|
209
|
+
return { base: theme || DEFAULT_THEME, overrides }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!code) return { light: { base: DEFAULT_THEME, overrides: {} } }
|
|
213
|
+
if (typeof code === 'string') return { light: side(code) }
|
|
214
|
+
|
|
215
|
+
if (code.light || code.dark) {
|
|
216
|
+
return {
|
|
217
|
+
light: side(code.light ?? code.dark),
|
|
218
|
+
dark: side(code.dark ?? code.light),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { light: side(code) }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Make sure a side's theme is registered, and answer the name to highlight by.
|
|
227
|
+
* A side with no overrides is just the bundled theme; one with overrides gets a
|
|
228
|
+
* derived theme built from it.
|
|
229
|
+
*/
|
|
230
|
+
async function registerSide(highlighter, side, name) {
|
|
231
|
+
if (!highlighter.getLoadedThemes().includes(side.base)) {
|
|
232
|
+
await highlighter.loadTheme(side.base)
|
|
233
|
+
}
|
|
234
|
+
if (Object.keys(side.overrides).length === 0) return side.base
|
|
235
|
+
|
|
236
|
+
if (!loadedDerived.has(name)) {
|
|
237
|
+
await highlighter.loadTheme({
|
|
238
|
+
...buildCodeTheme(highlighter.getTheme(side.base), side.overrides),
|
|
239
|
+
name,
|
|
240
|
+
})
|
|
241
|
+
loadedDerived.add(name)
|
|
242
|
+
}
|
|
243
|
+
return name
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Resolve the `code:` declaration into codeToHtml options.
|
|
248
|
+
*
|
|
249
|
+
* One theme highlights the same in both schemes — right for a site whose code
|
|
250
|
+
* blocks are a terminal. A pair makes Shiki emit CSS variables per token
|
|
251
|
+
* (`defaultColor: false`) instead of inline colour, so one rendered block can
|
|
252
|
+
* follow the visitor's choice; injectCodeLayoutCSS supplies the rules that read
|
|
253
|
+
* them.
|
|
175
254
|
*/
|
|
176
|
-
async function
|
|
177
|
-
|
|
178
|
-
if (siteThemeLoaded) return SITE_CODE_THEME
|
|
255
|
+
async function resolveThemeOptions(highlighter, code) {
|
|
256
|
+
const config = parseCodeConfig(code)
|
|
179
257
|
|
|
180
258
|
try {
|
|
181
|
-
await
|
|
182
|
-
|
|
183
|
-
|
|
259
|
+
const light = await registerSide(highlighter, config.light, `${SITE_CODE_THEME}-light`)
|
|
260
|
+
if (!config.dark) return { theme: light }
|
|
261
|
+
|
|
262
|
+
const dark = await registerSide(highlighter, config.dark, `${SITE_CODE_THEME}-dark`)
|
|
263
|
+
return { themes: { light, dark }, defaultColor: false }
|
|
184
264
|
} catch (error) {
|
|
185
265
|
console.warn('[Code] Could not apply theme.code, using the default:', error)
|
|
186
|
-
return DEFAULT_THEME
|
|
266
|
+
return { theme: DEFAULT_THEME }
|
|
187
267
|
}
|
|
188
268
|
}
|
|
189
269
|
|
|
@@ -194,7 +274,7 @@ async function highlightCode(code, language, highlighter, codeTheme) {
|
|
|
194
274
|
if (!highlighter) return null
|
|
195
275
|
|
|
196
276
|
try {
|
|
197
|
-
const
|
|
277
|
+
const themeOptions = await resolveThemeOptions(highlighter, codeTheme)
|
|
198
278
|
|
|
199
279
|
// Load language if not already loaded
|
|
200
280
|
const loadedLangs = highlighter.getLoadedLanguages()
|
|
@@ -205,13 +285,13 @@ async function highlightCode(code, language, highlighter, codeTheme) {
|
|
|
205
285
|
await highlighter.loadLanguage(lang)
|
|
206
286
|
} catch {
|
|
207
287
|
// Language not available, fall back to plaintext
|
|
208
|
-
return highlighter.codeToHtml(code, { lang: 'plaintext',
|
|
288
|
+
return highlighter.codeToHtml(code, { lang: 'plaintext', ...themeOptions })
|
|
209
289
|
}
|
|
210
290
|
}
|
|
211
291
|
|
|
212
292
|
return highlighter.codeToHtml(code, {
|
|
213
293
|
lang: lang === 'plaintext' ? 'text' : lang,
|
|
214
|
-
|
|
294
|
+
...themeOptions,
|
|
215
295
|
})
|
|
216
296
|
} catch (error) {
|
|
217
297
|
console.warn('[Code] Highlighting failed:', error)
|
|
@@ -31,7 +31,7 @@ export function Details({
|
|
|
31
31
|
return (
|
|
32
32
|
<div
|
|
33
33
|
className={cn(
|
|
34
|
-
'border border-
|
|
34
|
+
'border border-border rounded-lg overflow-hidden',
|
|
35
35
|
className
|
|
36
36
|
)}
|
|
37
37
|
{...props}
|
|
@@ -40,8 +40,8 @@ export function Details({
|
|
|
40
40
|
<button
|
|
41
41
|
className={cn(
|
|
42
42
|
'w-full flex items-center justify-between px-4 py-3',
|
|
43
|
-
'text-left font-medium text-
|
|
44
|
-
'hover:bg-
|
|
43
|
+
'text-left font-medium text-heading bg-muted',
|
|
44
|
+
'hover:bg-card transition-colors'
|
|
45
45
|
)}
|
|
46
46
|
onClick={() => setIsOpen(!isOpen)}
|
|
47
47
|
aria-expanded={isOpen}
|
|
@@ -49,7 +49,7 @@ export function Details({
|
|
|
49
49
|
<span>{summary}</span>
|
|
50
50
|
<svg
|
|
51
51
|
className={cn(
|
|
52
|
-
'w-5 h-5 text-
|
|
52
|
+
'w-5 h-5 text-subtle transition-transform',
|
|
53
53
|
isOpen && 'rotate-180'
|
|
54
54
|
)}
|
|
55
55
|
fill="none"
|
|
@@ -62,7 +62,7 @@ export function Details({
|
|
|
62
62
|
|
|
63
63
|
{/* Content */}
|
|
64
64
|
{isOpen && (
|
|
65
|
-
<div className="px-4 py-3 border-t border-
|
|
65
|
+
<div className="px-4 py-3 border-t border-border">
|
|
66
66
|
{typeof content === 'string' ? (
|
|
67
67
|
<SafeHtml value={content} className="prose prose-sm" />
|
|
68
68
|
) : (
|
|
@@ -24,16 +24,16 @@ export function Divider({ type = 'hr', className, ...props }) {
|
|
|
24
24
|
role="separator"
|
|
25
25
|
{...props}
|
|
26
26
|
>
|
|
27
|
-
<span className="w-2 h-2 bg-
|
|
28
|
-
<span className="w-2 h-2 bg-
|
|
29
|
-
<span className="w-2 h-2 bg-
|
|
27
|
+
<span className="w-2 h-2 bg-border rounded-full" />
|
|
28
|
+
<span className="w-2 h-2 bg-border rounded-full" />
|
|
29
|
+
<span className="w-2 h-2 bg-border rounded-full" />
|
|
30
30
|
</div>
|
|
31
31
|
)
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
return (
|
|
35
35
|
<hr
|
|
36
|
-
className={cn('border-
|
|
36
|
+
className={cn('border-border my-6', className)}
|
|
37
37
|
{...props}
|
|
38
38
|
/>
|
|
39
39
|
)
|
|
@@ -22,10 +22,10 @@ export function Table({ content, className, ...props }) {
|
|
|
22
22
|
|
|
23
23
|
return (
|
|
24
24
|
<div className={cn('overflow-x-auto', className)} {...props}>
|
|
25
|
-
<table className="min-w-full divide-y divide-
|
|
26
|
-
<tbody className="divide-y divide-
|
|
25
|
+
<table className="min-w-full divide-y divide-border border border-border rounded-lg">
|
|
26
|
+
<tbody className="divide-y divide-border bg-section">
|
|
27
27
|
{content.map((row, rowIndex) => (
|
|
28
|
-
<tr key={rowIndex} className={rowIndex % 2 === 0 ? 'bg-
|
|
28
|
+
<tr key={rowIndex} className={rowIndex % 2 === 0 ? 'bg-section' : 'bg-muted'}>
|
|
29
29
|
{row.content?.map((cell, cellIndex) => {
|
|
30
30
|
const CellTag = cell.type === 'tableHeader' ? 'th' : 'td'
|
|
31
31
|
const cellContent = cell.content?.[0]?.content?.[0]?.text || ''
|
|
@@ -36,8 +36,8 @@ export function Table({ content, className, ...props }) {
|
|
|
36
36
|
className={cn(
|
|
37
37
|
'px-4 py-2 text-sm',
|
|
38
38
|
cell.type === 'tableHeader'
|
|
39
|
-
? 'font-medium text-
|
|
40
|
-
: 'text-
|
|
39
|
+
? 'font-medium text-heading bg-muted'
|
|
40
|
+
: 'text-body'
|
|
41
41
|
)}
|
|
42
42
|
>
|
|
43
43
|
<SafeHtml value={cellContent} as="span" />
|
package/src/styled/index.js
CHANGED
|
@@ -7,14 +7,13 @@
|
|
|
7
7
|
* For unstyled primitives, use the main '@uniweb/kit' export.
|
|
8
8
|
*
|
|
9
9
|
* @example
|
|
10
|
-
* import {
|
|
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
|
package/src/hooks/useTheme.js
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useTheme Hook
|
|
3
|
-
*
|
|
4
|
-
* Provides standardized theme classes for components.
|
|
5
|
-
* Eliminates duplicated theme objects across 25+ components.
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* function Hero({ params }) {
|
|
9
|
-
* const t = useTheme(params.theme)
|
|
10
|
-
*
|
|
11
|
-
* return (
|
|
12
|
-
* <section className={t.section}>
|
|
13
|
-
* <h1 className={t.title}>...</h1>
|
|
14
|
-
* <p className={t.body}>...</p>
|
|
15
|
-
* </section>
|
|
16
|
-
* )
|
|
17
|
-
* }
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* // With custom overrides
|
|
21
|
-
* const t = useTheme('dark', {
|
|
22
|
-
* card: 'bg-gray-800 rounded-xl',
|
|
23
|
-
* cardHover: 'hover:bg-gray-700'
|
|
24
|
-
* })
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Standard theme definitions.
|
|
29
|
-
* Keys are standardized across all components.
|
|
30
|
-
*/
|
|
31
|
-
const THEMES = {
|
|
32
|
-
light: {
|
|
33
|
-
// Section backgrounds
|
|
34
|
-
section: 'bg-white',
|
|
35
|
-
sectionAlt: 'bg-gray-50',
|
|
36
|
-
|
|
37
|
-
// Typography
|
|
38
|
-
title: 'text-gray-900',
|
|
39
|
-
subtitle: 'text-gray-700',
|
|
40
|
-
pretitle: 'text-primary',
|
|
41
|
-
body: 'text-gray-600',
|
|
42
|
-
muted: 'text-gray-500',
|
|
43
|
-
|
|
44
|
-
// Cards
|
|
45
|
-
card: 'bg-gray-50',
|
|
46
|
-
cardHover: 'hover:shadow-lg',
|
|
47
|
-
cardBorder: 'border border-gray-200',
|
|
48
|
-
|
|
49
|
-
// Interactive
|
|
50
|
-
link: 'text-primary hover:text-primary-dark',
|
|
51
|
-
linkMuted: 'text-gray-600 hover:text-gray-900',
|
|
52
|
-
|
|
53
|
-
// Borders & Dividers
|
|
54
|
-
border: 'border-gray-200',
|
|
55
|
-
divider: 'bg-gray-200',
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
gray: {
|
|
59
|
-
section: 'bg-gray-50',
|
|
60
|
-
sectionAlt: 'bg-gray-100',
|
|
61
|
-
|
|
62
|
-
title: 'text-gray-900',
|
|
63
|
-
subtitle: 'text-gray-700',
|
|
64
|
-
pretitle: 'text-primary',
|
|
65
|
-
body: 'text-gray-600',
|
|
66
|
-
muted: 'text-gray-500',
|
|
67
|
-
|
|
68
|
-
card: 'bg-white shadow-sm',
|
|
69
|
-
cardHover: 'hover:shadow-lg',
|
|
70
|
-
cardBorder: 'border border-gray-200',
|
|
71
|
-
|
|
72
|
-
link: 'text-primary hover:text-primary-dark',
|
|
73
|
-
linkMuted: 'text-gray-600 hover:text-gray-900',
|
|
74
|
-
|
|
75
|
-
border: 'border-gray-200',
|
|
76
|
-
divider: 'bg-gray-300',
|
|
77
|
-
},
|
|
78
|
-
|
|
79
|
-
dark: {
|
|
80
|
-
section: 'bg-gray-900',
|
|
81
|
-
sectionAlt: 'bg-gray-800',
|
|
82
|
-
|
|
83
|
-
title: 'text-white',
|
|
84
|
-
subtitle: 'text-gray-300',
|
|
85
|
-
pretitle: 'text-primary',
|
|
86
|
-
body: 'text-gray-400',
|
|
87
|
-
muted: 'text-gray-500',
|
|
88
|
-
|
|
89
|
-
card: 'bg-gray-800',
|
|
90
|
-
cardHover: 'hover:bg-gray-750',
|
|
91
|
-
cardBorder: 'border border-gray-700',
|
|
92
|
-
|
|
93
|
-
link: 'text-primary hover:text-primary-light',
|
|
94
|
-
linkMuted: 'text-gray-400 hover:text-white',
|
|
95
|
-
|
|
96
|
-
border: 'border-gray-700',
|
|
97
|
-
divider: 'bg-gray-700',
|
|
98
|
-
},
|
|
99
|
-
|
|
100
|
-
primary: {
|
|
101
|
-
section: 'bg-primary',
|
|
102
|
-
sectionAlt: 'bg-primary-dark',
|
|
103
|
-
|
|
104
|
-
title: 'text-white',
|
|
105
|
-
subtitle: 'text-white/90',
|
|
106
|
-
pretitle: 'text-white/80',
|
|
107
|
-
body: 'text-white/80',
|
|
108
|
-
muted: 'text-white/60',
|
|
109
|
-
|
|
110
|
-
card: 'bg-white/10',
|
|
111
|
-
cardHover: 'hover:bg-white/20',
|
|
112
|
-
cardBorder: 'border border-white/20',
|
|
113
|
-
|
|
114
|
-
link: 'text-white hover:text-white/80',
|
|
115
|
-
linkMuted: 'text-white/70 hover:text-white',
|
|
116
|
-
|
|
117
|
-
border: 'border-white/20',
|
|
118
|
-
divider: 'bg-white/20',
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
gradient: {
|
|
122
|
-
section: 'bg-gradient-to-br from-primary to-indigo-600',
|
|
123
|
-
sectionAlt: 'bg-gradient-to-br from-primary-dark to-indigo-700',
|
|
124
|
-
|
|
125
|
-
title: 'text-white',
|
|
126
|
-
subtitle: 'text-white/90',
|
|
127
|
-
pretitle: 'text-white/80',
|
|
128
|
-
body: 'text-white/80',
|
|
129
|
-
muted: 'text-white/60',
|
|
130
|
-
|
|
131
|
-
card: 'bg-white/10 backdrop-blur-sm',
|
|
132
|
-
cardHover: 'hover:bg-white/20',
|
|
133
|
-
cardBorder: 'border border-white/20',
|
|
134
|
-
|
|
135
|
-
link: 'text-white hover:text-white/80',
|
|
136
|
-
linkMuted: 'text-white/70 hover:text-white',
|
|
137
|
-
|
|
138
|
-
border: 'border-white/20',
|
|
139
|
-
divider: 'bg-white/20',
|
|
140
|
-
},
|
|
141
|
-
|
|
142
|
-
glass: {
|
|
143
|
-
section: 'bg-white/10 backdrop-blur-lg',
|
|
144
|
-
sectionAlt: 'bg-white/5 backdrop-blur-lg',
|
|
145
|
-
|
|
146
|
-
title: 'text-white',
|
|
147
|
-
subtitle: 'text-white/90',
|
|
148
|
-
pretitle: 'text-primary',
|
|
149
|
-
body: 'text-white/80',
|
|
150
|
-
muted: 'text-white/60',
|
|
151
|
-
|
|
152
|
-
card: 'bg-white/10 backdrop-blur-sm',
|
|
153
|
-
cardHover: 'hover:bg-white/20',
|
|
154
|
-
cardBorder: 'border border-white/20',
|
|
155
|
-
|
|
156
|
-
link: 'text-white hover:text-primary',
|
|
157
|
-
linkMuted: 'text-white/70 hover:text-white',
|
|
158
|
-
|
|
159
|
-
border: 'border-white/20',
|
|
160
|
-
divider: 'bg-white/20',
|
|
161
|
-
},
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Hook to get theme classes by name.
|
|
166
|
-
*
|
|
167
|
-
* @param {string} themeName - Theme name (light, gray, dark, primary, gradient, glass)
|
|
168
|
-
* @param {Object} overrides - Custom class overrides
|
|
169
|
-
* @returns {Object} Theme classes object
|
|
170
|
-
*/
|
|
171
|
-
export function useTheme(themeName = 'light', overrides = {}) {
|
|
172
|
-
const baseTheme = THEMES[themeName] || THEMES.light
|
|
173
|
-
|
|
174
|
-
if (Object.keys(overrides).length === 0) {
|
|
175
|
-
return baseTheme
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
...baseTheme,
|
|
180
|
-
...overrides,
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Get theme classes without the hook (for non-React contexts)
|
|
186
|
-
* @param {string} themeName
|
|
187
|
-
* @param {Object} overrides
|
|
188
|
-
* @returns {Object}
|
|
189
|
-
*/
|
|
190
|
-
export function getThemeClasses(themeName = 'light', overrides = {}) {
|
|
191
|
-
const baseTheme = THEMES[themeName] || THEMES.light
|
|
192
|
-
return { ...baseTheme, ...overrides }
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* All available theme names
|
|
197
|
-
*/
|
|
198
|
-
export const THEME_NAMES = Object.keys(THEMES)
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Export theme definitions for customization
|
|
202
|
-
*/
|
|
203
|
-
export { THEMES }
|
|
204
|
-
|
|
205
|
-
export default useTheme
|
|
@@ -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'
|