@repraze/kernelkit 0.1.0-alpha.3 → 0.1.0-alpha.4

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.
Files changed (90) hide show
  1. package/README.md +17 -0
  2. package/dist/components/search-dialog.d.ts +6 -1
  3. package/dist/components/search-dialog.d.ts.map +1 -1
  4. package/dist/components/search-dialog.js +13 -1
  5. package/dist/components/search-dialog.js.map +1 -1
  6. package/dist/docs/doc-kit.d.ts +72 -0
  7. package/dist/docs/doc-kit.d.ts.map +1 -0
  8. package/dist/docs/doc-kit.js +45 -0
  9. package/dist/docs/doc-kit.js.map +1 -0
  10. package/dist/docs/doc-page.d.ts +27 -0
  11. package/dist/docs/doc-page.d.ts.map +1 -0
  12. package/dist/docs/doc-page.js +33 -0
  13. package/dist/docs/doc-page.js.map +1 -0
  14. package/dist/docs/docs-index.d.ts +60 -0
  15. package/dist/docs/docs-index.d.ts.map +1 -0
  16. package/dist/docs/docs-index.js +45 -0
  17. package/dist/docs/docs-index.js.map +1 -0
  18. package/dist/docs/docs-nav.d.ts +13 -0
  19. package/dist/docs/docs-nav.d.ts.map +1 -0
  20. package/dist/docs/docs-nav.js +8 -0
  21. package/dist/docs/docs-nav.js.map +1 -0
  22. package/dist/docs/docs-search.d.ts +19 -0
  23. package/dist/docs/docs-search.d.ts.map +1 -0
  24. package/dist/docs/docs-search.js +33 -0
  25. package/dist/docs/docs-search.js.map +1 -0
  26. package/dist/docs/index.d.ts +7 -0
  27. package/dist/docs/index.d.ts.map +1 -0
  28. package/dist/docs/index.js +7 -0
  29. package/dist/docs/index.js.map +1 -0
  30. package/dist/docs/review-matrix.d.ts +25 -0
  31. package/dist/docs/review-matrix.d.ts.map +1 -0
  32. package/dist/docs/review-matrix.js +31 -0
  33. package/dist/docs/review-matrix.js.map +1 -0
  34. package/dist/hooks/use-theme-palette.d.ts +8 -0
  35. package/dist/hooks/use-theme-palette.d.ts.map +1 -0
  36. package/dist/hooks/use-theme-palette.js +24 -0
  37. package/dist/hooks/use-theme-palette.js.map +1 -0
  38. package/dist/provider/theme-provider.d.ts +10 -1
  39. package/dist/provider/theme-provider.d.ts.map +1 -1
  40. package/dist/provider/theme-provider.js +23 -8
  41. package/dist/provider/theme-provider.js.map +1 -1
  42. package/dist/site/brand.d.ts +20 -0
  43. package/dist/site/brand.d.ts.map +1 -0
  44. package/dist/site/brand.js +17 -0
  45. package/dist/site/brand.js.map +1 -0
  46. package/dist/site/github-icon.d.ts +4 -0
  47. package/dist/site/github-icon.d.ts.map +1 -0
  48. package/dist/site/github-icon.js +6 -0
  49. package/dist/site/github-icon.js.map +1 -0
  50. package/dist/site/index.d.ts +6 -0
  51. package/dist/site/index.d.ts.map +1 -0
  52. package/dist/site/index.js +6 -0
  53. package/dist/site/index.js.map +1 -0
  54. package/dist/site/suite-footer.d.ts +24 -0
  55. package/dist/site/suite-footer.d.ts.map +1 -0
  56. package/dist/site/suite-footer.js +19 -0
  57. package/dist/site/suite-footer.js.map +1 -0
  58. package/dist/site/suite-header.d.ts +40 -0
  59. package/dist/site/suite-header.d.ts.map +1 -0
  60. package/dist/site/suite-header.js +19 -0
  61. package/dist/site/suite-header.js.map +1 -0
  62. package/dist/site/use-page-title.d.ts +3 -0
  63. package/dist/site/use-page-title.d.ts.map +1 -0
  64. package/dist/site/use-page-title.js +8 -0
  65. package/dist/site/use-page-title.js.map +1 -0
  66. package/dist/vite/index.d.ts +65 -0
  67. package/dist/vite/index.d.ts.map +1 -0
  68. package/dist/vite/index.js +242 -0
  69. package/dist/vite/index.js.map +1 -0
  70. package/package.json +39 -3
  71. package/src/components/search-dialog.tsx +14 -1
  72. package/src/docs/doc-kit.tsx +176 -0
  73. package/src/docs/doc-page.tsx +126 -0
  74. package/src/docs/docs-index.ts +108 -0
  75. package/src/docs/docs-nav.tsx +47 -0
  76. package/src/docs/docs-search.tsx +88 -0
  77. package/src/docs/index.ts +6 -0
  78. package/src/docs/review-matrix.tsx +67 -0
  79. package/src/hooks/use-theme-palette.ts +25 -0
  80. package/src/provider/theme-provider.tsx +34 -9
  81. package/src/site/brand.tsx +41 -0
  82. package/src/site/github-icon.tsx +10 -0
  83. package/src/site/index.ts +5 -0
  84. package/src/site/suite-footer.tsx +68 -0
  85. package/src/site/suite-header.tsx +114 -0
  86. package/src/site/use-page-title.ts +8 -0
  87. package/src/styles/kernelkit.css +2 -0
  88. package/src/styles/site.css +15 -0
  89. package/src/vite/client.d.ts +11 -0
  90. package/src/vite/index.ts +298 -0
@@ -0,0 +1,108 @@
1
+ import type {DocDef, DocEntry} from "./doc-kit";
2
+
3
+ /** What the nav, search and header need about a docs page, without loading its demos (`virtual:docs-meta`). */
4
+ export interface DocMeta<Category extends string = string> {
5
+ slug: string;
6
+ title: string;
7
+ category: Category;
8
+ description: string;
9
+ order?: number;
10
+ }
11
+
12
+ /** A docs file's module: its `doc` and the demo components it exports. */
13
+ export type DocModule<Category extends string = string> = {doc: DocDef<Category>} & Record<string, unknown>;
14
+
15
+ /** What docsMetaPlugin serves for `<file>.tsx?demo-sources`: each top-level declaration's own source. */
16
+ export interface DemoSources {
17
+ functions: Record<string, string>;
18
+ }
19
+
20
+ type Glob<T> = Record<string, () => Promise<T>>;
21
+
22
+ export interface DocsIndexOptions<Category extends string> {
23
+ /** Category order: the sidebar, search groups and pager follow it. */
24
+ categories: readonly Category[];
25
+ /** `import META from "virtual:docs-meta"`. */
26
+ meta: readonly {slug: string; title: string; category: string; description: string; order?: number}[];
27
+ /** `import.meta.glob("../docs/*.tsx")`: each page is its own chunk, loaded when opened. */
28
+ modules: Glob<unknown>;
29
+ /** `import.meta.glob("../docs/*.tsx", {query: "?demo-sources", import: "default"})`. */
30
+ sources: Glob<unknown>;
31
+ /** Where docs pages live, for `slugOf` (default "/docs"). */
32
+ basePath?: string;
33
+ }
34
+
35
+ export interface DocsIndex<Category extends string = string> {
36
+ categories: readonly Category[];
37
+ /** Every page, in category order, then `order`, then title. */
38
+ docs: DocMeta<Category>[];
39
+ bySlug: Map<string, DocMeta<Category>>;
40
+ /** Non-empty categories with their pages, for the sidebar and search. */
41
+ byCategory: {category: Category; docs: DocMeta<Category>[]}[];
42
+ /** Load a page's module and its demos' sources; undefined for an unknown slug. */
43
+ loadDoc(slug: string): Promise<DocEntry<Category> | undefined>;
44
+ /** The pages before and after one, for the Pager. */
45
+ neighbours(slug: string): {prev?: DocMeta<Category>; next?: DocMeta<Category>};
46
+ /** The docs slug in a pathname ("/base/docs/button" → "button"), or undefined outside the docs. */
47
+ slugOf(pathname: string): string | undefined;
48
+ }
49
+
50
+ const slugOfFile = (path: string) => /([^/]+)\.tsx$/.exec(path)?.[1];
51
+
52
+ /** The site's docs index, from the build-time manifest and the per-page globs. */
53
+ export function createDocsIndex<Category extends string>({
54
+ categories,
55
+ meta,
56
+ modules,
57
+ sources,
58
+ basePath = "/docs",
59
+ }: DocsIndexOptions<Category>): DocsIndex<Category> {
60
+ const moduleBySlug = new Map(Object.entries(modules).map(([path, load]) => [slugOfFile(path), load]));
61
+ const sourcesBySlug = new Map(Object.entries(sources).map(([path, load]) => [slugOfFile(path), load]));
62
+
63
+ const docs = (meta as DocMeta<Category>[]).toSorted(
64
+ (a, b) =>
65
+ categories.indexOf(a.category) - categories.indexOf(b.category) ||
66
+ (a.order ?? 50) - (b.order ?? 50) ||
67
+ a.title.localeCompare(b.title),
68
+ );
69
+ const bySlug = new Map(docs.map((d) => [d.slug, d]));
70
+ const byCategory = categories
71
+ .map((category) => ({category, docs: docs.filter((d) => d.category === category)}))
72
+ .filter((group) => group.docs.length > 0);
73
+ const pathRe = new RegExp(`${basePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([^/]+)`);
74
+
75
+ return {
76
+ categories,
77
+ docs,
78
+ bySlug,
79
+ byCategory,
80
+ async loadDoc(slug) {
81
+ const load = moduleBySlug.get(slug);
82
+ const loadSources = sourcesBySlug.get(slug);
83
+ if (!load || !loadSources) {
84
+ return undefined;
85
+ }
86
+ const [mod, demoSources] = (await Promise.all([load(), loadSources()])) as [
87
+ DocModule<Category>,
88
+ DemoSources,
89
+ ];
90
+ // Match demos to their export by identity: function names don't survive minification.
91
+ const exportName = (component: unknown) => Object.keys(mod).find((key) => mod[key] === component) ?? "";
92
+ return {
93
+ ...mod.doc,
94
+ slug,
95
+ demoSources: mod.doc.demos.map((demo) => demoSources.functions[exportName(demo.component)] ?? ""),
96
+ };
97
+ },
98
+ neighbours(slug) {
99
+ const doc = bySlug.get(slug);
100
+ const index = doc ? docs.indexOf(doc) : -1;
101
+ return index < 0 ? {} : {prev: docs[index - 1], next: docs[index + 1]};
102
+ },
103
+ slugOf(pathname) {
104
+ const slug = pathRe.exec(pathname)?.[1];
105
+ return slug && bySlug.has(slug) ? slug : undefined;
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import {
4
+ SidebarGroup,
5
+ SidebarGroupLabel,
6
+ SidebarMenu,
7
+ SidebarMenuButton,
8
+ SidebarMenuItem,
9
+ } from "@repraze/kernelkit/components/sidebar";
10
+
11
+ import type {DocLinkFor} from "./doc-page";
12
+ import type {DocsIndex} from "./docs-index";
13
+
14
+ export interface DocsNavProps {
15
+ index: DocsIndex;
16
+ /** The open page's slug (`index.slugOf(pathname)`), marked current. */
17
+ current?: string;
18
+ linkFor: DocLinkFor;
19
+ /** Called when a link is clicked (the phone menu closes). */
20
+ onNavigate?: () => void;
21
+ }
22
+
23
+ /** Docs navigation, one Sidebar group per category: the desktop SiteSidebar and the phone menu share it. */
24
+ export function DocsNav({index, current, linkFor, onNavigate}: DocsNavProps) {
25
+ return (
26
+ <nav aria-label="Docs" className="flex flex-col">
27
+ {index.byCategory.map(({category, docs}) => (
28
+ <SidebarGroup key={category}>
29
+ <SidebarGroupLabel className="font-mono uppercase">{category}</SidebarGroupLabel>
30
+ <SidebarMenu>
31
+ {docs.map((doc) => (
32
+ <SidebarMenuItem key={doc.slug}>
33
+ <SidebarMenuButton
34
+ isActive={doc.slug === current}
35
+ render={linkFor(doc.slug)}
36
+ onClick={onNavigate}
37
+ >
38
+ <span>{doc.title}</span>
39
+ </SidebarMenuButton>
40
+ </SidebarMenuItem>
41
+ ))}
42
+ </SidebarMenu>
43
+ </SidebarGroup>
44
+ ))}
45
+ </nav>
46
+ );
47
+ }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import {Command, CommandEmpty, CommandGroup, CommandInput, CommandList} from "@repraze/kernelkit/components/command";
4
+ import {SearchDialog, SearchFooter, SearchItem, substringFilter} from "@repraze/kernelkit/components/search-dialog";
5
+ import {SearchTrigger, useSearchHotkeys} from "@repraze/kernelkit/components/search-trigger";
6
+ import {FileTextIcon, type LucideIcon} from "lucide-react";
7
+ import {useCallback, useState} from "react";
8
+
9
+ import type {DocsIndex} from "./docs-index";
10
+
11
+ export interface DocsSearchProps {
12
+ index: DocsIndex;
13
+ /** One icon per category, so a result's kind reads at a glance; `fallbackIcon` for the others. */
14
+ icons?: Record<string, LucideIcon>;
15
+ fallbackIcon?: LucideIcon;
16
+ /** Slugs shown before the first keystroke, in a "Suggested" group. */
17
+ suggested?: string[];
18
+ placeholder?: string;
19
+ /** Go to a page (the router's navigate). */
20
+ onSelect: (slug: string) => void;
21
+ }
22
+
23
+ /**
24
+ * The suite's docs search: SearchTrigger + hotkeys (Ctrl/⌘ K, `/`) opening a SearchDialog over the docs
25
+ * index, grouped by category, with a plain substring filter.
26
+ */
27
+ export function DocsSearch({
28
+ index,
29
+ icons = {},
30
+ fallbackIcon = FileTextIcon,
31
+ suggested = [],
32
+ placeholder = "Search the docs…",
33
+ onSelect,
34
+ }: DocsSearchProps) {
35
+ const [open, setOpen] = useState(false);
36
+ const [query, setQuery] = useState("");
37
+ useSearchHotkeys(useCallback((toggle: boolean) => setOpen((o) => (toggle ? !o : true)), []));
38
+
39
+ const groups = query.trim()
40
+ ? index.byCategory
41
+ : [{category: "Suggested", docs: suggested.flatMap((slug) => index.docs.filter((d) => d.slug === slug))}];
42
+
43
+ return (
44
+ <>
45
+ <SearchTrigger onClick={() => setOpen(true)} aria-label="Search docs" />
46
+ <SearchDialog
47
+ open={open}
48
+ onOpenChange={(next) => {
49
+ setOpen(next);
50
+ if (!next) {
51
+ setQuery("");
52
+ }
53
+ }}
54
+ description="Search the docs"
55
+ >
56
+ <Command filter={substringFilter} loop>
57
+ <CommandInput value={query} onValueChange={setQuery} placeholder={placeholder} />
58
+ <CommandList>
59
+ <CommandEmpty>No results for “{query}”.</CommandEmpty>
60
+ {groups.map(({category, docs}) => (
61
+ <CommandGroup key={category} heading={category}>
62
+ {docs.map((doc) => {
63
+ const Icon = icons[doc.category] ?? fallbackIcon;
64
+ return (
65
+ <SearchItem
66
+ key={doc.slug}
67
+ value={doc.title}
68
+ keywords={[doc.category, doc.slug]}
69
+ icon={<Icon />}
70
+ title={doc.title}
71
+ description={doc.description}
72
+ onSelect={() => {
73
+ setOpen(false);
74
+ setQuery("");
75
+ onSelect(doc.slug);
76
+ }}
77
+ />
78
+ );
79
+ })}
80
+ </CommandGroup>
81
+ ))}
82
+ </CommandList>
83
+ <SearchFooter />
84
+ </Command>
85
+ </SearchDialog>
86
+ </>
87
+ );
88
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./doc-kit";
2
+ export * from "./doc-page";
3
+ export * from "./docs-index";
4
+ export * from "./docs-nav";
5
+ export * from "./docs-search";
6
+ export * from "./review-matrix";
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import type {Accent, Radius, ResolvedMode} from "@repraze/kernelkit/provider";
4
+ import {ThemeScope, type ThemeScopeProps} from "@repraze/kernelkit/provider";
5
+ import {cn} from "cn";
6
+
7
+ import type {DocEntry} from "./doc-kit";
8
+
9
+ export interface ReviewScope {
10
+ label: string;
11
+ scope: Omit<ThemeScopeProps, "children">;
12
+ }
13
+
14
+ /**
15
+ * The GUIDELINES §8 scopes: dark, light and both high-contrast modes in the site's own accent and radius,
16
+ * then two variants (by default dark · radius-none · cyan and light · radius-xl · yellow).
17
+ */
18
+ export function reviewScopes(
19
+ site: {accent: Accent; radius: Radius},
20
+ variants: {mode: ResolvedMode; accent: Accent; radius: Radius}[] = [
21
+ {mode: "dark", accent: "cyan", radius: "none"},
22
+ {mode: "light", accent: "yellow", radius: "xl"},
23
+ ],
24
+ ): ReviewScope[] {
25
+ return [
26
+ {label: "dark", scope: {mode: "dark", contrast: "normal", ...site}},
27
+ {label: "light", scope: {mode: "light", contrast: "normal", ...site}},
28
+ {label: "dark · hc", scope: {mode: "dark", contrast: "high", ...site}},
29
+ {label: "light · hc", scope: {mode: "light", contrast: "high", ...site}},
30
+ ...variants.map(({mode, accent, radius}) => ({
31
+ label: `${mode} · radius-${radius} · ${accent}`,
32
+ scope: {mode, contrast: "normal" as const, accent, radius},
33
+ })),
34
+ ];
35
+ }
36
+
37
+ /** Every demo of a page in each scope, side by side, for visual review (GUIDELINES §8). */
38
+ export function ReviewMatrix({doc, scopes}: {doc: DocEntry; scopes: ReviewScope[]}) {
39
+ return (
40
+ <div className="flex flex-col gap-6 p-6" data-slot="review">
41
+ <h1 className="font-mono text-lg font-semibold">{doc.title} · review</h1>
42
+ {doc.demos.map((demo) => {
43
+ const Component = demo.component;
44
+ return (
45
+ <section key={demo.title} className="flex flex-col gap-2">
46
+ <h2 className="font-mono text-sm text-muted-foreground">{demo.title}</h2>
47
+ <div className="grid grid-cols-2 gap-px overflow-hidden border bg-border">
48
+ {scopes.map(({label, scope}) => (
49
+ <ThemeScope key={label} {...scope} className="flex flex-col gap-3 p-4">
50
+ <span className="font-mono text-[10px] text-muted-foreground">{label}</span>
51
+ <div
52
+ className={cn(
53
+ "flex min-h-24 flex-1",
54
+ demo.layout === "full" ? "" : "items-center justify-center",
55
+ )}
56
+ >
57
+ <Component />
58
+ </div>
59
+ </ThemeScope>
60
+ ))}
61
+ </div>
62
+ </section>
63
+ );
64
+ })}
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,25 @@
1
+ import {useResolvedTheme} from "@repraze/kernelkit/provider";
2
+ import {allModes, createPalette, type Mode} from "@repraze/termicolor";
3
+ import {useMemo} from "react";
4
+
5
+ const cache = new Map<string, Mode>();
6
+
7
+ /**
8
+ * The TermiColor mode that applies here (nearest ThemeScope, else the ThemeProvider): neutrals, accents
9
+ * and roles as hex strings, with `roles.accent` & co. in the current accent. For canvas, chart and WebGPU
10
+ * code that can't read CSS variables. Memoised per mode, contrast and accent.
11
+ */
12
+ export function useThemePalette(): Mode {
13
+ const {mode, contrast, accent} = useResolvedTheme();
14
+ return useMemo(() => {
15
+ const id = `${mode}${contrast === "high" ? "-hc" : ""}`;
16
+ const key = `${id}:${accent}`;
17
+ let found = cache.get(key);
18
+ if (!found) {
19
+ const modes = allModes(createPalette({accent}));
20
+ found = modes.find((m) => m.id === id) ?? modes[0]!;
21
+ cache.set(key, found);
22
+ }
23
+ return found;
24
+ }, [mode, contrast, accent]);
25
+ }
@@ -40,6 +40,9 @@ export interface ThemeContextValue extends ThemeState {
40
40
 
41
41
  const ThemeContext = createContext<ThemeContextValue | null>(null);
42
42
 
43
+ /** The nearest ThemeScope's resolved theme, so code that paints (canvas, charts) can follow a scope. */
44
+ const ScopeContext = createContext<ResolvedTheme | null>(null);
45
+
43
46
  /** Element overlays portal into, so popups inside a ThemeScope keep its theme. */
44
47
  const PortalContainerContext = createContext<HTMLElement | null>(null);
45
48
 
@@ -181,6 +184,23 @@ export function useTheme(): ThemeContextValue {
181
184
  return ctx;
182
185
  }
183
186
 
187
+ /** A theme with its mode resolved (never "system"). */
188
+ export type ResolvedTheme = Omit<ThemeState, "mode"> & {mode: ResolvedMode};
189
+
190
+ /**
191
+ * The theme that applies here: the nearest ThemeScope's, else the provider's with "system" resolved.
192
+ * Use it for things CSS can't reach (canvas, WebGL/WebGPU, charts) so they follow review scopes too.
193
+ */
194
+ export function useResolvedTheme(): ResolvedTheme {
195
+ const scope = useContext(ScopeContext);
196
+ const ctx = useContext(ThemeContext);
197
+ const mode = scope?.mode ?? ctx?.resolvedMode ?? "light";
198
+ const contrast = scope?.contrast ?? ctx?.contrast ?? DEFAULT_THEME.contrast;
199
+ const accent = scope?.accent ?? ctx?.accent ?? DEFAULT_THEME.accent;
200
+ const radius = scope?.radius ?? ctx?.radius ?? DEFAULT_THEME.radius;
201
+ return useMemo(() => ({mode, contrast, accent, radius}), [mode, contrast, accent, radius]);
202
+ }
203
+
184
204
  /** Renders the pre-paint script. Place in <head> (Next.js: in the root layout). */
185
205
  export function ThemeScript({nonce, ...options}: ThemeScriptOptions & {nonce?: string}) {
186
206
  return <script nonce={nonce} suppressHydrationWarning dangerouslySetInnerHTML={{__html: themeScript(options)}} />;
@@ -188,7 +208,7 @@ export function ThemeScript({nonce, ...options}: ThemeScriptOptions & {nonce?: s
188
208
 
189
209
  export interface ThemeScopeProps extends Omit<React.ComponentProps<"div">, "children"> {
190
210
  children?: ReactNode;
191
- /** Values not given inherit from the surrounding <ThemeProvider> (or the defaults). */
211
+ /** Values not given inherit from the surrounding ThemeScope or <ThemeProvider> (or the defaults). */
192
212
  mode?: ResolvedMode;
193
213
  contrast?: ThemeState["contrast"];
194
214
  accent?: ThemeState["accent"];
@@ -200,14 +220,17 @@ export interface ThemeScopeProps extends Omit<React.ComponentProps<"div">, "chil
200
220
  * the scope, so they keep its colors and radius.
201
221
  */
202
222
  export function ThemeScope({mode, contrast, accent, radius, className, children, ...props}: ThemeScopeProps) {
203
- const parent = useContext(ThemeContext);
223
+ const parent = useResolvedTheme();
204
224
  const [container, setContainer] = useState<HTMLDivElement | null>(null);
205
- const theme = {
206
- mode: mode ?? parent?.resolvedMode ?? "light",
207
- contrast: contrast ?? parent?.contrast ?? DEFAULT_THEME.contrast,
208
- accent: accent ?? parent?.accent ?? DEFAULT_THEME.accent,
209
- radius: radius ?? parent?.radius ?? DEFAULT_THEME.radius,
210
- };
225
+ const theme = useMemo(
226
+ () => ({
227
+ mode: mode ?? parent.mode,
228
+ contrast: contrast ?? parent.contrast,
229
+ accent: accent ?? parent.accent,
230
+ radius: radius ?? parent.radius,
231
+ }),
232
+ [mode, contrast, accent, radius, parent],
233
+ );
211
234
  const classes = themeClassName(theme);
212
235
  return (
213
236
  <div
@@ -215,7 +238,9 @@ export function ThemeScope({mode, contrast, accent, radius, className, children,
215
238
  className={[classes, "bg-background text-foreground", className].filter(Boolean).join(" ")}
216
239
  {...props}
217
240
  >
218
- <PortalContainerContext.Provider value={container}>{children}</PortalContainerContext.Provider>
241
+ <ScopeContext.Provider value={theme}>
242
+ <PortalContainerContext.Provider value={container}>{children}</PortalContainerContext.Provider>
243
+ </ScopeContext.Provider>
219
244
  <div ref={setContainer} data-slot="theme-scope-portal" className={classes} />
220
245
  </div>
221
246
  );
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import {cn} from "cn";
4
+
5
+ /**
6
+ * A site's brand mark (an SVG string from its `brand/mark.ts`), inlined. Size it in multiples of 32px
7
+ * (default `size-8`) so it stays pixel-sharp (GUIDELINES §7b).
8
+ */
9
+ export function BrandMark({svg, className}: {svg: string; className?: string}) {
10
+ return (
11
+ <span
12
+ aria-hidden
13
+ className={cn("inline-block size-8 shrink-0 leading-0", className)}
14
+ // Static markup generated by the site from its own mark shapes, never from user input.
15
+ dangerouslySetInnerHTML={{__html: svg}}
16
+ />
17
+ );
18
+ }
19
+
20
+ export interface BrandLogoProps {
21
+ /** The mark's SVG markup. */
22
+ svg: string;
23
+ /** The wordmark's first word, in the foreground ("Kernel"). */
24
+ first: string;
25
+ /** Its second word, in the accent text tier ("Kit"). */
26
+ second: string;
27
+ className?: string;
28
+ }
29
+
30
+ /** Mark + two-tone wordmark in mono semibold: Kernel**Kit**, Termi**Color** (the suite's brand pattern). */
31
+ export function BrandLogo({svg, first, second, className}: BrandLogoProps) {
32
+ return (
33
+ <span className={cn("inline-flex items-center gap-2", className)}>
34
+ <BrandMark svg={svg} />
35
+ <span className="font-mono text-sm font-semibold tracking-tight">
36
+ {first}
37
+ <span className="text-primary-text">{second}</span>
38
+ </span>
39
+ </span>
40
+ );
41
+ }
@@ -0,0 +1,10 @@
1
+ import type * as React from "react";
2
+
3
+ /** GitHub mark (lucide no longer ships brand icons). */
4
+ export function GithubIcon(props: React.SVGProps<SVGSVGElement>) {
5
+ return (
6
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
7
+ <path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8.1 3.2.8.8 1.3 1.9 1.3 3.1 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1.1.9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3" />
8
+ </svg>
9
+ );
10
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./brand";
2
+ export * from "./github-icon";
3
+ export * from "./suite-footer";
4
+ export * from "./suite-header";
5
+ export * from "./use-page-title";
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import {SiteFooter, SiteFooterLink, SiteFooterLinks} from "@repraze/kernelkit/components/site-footer";
4
+ import type * as React from "react";
5
+ import {Fragment} from "react";
6
+
7
+ export interface SuiteFooterLinkItem {
8
+ label: string;
9
+ /** A router link as a `render` element; or `href` for a plain anchor. */
10
+ render?: React.ReactElement;
11
+ href?: string;
12
+ }
13
+
14
+ /** Suite projects a site can credit, in this order: "built with KernelKit", "themed by TermiColor". */
15
+ export type SuiteCredit = "kernelkit" | "termicolor";
16
+
17
+ const CREDITS: Record<SuiteCredit, {text: string; name: string; href: string}> = {
18
+ kernelkit: {text: "built with", name: "KernelKit", href: "https://repraze.github.io/kernelkit/"},
19
+ termicolor: {text: "themed by", name: "TermiColor", href: "https://repraze.github.io/termicolor/"},
20
+ };
21
+
22
+ export interface SuiteFooterProps {
23
+ links: SuiteFooterLinkItem[];
24
+ /** Which suite projects to credit (default `["termicolor"]`). */
25
+ credits?: SuiteCredit[];
26
+ /** A phrase of the site's own after the credits ("built from one palette"). */
27
+ tagline?: React.ReactNode;
28
+ /** Default "MIT". */
29
+ license?: string;
30
+ }
31
+
32
+ /**
33
+ * The suite's footer (GUIDELINES §7b): "MIT · themed by TermiColor · a repraze.com project" on the left,
34
+ * the site's links on the right.
35
+ */
36
+ export function SuiteFooter({links, credits = ["termicolor"], tagline, license = "MIT"}: SuiteFooterProps) {
37
+ return (
38
+ <SiteFooter>
39
+ <span>
40
+ {license}
41
+ {credits.map((key) => {
42
+ const credit = CREDITS[key];
43
+ return (
44
+ <Fragment key={key}>
45
+ {" "}
46
+ · {credit.text}{" "}
47
+ <SiteFooterLink href={credit.href} className="underline underline-offset-4">
48
+ {credit.name}
49
+ </SiteFooterLink>
50
+ </Fragment>
51
+ );
52
+ })}
53
+ {tagline ? <> · {tagline}</> : null} · a{" "}
54
+ <SiteFooterLink href="https://repraze.com" className="underline underline-offset-4">
55
+ repraze.com
56
+ </SiteFooterLink>{" "}
57
+ project
58
+ </span>
59
+ <SiteFooterLinks aria-label="Footer">
60
+ {links.map((link) => (
61
+ <SiteFooterLink key={link.label} href={link.href} render={link.render}>
62
+ {link.label}
63
+ </SiteFooterLink>
64
+ ))}
65
+ </SiteFooterLinks>
66
+ </SiteFooter>
67
+ );
68
+ }
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import {Button} from "@repraze/kernelkit/components/button";
4
+ import {Separator} from "@repraze/kernelkit/components/separator";
5
+ import {
6
+ SiteHeader,
7
+ SiteHeaderActions,
8
+ SiteHeaderBrand,
9
+ SiteHeaderContent,
10
+ SiteHeaderMenu,
11
+ SiteNav,
12
+ SiteNavLink,
13
+ } from "@repraze/kernelkit/components/site-header";
14
+ import {ThemeMenu} from "@repraze/kernelkit/components/theme-menu";
15
+ import type * as React from "react";
16
+ import {useEffect, useState} from "react";
17
+
18
+ import {GithubIcon} from "./github-icon";
19
+
20
+ export interface SuiteNavItem {
21
+ label: string;
22
+ /** A router link as a `render` element (`<Link to="/ports" />`); or `href` for a plain anchor. */
23
+ render?: React.ReactElement;
24
+ href?: string;
25
+ /** Marks the entry current (`aria-current="page"`): the section the open page belongs to. */
26
+ current?: boolean;
27
+ }
28
+
29
+ export interface SuiteHeaderProps {
30
+ /** The brand (a BrandLogo), in the header and as the phone menu's title. */
31
+ brand: React.ReactNode;
32
+ /** Accessible name of the home link, e.g. "KernelKit home". */
33
+ brandLabel: string;
34
+ /** The home link as a `render` element (`<Link to="/" />`); a plain "/" anchor otherwise. */
35
+ home?: React.ReactElement;
36
+ nav: SuiteNavItem[];
37
+ /** Accessible name of the nav (default "Main"). */
38
+ navLabel?: string;
39
+ /** The search (DocsSearch, or the site's own), before the separator. */
40
+ search?: React.ReactNode;
41
+ /** Replaces the default `<ThemeMenu />`. */
42
+ themeMenu?: React.ReactNode;
43
+ /** The repository button (a GitHub icon). */
44
+ repository?: {href: string; label?: string; icon?: React.ReactNode};
45
+ /** The phone menu's content after the nav links (the docs nav, a tree). */
46
+ menu?: React.ReactNode;
47
+ /** Closes the phone menu when it changes: pass the pathname. */
48
+ locationKey?: string;
49
+ }
50
+
51
+ /**
52
+ * The suite's site header (GUIDELINES §7b): phone menu, brand, mono nav, then search · theme · repository.
53
+ * Every site renders this; only the brand, nav and menu content differ.
54
+ */
55
+ export function SuiteHeader({
56
+ brand,
57
+ brandLabel,
58
+ home,
59
+ nav,
60
+ navLabel = "Main",
61
+ search,
62
+ themeMenu,
63
+ repository,
64
+ menu,
65
+ locationKey,
66
+ }: SuiteHeaderProps) {
67
+ const [menuOpen, setMenuOpen] = useState(false);
68
+ useEffect(() => setMenuOpen(false), [locationKey]);
69
+
70
+ const navLinks = nav.map((item) => (
71
+ <SiteNavLink
72
+ key={item.label}
73
+ href={item.href}
74
+ aria-current={item.current ? "page" : undefined}
75
+ render={item.render}
76
+ >
77
+ {item.label}
78
+ </SiteNavLink>
79
+ ));
80
+
81
+ return (
82
+ <SiteHeader>
83
+ <SiteHeaderContent>
84
+ <SiteHeaderMenu title={brand} open={menuOpen} onOpenChange={setMenuOpen}>
85
+ <nav aria-label={navLabel} className="flex flex-col gap-1 px-2 pt-2">
86
+ {navLinks}
87
+ </nav>
88
+ {menu}
89
+ </SiteHeaderMenu>
90
+ <SiteHeaderBrand aria-label={brandLabel} href={home ? undefined : "/"} render={home}>
91
+ {brand}
92
+ </SiteHeaderBrand>
93
+ <SiteNav aria-label={navLabel}>{navLinks}</SiteNav>
94
+ <SiteHeaderActions>
95
+ {search}
96
+ <Separator
97
+ orientation="vertical"
98
+ className="mx-1 hidden data-vertical:h-5 data-vertical:self-center sm:block"
99
+ />
100
+ {themeMenu ?? <ThemeMenu />}
101
+ {repository ? (
102
+ <Button
103
+ variant="ghost"
104
+ size="icon"
105
+ render={<a href={repository.href} aria-label={repository.label ?? "GitHub"} />}
106
+ >
107
+ {repository.icon ?? <GithubIcon className="size-4" />}
108
+ </Button>
109
+ ) : null}
110
+ </SiteHeaderActions>
111
+ </SiteHeaderContent>
112
+ </SiteHeader>
113
+ );
114
+ }