@repraze/kernelkit 0.1.0-alpha.0 → 0.1.0-alpha.2

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 (50) hide show
  1. package/dist/components/calendar.js +1 -1
  2. package/dist/components/calendar.js.map +1 -1
  3. package/dist/components/carousel.d.ts +6 -1
  4. package/dist/components/carousel.d.ts.map +1 -1
  5. package/dist/components/carousel.js +30 -2
  6. package/dist/components/carousel.js.map +1 -1
  7. package/dist/components/command.d.ts +1 -1
  8. package/dist/components/command.d.ts.map +1 -1
  9. package/dist/components/command.js +2 -2
  10. package/dist/components/command.js.map +1 -1
  11. package/dist/components/dropdown-menu.js +1 -1
  12. package/dist/components/dropdown-menu.js.map +1 -1
  13. package/dist/components/hero.d.ts +14 -2
  14. package/dist/components/hero.d.ts.map +1 -1
  15. package/dist/components/hero.js +41 -8
  16. package/dist/components/hero.js.map +1 -1
  17. package/dist/components/navigation-menu.d.ts.map +1 -1
  18. package/dist/components/navigation-menu.js +3 -1
  19. package/dist/components/navigation-menu.js.map +1 -1
  20. package/dist/components/sidebar.d.ts.map +1 -1
  21. package/dist/components/sidebar.js +3 -2
  22. package/dist/components/sidebar.js.map +1 -1
  23. package/dist/components/site-header.d.ts +3 -3
  24. package/dist/components/site-header.d.ts.map +1 -1
  25. package/dist/components/site-header.js +5 -7
  26. package/dist/components/site-header.js.map +1 -1
  27. package/dist/components/theme-menu.d.ts +49 -0
  28. package/dist/components/theme-menu.d.ts.map +1 -0
  29. package/dist/components/theme-menu.js +75 -0
  30. package/dist/components/theme-menu.js.map +1 -0
  31. package/dist/lib/recipes.d.ts +7 -1
  32. package/dist/lib/recipes.d.ts.map +1 -1
  33. package/dist/lib/recipes.js +7 -1
  34. package/dist/lib/recipes.js.map +1 -1
  35. package/dist/provider/theme-provider.d.ts +4 -0
  36. package/dist/provider/theme-provider.d.ts.map +1 -1
  37. package/dist/provider/theme-provider.js +14 -1
  38. package/dist/provider/theme-provider.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/components/calendar.tsx +1 -1
  41. package/src/components/carousel.tsx +35 -0
  42. package/src/components/command.tsx +2 -3
  43. package/src/components/dropdown-menu.tsx +1 -1
  44. package/src/components/hero.tsx +77 -7
  45. package/src/components/navigation-menu.tsx +4 -0
  46. package/src/components/sidebar.tsx +3 -2
  47. package/src/components/site-header.tsx +6 -7
  48. package/src/components/theme-menu.tsx +243 -0
  49. package/src/lib/recipes.ts +9 -2
  50. package/src/provider/theme-provider.tsx +18 -1
@@ -95,9 +95,9 @@ function SiteNav({className, render, ...props}: useRender.ComponentProps<"nav">)
95
95
  /**
96
96
  * A nav link, styled like the suite's other navigation (GUIDELINES §7c): muted at rest, a hover wash,
97
97
  * and when current (`aria-current="page"`, `data-status="active"` from TanStack Router, or `data-active`)
98
- * foreground text plus a 2px accent indicator. In SiteNav the indicator sits on the header's bottom
99
- * border (like line Tabs); elsewhere (the phone menu) the link is a sidebar row with a tint and the
100
- * indicator on its left edge.
98
+ * the sidebar's current-row look: accent tint, foreground text and a 2px accent pill. In SiteNav the pill
99
+ * runs along the link's bottom edge (like NavigationMenu); elsewhere (the phone menu) it sits on the
100
+ * left edge, like a sidebar row.
101
101
  */
102
102
  function SiteNavLink({className, render, ...props}: useRender.ComponentProps<"a">) {
103
103
  return useRender({
@@ -107,12 +107,11 @@ function SiteNavLink({className, render, ...props}: useRender.ComponentProps<"a"
107
107
  className: cn(
108
108
  "relative flex h-8 items-center rounded-md px-2.5 font-mono text-sm text-muted-foreground transition-colors hover:bg-hover hover:text-foreground motion-reduce:transition-none",
109
109
  focusRing,
110
- // indicator: vertical by default, on the header border inside SiteNav
110
+ // indicator: left edge by default, bottom edge inside SiteNav
111
111
  "after:pointer-events-none after:absolute after:top-1.5 after:bottom-1.5 after:left-0 after:w-0.5 after:rounded-pill after:bg-primary-text after:opacity-0",
112
- "in-data-[slot=site-nav]:after:top-auto in-data-[slot=site-nav]:after:right-2.5 in-data-[slot=site-nav]:after:-bottom-[calc(0.75rem+1px)] in-data-[slot=site-nav]:after:left-2.5 in-data-[slot=site-nav]:after:h-0.5 in-data-[slot=site-nav]:after:w-auto",
112
+ "in-data-[slot=site-nav]:after:top-auto in-data-[slot=site-nav]:after:right-2.5 in-data-[slot=site-nav]:after:bottom-0 in-data-[slot=site-nav]:after:left-2.5 in-data-[slot=site-nav]:after:h-0.5 in-data-[slot=site-nav]:after:w-auto",
113
113
  "aria-[current=page]:text-foreground aria-[current=page]:after:opacity-100 data-[status=active]:text-foreground data-[status=active]:after:opacity-100 data-active:text-foreground data-active:after:opacity-100",
114
- // outside the header row: the sidebar's current-row tint
115
- "not-in-data-[slot=site-nav]:aria-[current=page]:bg-primary-tint not-in-data-[slot=site-nav]:data-[status=active]:bg-primary-tint not-in-data-[slot=site-nav]:data-active:bg-primary-tint",
114
+ "aria-[current=page]:bg-primary-tint data-[status=active]:bg-primary-tint data-active:bg-primary-tint",
116
115
  className,
117
116
  ),
118
117
  },
@@ -0,0 +1,243 @@
1
+ "use client";
2
+
3
+ import {Button} from "@repraze/kernelkit/components/button";
4
+ import {Label} from "@repraze/kernelkit/components/label";
5
+ import {
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverHeader,
9
+ PopoverTitle,
10
+ PopoverTrigger,
11
+ } from "@repraze/kernelkit/components/popover";
12
+ import {Switch} from "@repraze/kernelkit/components/switch";
13
+ import {ToggleGroup, ToggleGroupItem} from "@repraze/kernelkit/components/toggle-group";
14
+ import {Tooltip, TooltipContent, TooltipTrigger} from "@repraze/kernelkit/components/tooltip";
15
+ import {type Accent, ACCENTS, type Mode, RADII, type Radius, useTheme} from "@repraze/kernelkit/provider";
16
+ import {cn} from "cn";
17
+ import {CheckIcon, type LucideIcon, MonitorIcon, MoonIcon, RotateCcwIcon, SunIcon} from "lucide-react";
18
+ import * as React from "react";
19
+
20
+ /** Mode choices in display order, with the icon the trigger shows for each. */
21
+ const THEME_MODES: readonly {value: Mode; label: string; icon: LucideIcon}[] = [
22
+ {value: "light", label: "Light", icon: SunIcon},
23
+ {value: "dark", label: "Dark", icon: MoonIcon},
24
+ {value: "system", label: "System", icon: MonitorIcon},
25
+ ];
26
+
27
+ const modeOption = (mode: Mode) => THEME_MODES.find((m) => m.value === mode) ?? THEME_MODES[2]!;
28
+
29
+ /** The icon for the chosen mode (not the resolved one): sun, moon, or monitor for "system". */
30
+ function ThemeModeIcon({mode, ...props}: {mode?: Mode} & React.ComponentProps<LucideIcon>) {
31
+ const theme = useTheme();
32
+ const Icon = modeOption(mode ?? theme.mode).icon;
33
+ return <Icon data-slot="theme-mode-icon" {...props} />;
34
+ }
35
+
36
+ /** Light / dark / system as a segmented control. */
37
+ function ThemeModePicker({className}: {className?: string}) {
38
+ const theme = useTheme();
39
+ return (
40
+ <ToggleGroup
41
+ data-slot="theme-mode-picker"
42
+ aria-label="Mode"
43
+ variant="outline"
44
+ size="sm"
45
+ value={[theme.mode]}
46
+ onValueChange={(v) => v[0] && theme.setMode(v[0] as Mode)}
47
+ className={cn("w-full", className)}
48
+ >
49
+ {THEME_MODES.map(({value, label, icon: Icon}) => (
50
+ <ToggleGroupItem key={value} value={value} className="flex-1">
51
+ <Icon /> {label}
52
+ </ToggleGroupItem>
53
+ ))}
54
+ </ToggleGroup>
55
+ );
56
+ }
57
+
58
+ /** Swatch per accent: the vivid fill tier, with that accent's own on-fill color for the check mark. */
59
+ const swatch = (accent: Accent) => ({
60
+ background: `var(--tc-accent-${accent}-accent-fill)`,
61
+ color: `var(--tc-accent-${accent}-on-accent-fill)`,
62
+ });
63
+
64
+ /** The eight TermiColor accents as swatches; sets the theme's accent. */
65
+ function ThemeAccentPicker({className}: {className?: string}) {
66
+ const theme = useTheme();
67
+ return (
68
+ <div
69
+ data-slot="theme-accent-picker"
70
+ role="group"
71
+ aria-label="Accent"
72
+ className={cn("grid grid-cols-8 gap-1.5", className)}
73
+ >
74
+ {ACCENTS.map((accent) => {
75
+ const on = theme.accent === accent;
76
+ return (
77
+ <Tooltip key={accent}>
78
+ <TooltipTrigger
79
+ render={
80
+ <button
81
+ type="button"
82
+ aria-label={accent}
83
+ aria-pressed={on}
84
+ onClick={() => theme.setAccent(accent)}
85
+ style={swatch(accent)}
86
+ className={cn(
87
+ "flex aspect-square items-center justify-center rounded-md outline-none",
88
+ "ring-offset-2 ring-offset-transparent transition-shadow duration-100 motion-reduce:transition-none",
89
+ "hover:ring-2 hover:ring-input focus-visible:ring-2 focus-visible:ring-ring",
90
+ on && "ring-2 ring-foreground hover:ring-foreground",
91
+ )}
92
+ />
93
+ }
94
+ >
95
+ {on ? <CheckIcon className="size-3.5" strokeWidth={3} /> : null}
96
+ </TooltipTrigger>
97
+ <TooltipContent className="capitalize">{accent}</TooltipContent>
98
+ </Tooltip>
99
+ );
100
+ })}
101
+ </div>
102
+ );
103
+ }
104
+
105
+ /** The radius scale as a segmented control; sets the theme's radius. */
106
+ function ThemeRadiusPicker({className}: {className?: string}) {
107
+ const theme = useTheme();
108
+ return (
109
+ <ToggleGroup
110
+ data-slot="theme-radius-picker"
111
+ aria-label="Radius"
112
+ variant="outline"
113
+ size="sm"
114
+ value={[theme.radius]}
115
+ onValueChange={(v) => v[0] && theme.setRadius(v[0] as Radius)}
116
+ className={cn("w-full", className)}
117
+ >
118
+ {RADII.map((radius) => (
119
+ <ToggleGroupItem key={radius} value={radius} className="flex-1 font-mono">
120
+ {radius}
121
+ </ToggleGroupItem>
122
+ ))}
123
+ </ToggleGroup>
124
+ );
125
+ }
126
+
127
+ /** High contrast on/off, with its label. */
128
+ function ThemeContrastSwitch({className}: {className?: string}) {
129
+ const theme = useTheme();
130
+ const id = React.useId();
131
+ return (
132
+ <div data-slot="theme-contrast-switch" className={cn("flex items-center justify-between gap-2", className)}>
133
+ <Label htmlFor={id}>High contrast</Label>
134
+ <Switch
135
+ id={id}
136
+ checked={theme.contrast === "high"}
137
+ onCheckedChange={(on) => theme.setContrast(on ? "high" : "normal")}
138
+ />
139
+ </div>
140
+ );
141
+ }
142
+
143
+ /** A labelled block inside the menu. */
144
+ function ThemeMenuSection({
145
+ label,
146
+ children,
147
+ className,
148
+ }: {
149
+ label: string;
150
+ children: React.ReactNode;
151
+ className?: string;
152
+ }) {
153
+ return (
154
+ <div data-slot="theme-menu-section" className={cn("flex flex-col gap-2", className)}>
155
+ <span className="text-xs font-medium text-muted-foreground">{label}</span>
156
+ {children}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ interface ThemeMenuProps extends Omit<React.ComponentProps<typeof Button>, "children" | "title"> {
162
+ /** Popover title. */
163
+ title?: React.ReactNode;
164
+ /** Extra controls, after the built-in ones and before Reset. */
165
+ children?: React.ReactNode;
166
+ /** Popover alignment against the trigger. */
167
+ align?: "start" | "center" | "end";
168
+ contentClassName?: string;
169
+ }
170
+
171
+ /**
172
+ * The suite's theme control (GUIDELINES §7b): one ghost icon button in `SiteHeaderActions` showing the
173
+ * chosen mode (sun, moon, monitor), opening a popover with mode, accent, high contrast, radius and a
174
+ * Reset to the provider's defaults. Reads and writes `useTheme()`, so it needs a `ThemeProvider`.
175
+ * Trigger props (`aria-label`, `className`, `data-*`) pass through to the button.
176
+ */
177
+ function ThemeMenu({title = "Theme", children, align = "end", contentClassName, className, ...props}: ThemeMenuProps) {
178
+ const theme = useTheme();
179
+ const {defaults} = theme;
180
+ const isDefault =
181
+ theme.mode === defaults.mode &&
182
+ theme.accent === defaults.accent &&
183
+ theme.contrast === defaults.contrast &&
184
+ theme.radius === defaults.radius;
185
+ return (
186
+ <Popover>
187
+ <PopoverTrigger
188
+ render={
189
+ <Button
190
+ variant="ghost"
191
+ size="icon"
192
+ aria-label={`Theme: ${modeOption(theme.mode).label}`}
193
+ data-slot="theme-menu-trigger"
194
+ data-mode={theme.mode}
195
+ className={className}
196
+ {...props}
197
+ />
198
+ }
199
+ >
200
+ <ThemeModeIcon />
201
+ </PopoverTrigger>
202
+ <PopoverContent data-slot="theme-menu-content" align={align} className={cn("w-72 gap-4", contentClassName)}>
203
+ <PopoverHeader>
204
+ <PopoverTitle>{title}</PopoverTitle>
205
+ </PopoverHeader>
206
+ <ThemeMenuSection label="Mode">
207
+ <ThemeModePicker />
208
+ </ThemeMenuSection>
209
+ <ThemeMenuSection label="Accent">
210
+ <ThemeAccentPicker />
211
+ </ThemeMenuSection>
212
+ <ThemeContrastSwitch />
213
+ <ThemeMenuSection label="Radius">
214
+ <ThemeRadiusPicker />
215
+ </ThemeMenuSection>
216
+ {children}
217
+ <div className="-mx-3 -mb-3 border-t border-border p-3">
218
+ <Button
219
+ variant="outline"
220
+ size="sm"
221
+ data-slot="theme-menu-reset"
222
+ className="w-full"
223
+ disabled={isDefault}
224
+ onClick={theme.resetTheme}
225
+ >
226
+ <RotateCcwIcon data-icon="inline-start" /> Reset to defaults
227
+ </Button>
228
+ </div>
229
+ </PopoverContent>
230
+ </Popover>
231
+ );
232
+ }
233
+
234
+ export {
235
+ ThemeAccentPicker,
236
+ ThemeContrastSwitch,
237
+ ThemeMenu,
238
+ ThemeMenuSection,
239
+ ThemeModeIcon,
240
+ ThemeModePicker,
241
+ ThemeRadiusPicker,
242
+ };
243
+ export type {ThemeMenuProps};
@@ -23,9 +23,16 @@ export const popupSurface =
23
23
  export const popupMotion =
24
24
  "origin-(--transform-origin) duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:animate-none";
25
25
 
26
+ /**
27
+ * The sidebar's current-row indicator (§7c): a 2px accent pill inset on the row's left edge, hidden until
28
+ * the row's state shows it (`data-highlighted:after:opacity-100`, `data-active:after:opacity-100`...).
29
+ * The row keeps its own rounding on every corner.
30
+ */
31
+ export const rowIndicator =
32
+ "after:pointer-events-none after:absolute after:top-1.5 after:bottom-1.5 after:left-0 after:w-0.5 after:rounded-pill after:bg-primary-text after:opacity-0";
33
+
26
34
  /** Row inside a popup or list: menu item, select item, command item (§4, §6). */
27
- export const listItem =
28
- "relative flex min-h-8 cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-highlighted:[&_svg:not([class*='text-'])]:text-accent-foreground";
35
+ export const listItem = `${rowIndicator} data-highlighted:after:opacity-100 relative flex min-h-8 cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-highlighted:[&_svg:not([class*='text-'])]:text-accent-foreground`;
29
36
 
30
37
  /** Group label inside a popup or list. */
31
38
  export const listLabel = "px-2 py-1.5 text-xs font-medium text-muted-foreground";
@@ -32,6 +32,10 @@ export interface ThemeContextValue extends ThemeState {
32
32
  setContrast: (contrast: ThemeState["contrast"]) => void;
33
33
  setAccent: (accent: ThemeState["accent"]) => void;
34
34
  setRadius: (radius: ThemeState["radius"]) => void;
35
+ /** The values `resetTheme` restores: the provider's `defaultTheme` over the kit defaults. */
36
+ defaults: ThemeState;
37
+ /** Forget the stored choice and go back to `defaults`. */
38
+ resetTheme: () => void;
35
39
  }
36
40
 
37
41
  const ThemeContext = createContext<ThemeContextValue | null>(null);
@@ -139,6 +143,17 @@ export function ThemeProvider({
139
143
  [storageKey, fallback],
140
144
  );
141
145
 
146
+ const resetTheme = useCallback(() => {
147
+ if (storageKey) {
148
+ try {
149
+ window.localStorage.removeItem(storageKey);
150
+ } catch {
151
+ // storage blocked: the in-memory reset still applies
152
+ }
153
+ }
154
+ setStored(fallback);
155
+ }, [storageKey, fallback]);
156
+
142
157
  const value = useMemo<ThemeContextValue>(
143
158
  () => ({
144
159
  ...theme,
@@ -148,8 +163,10 @@ export function ThemeProvider({
148
163
  setContrast: (contrast) => setTheme({contrast}),
149
164
  setAccent: (accent) => setTheme({accent}),
150
165
  setRadius: (radius) => setTheme({radius}),
166
+ defaults: fallback,
167
+ resetTheme,
151
168
  }),
152
- [theme, resolvedMode, setTheme],
169
+ [theme, resolvedMode, setTheme, fallback, resetTheme],
153
170
  );
154
171
 
155
172
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;