@epicbagel/saas-kit 1.3.0 → 1.4.0

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/README.md CHANGED
@@ -219,6 +219,62 @@ import { FilePreview } from "@epicbagel/saas-kit/primitives/file-preview";
219
219
  `primitives/document-preview` is the same dialog with every `src` in the
220
220
  browser's viewer, as it was in ui-kit; its API is unchanged.
221
221
 
222
+ ## Theme selector (1.4.0)
223
+
224
+ `primitives/theme-selector` is a page of appearance settings: light, dark or
225
+ system, then a grid of palettes, each previewing both modes. It owns no
226
+ state: it shows the `palette` and `mode` it is given and reports picks, and
227
+ the app applies and stores them. The palette is a `data-theme` attribute on
228
+ `<html>` and the mode the `.dark` class: two independent axes.
229
+
230
+ ```tsx
231
+ import { ThemeSelector, type ThemeSelectorMode } from "@epicbagel/saas-kit/primitives/theme-selector";
232
+ import { NEUTRAL_PALETTE, type ThemePalette } from "@epicbagel/saas-kit/lib/neutral-theme";
233
+
234
+ const PALETTES: ThemePalette[] = [NEUTRAL_PALETTE, ...yourPalettes];
235
+
236
+ export function Appearance() {
237
+ const [palette, setPalette] = useState(NEUTRAL_PALETTE.id);
238
+ const [mode, setMode] = useState<ThemeSelectorMode>("system");
239
+ // Apply them: set `data-theme` on <html> to the palette, and toggle
240
+ // `.dark` for the mode (next-themes does that part for you).
241
+ return (
242
+ <ThemeSelector
243
+ palettes={PALETTES}
244
+ palette={palette}
245
+ onPaletteChange={setPalette}
246
+ mode={mode}
247
+ onModeChange={setMode}
248
+ />
249
+ );
250
+ }
251
+ ```
252
+
253
+ - **Palettes come in.** The selector knows only the kit's neutral palette,
254
+ `NEUTRAL_PALETTE` (`lib/neutral-theme`), which it offers alone when given
255
+ no `palettes`. Its id is `minimal`, the value the stylesheet's default
256
+ already answers to, so storing it changes nothing on screen.
257
+ - **A `ThemePalette`** is an `id` (its `data-theme` value, and what you
258
+ store), a `label`, and optionally a `description`, a `radius` and each
259
+ mode's semantic tokens as hex (`light`, `dark`: the token contract in
260
+ `lib/neutral-theme`). Given the tokens, a preview paints from them inline
261
+ (`paletteStyle`), whatever palette the page is in. Without them it carries
262
+ `data-theme` and `data-mode`, so your palette CSS must select any element
263
+ (`[data-theme="<id>"]`, not `:root[data-theme="<id>"]`), re-declare the
264
+ `--color-*` aliases there (`paletteDeclarations` writes them) and answer
265
+ `[data-mode="light"]` and `[data-mode="dark"]`.
266
+ - **Before the stored pick is known**, pass `null` for `palette` or `mode`:
267
+ nothing is marked rather than the wrong option. Omitted, they default to
268
+ the neutral palette and `system`.
269
+ - **Layout.** One column on a phone (below `sm`). Otherwise the grid
270
+ follows the selector's own width, so the same component fits a dialog and
271
+ a settings page: one column while it is narrower than 24rem, two from
272
+ there, three from 42rem.
273
+ - **Words.** `modeLabel` ("Mode"), `paletteLabel` ("Theme"), `lightLabel`,
274
+ `darkLabel`, `systemLabel`, and optional `modeDescription` and
275
+ `paletteDescription`. Each palette's card is named by its `label` and
276
+ described by its `description`.
277
+
222
278
  ## License
223
279
 
224
280
  MIT. Some components and styles are adapted from shadcn/ui, and the file
@@ -0,0 +1,52 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } }"use client";
2
+
3
+
4
+ var _chunkGXHGMI3Zcjs = require('./chunk-GXHGMI3Z.cjs');
5
+
6
+
7
+ var _chunkKWLSBTB2cjs = require('./chunk-KWLSBTB2.cjs');
8
+
9
+ // src/primitives/toggle-group.tsx
10
+ var _react = require('react'); var React = _interopRequireWildcard(_react);
11
+ var _reacttogglegroup = require('@radix-ui/react-toggle-group'); var ToggleGroupPrimitive = _interopRequireWildcard(_reacttogglegroup);
12
+ var _jsxruntime = require('react/jsx-runtime');
13
+ var ToggleGroupContext = React.createContext({
14
+ size: "default",
15
+ variant: "default"
16
+ });
17
+ var ToggleGroup = React.forwardRef(({ className, variant, size, children, ...props }, ref) => /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
18
+ ToggleGroupPrimitive.Root,
19
+ {
20
+ ref,
21
+ className: _chunkKWLSBTB2cjs.cn.call(void 0, "flex items-center justify-center gap-1", className),
22
+ ...props,
23
+ children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, ToggleGroupContext.Provider, { value: { variant, size }, children })
24
+ }
25
+ ));
26
+ ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
27
+ var ToggleGroupItem = React.forwardRef(({ className, children, variant, size, grow = false, textSize = "inherit", ...props }, ref) => {
28
+ const context = React.useContext(ToggleGroupContext);
29
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
30
+ ToggleGroupPrimitive.Item,
31
+ {
32
+ ref,
33
+ className: _chunkKWLSBTB2cjs.cn.call(void 0,
34
+ _chunkGXHGMI3Zcjs.toggleVariants.call(void 0, {
35
+ variant: context.variant || variant,
36
+ size: context.size || size
37
+ }),
38
+ grow && "flex-1",
39
+ textSize === "xs" && "text-xs",
40
+ className
41
+ ),
42
+ ...props,
43
+ children
44
+ }
45
+ );
46
+ });
47
+ ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
48
+
49
+
50
+
51
+
52
+ exports.ToggleGroup = ToggleGroup; exports.ToggleGroupItem = ToggleGroupItem;
@@ -0,0 +1,271 @@
1
+ // src/lib/neutral-theme.ts
2
+ var SEMANTIC_TOKENS = [
3
+ "background",
4
+ "foreground",
5
+ "card",
6
+ "card-foreground",
7
+ "popover",
8
+ "popover-foreground",
9
+ "primary",
10
+ "primary-foreground",
11
+ "secondary",
12
+ "secondary-foreground",
13
+ "muted",
14
+ "muted-foreground",
15
+ "accent",
16
+ "accent-foreground",
17
+ "destructive",
18
+ "destructive-foreground",
19
+ "border",
20
+ "input",
21
+ "ring",
22
+ "chart-1",
23
+ "chart-2",
24
+ "chart-3",
25
+ "chart-4",
26
+ "chart-5",
27
+ "sidebar-background",
28
+ "sidebar-foreground",
29
+ "sidebar-primary",
30
+ "sidebar-primary-foreground",
31
+ "sidebar-accent",
32
+ "sidebar-accent-foreground",
33
+ "sidebar-border",
34
+ "sidebar-ring"
35
+ ];
36
+ function hexToRgb(hex) {
37
+ const h = hex.replace("#", "").trim();
38
+ const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
39
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) {
40
+ throw new Error(`Not a hex colour: ${hex}`);
41
+ }
42
+ return [
43
+ parseInt(full.slice(0, 2), 16),
44
+ parseInt(full.slice(2, 4), 16),
45
+ parseInt(full.slice(4, 6), 16)
46
+ ];
47
+ }
48
+ function hexToHsl(hex) {
49
+ const [r255, g255, b255] = hexToRgb(hex);
50
+ const r = r255 / 255;
51
+ const g = g255 / 255;
52
+ const b = b255 / 255;
53
+ const max = Math.max(r, g, b);
54
+ const min = Math.min(r, g, b);
55
+ const l = (max + min) / 2;
56
+ const d = max - min;
57
+ let h = 0;
58
+ let s = 0;
59
+ if (d !== 0) {
60
+ s = d / (1 - Math.abs(2 * l - 1));
61
+ if (max === r) h = (g - b) / d % 6;
62
+ else if (max === g) h = (b - r) / d + 2;
63
+ else h = (r - g) / d + 4;
64
+ h *= 60;
65
+ if (h < 0) h += 360;
66
+ }
67
+ const round = (n) => Math.round(n * 10) / 10;
68
+ return `${round(h)} ${round(s * 100)}% ${round(l * 100)}%`;
69
+ }
70
+ function luminance(hex) {
71
+ const [r, g, b] = hexToRgb(hex).map((v) => {
72
+ const c = v / 255;
73
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
74
+ });
75
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
76
+ }
77
+ function contrastRatio(a, b) {
78
+ const la = luminance(a);
79
+ const lb = luminance(b);
80
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
81
+ return (hi + 0.05) / (lo + 0.05);
82
+ }
83
+ function bestForeground(bg, candidates) {
84
+ let best = candidates[0];
85
+ let bestRatio = 0;
86
+ for (const c of candidates) {
87
+ const ratio = contrastRatio(bg, c);
88
+ if (ratio > bestRatio) {
89
+ best = c;
90
+ bestRatio = ratio;
91
+ }
92
+ }
93
+ return best;
94
+ }
95
+ var NEUTRAL_RADIUS = "0rem";
96
+ function neutral(mode) {
97
+ const light = mode === "light";
98
+ const danger = "#d71921";
99
+ const mono = light ? {
100
+ bg: "#ffffff",
101
+ fg: "#000000",
102
+ surface: "#f5f5f5",
103
+ subtle: "#666666",
104
+ border: "#e5e5e5",
105
+ sidebar: "#fafafa",
106
+ strong: "#333333"
107
+ } : {
108
+ bg: "#000000",
109
+ fg: "#ffffff",
110
+ surface: "#1a1a1a",
111
+ subtle: "#999999",
112
+ border: "#333333",
113
+ sidebar: "#0d0d0d",
114
+ strong: "#cccccc"
115
+ };
116
+ return {
117
+ background: mono.bg,
118
+ foreground: mono.fg,
119
+ card: mono.bg,
120
+ "card-foreground": mono.fg,
121
+ popover: mono.bg,
122
+ "popover-foreground": mono.fg,
123
+ primary: mono.fg,
124
+ "primary-foreground": mono.bg,
125
+ secondary: mono.surface,
126
+ "secondary-foreground": mono.fg,
127
+ muted: mono.surface,
128
+ "muted-foreground": mono.subtle,
129
+ accent: mono.surface,
130
+ "accent-foreground": mono.fg,
131
+ destructive: danger,
132
+ "destructive-foreground": bestForeground(danger, ["#ffffff", "#000000"]),
133
+ border: mono.border,
134
+ input: mono.border,
135
+ ring: mono.fg,
136
+ "chart-1": light ? "#333333" : "#cccccc",
137
+ "chart-2": light ? "#4d4d4d" : "#b3b3b3",
138
+ "chart-3": light ? "#666666" : "#999999",
139
+ "chart-4": light ? "#808080" : "#808080",
140
+ "chart-5": light ? "#999999" : "#666666",
141
+ "sidebar-background": mono.sidebar,
142
+ "sidebar-foreground": mono.fg,
143
+ "sidebar-primary": mono.fg,
144
+ "sidebar-primary-foreground": mono.bg,
145
+ "sidebar-accent": mono.surface,
146
+ "sidebar-accent-foreground": mono.fg,
147
+ "sidebar-border": mono.border,
148
+ "sidebar-ring": mono.border
149
+ };
150
+ }
151
+ var NEUTRAL = {
152
+ light: neutral("light"),
153
+ dark: neutral("dark")
154
+ };
155
+ var NEUTRAL_THEME_ID = "minimal";
156
+ var NEUTRAL_PALETTE = {
157
+ id: NEUTRAL_THEME_ID,
158
+ label: "Neutral",
159
+ description: "Black and white, square corners.",
160
+ radius: NEUTRAL_RADIUS,
161
+ light: NEUTRAL.light,
162
+ dark: NEUTRAL.dark
163
+ };
164
+ function colorAlias(token) {
165
+ if (token.startsWith("chart-")) return null;
166
+ if (token === "sidebar-background") return "--color-sidebar";
167
+ return `--color-${token}`;
168
+ }
169
+ var RADIUS_SCALE = [
170
+ ["--radius-sm", "calc(var(--radius) - 4px)"],
171
+ ["--radius-md", "calc(var(--radius) - 2px)"],
172
+ ["--radius-lg", "var(--radius)"],
173
+ ["--radius-xl", "calc(var(--radius) + 4px)"],
174
+ ["--radius-2xl", "calc(var(--radius) + 8px)"]
175
+ ];
176
+ function paletteDeclarations(tokens, names, radius, { indent = " ", aliasSource = "neutral-theme.ts" } = {}) {
177
+ const value = (token) => {
178
+ const hex = tokens[token];
179
+ if (hex === void 0) throw new Error(`Missing token: ${token}`);
180
+ return hexToHsl(hex);
181
+ };
182
+ const lines = [`${indent}--radius: ${radius};`];
183
+ for (const token of names) {
184
+ lines.push(`${indent}--${token}: ${value(token)};`);
185
+ }
186
+ lines.push(`${indent}--sidebar: ${value("sidebar-background")};`);
187
+ lines.push(`${indent}/* Tailwind-facing aliases \u2014 see ${aliasSource} */`);
188
+ for (const token of names) {
189
+ const alias = colorAlias(token);
190
+ if (alias) lines.push(`${indent}${alias}: hsl(var(--${token}));`);
191
+ }
192
+ for (const [name, scale] of RADIUS_SCALE) {
193
+ lines.push(`${indent}${name}: ${scale};`);
194
+ }
195
+ return lines.join("\n");
196
+ }
197
+ function paletteStyle(tokens, radius, names = SEMANTIC_TOKENS) {
198
+ const value = (token) => {
199
+ const hex = tokens[token];
200
+ if (hex === void 0) throw new Error(`Missing token: ${token}`);
201
+ return hexToHsl(hex);
202
+ };
203
+ const style = {};
204
+ if (radius !== void 0) style["--radius"] = radius;
205
+ for (const token of names) style[`--${token}`] = value(token);
206
+ style["--sidebar"] = value("sidebar-background");
207
+ for (const token of names) {
208
+ const alias = colorAlias(token);
209
+ if (alias) style[alias] = `hsl(var(--${token}))`;
210
+ }
211
+ for (const [name, scale] of RADIUS_SCALE) style[name] = scale;
212
+ return style;
213
+ }
214
+ function paletteBlock(selector, declarations, indent = "") {
215
+ const head = selector.split("\n").map((line) => indent + line).join("\n");
216
+ return `${head} {
217
+ ${declarations}
218
+ ${indent}}
219
+ `;
220
+ }
221
+ function layerBlock(name, rules) {
222
+ return `@layer ${name} {
223
+ ${rules.join("\n")}}
224
+ `;
225
+ }
226
+ var DARK_ROOT_SELECTOR = ":root:where(.dark)";
227
+ function baseLayerPalette(light, dark, indent = " ") {
228
+ return layerBlock("base", [
229
+ paletteBlock(":root", light, indent),
230
+ paletteBlock(DARK_ROOT_SELECTOR, dark, indent)
231
+ ]);
232
+ }
233
+ var PALETTE_GENERATED_HEADER = `/* GENERATED FILE \u2014 do not edit.
234
+ * Source: src/lib/neutral-theme.ts \xB7 Regenerate: pnpm gen:themes
235
+ *
236
+ * The neutral palette: light on :root, dark when the root carries the
237
+ * \`.dark\` class. It sits in \`@layer base\`, so any unlayered palette rule
238
+ * or a host's own :root overrides wins on layer order alone. Imported by
239
+ * tokens.css; every --color-* alias and the radius scale are re-declared
240
+ * alongside the tokens they read.
241
+ */`;
242
+ function renderPaletteCss() {
243
+ const declarations = (mode) => paletteDeclarations(NEUTRAL[mode], SEMANTIC_TOKENS, NEUTRAL_RADIUS, {
244
+ indent: " "
245
+ });
246
+ return [
247
+ PALETTE_GENERATED_HEADER,
248
+ baseLayerPalette(declarations("light"), declarations("dark"))
249
+ ].join("\n");
250
+ }
251
+
252
+ export {
253
+ SEMANTIC_TOKENS,
254
+ hexToRgb,
255
+ hexToHsl,
256
+ luminance,
257
+ contrastRatio,
258
+ bestForeground,
259
+ NEUTRAL_RADIUS,
260
+ NEUTRAL,
261
+ NEUTRAL_THEME_ID,
262
+ NEUTRAL_PALETTE,
263
+ colorAlias,
264
+ paletteDeclarations,
265
+ paletteStyle,
266
+ paletteBlock,
267
+ layerBlock,
268
+ baseLayerPalette,
269
+ PALETTE_GENERATED_HEADER,
270
+ renderPaletteCss
271
+ };
@@ -0,0 +1,46 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } }"use client";
2
+
3
+
4
+ var _chunkKWLSBTB2cjs = require('./chunk-KWLSBTB2.cjs');
5
+
6
+ // src/primitives/radio-group.tsx
7
+ var _reactradiogroup = require('@radix-ui/react-radio-group'); var RadioGroupPrimitive = _interopRequireWildcard(_reactradiogroup);
8
+ var _react = require('react'); var React = _interopRequireWildcard(_react);
9
+ var _lucidereact = require('lucide-react');
10
+ var _jsxruntime = require('react/jsx-runtime');
11
+ var RadioGroup = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
12
+ RadioGroupPrimitive.Root,
13
+ {
14
+ ref,
15
+ "data-slot": "radio-group",
16
+ className: _chunkKWLSBTB2cjs.cn.call(void 0, "grid gap-3", className),
17
+ ...props
18
+ }
19
+ ));
20
+ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
21
+ var RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
22
+ RadioGroupPrimitive.Item,
23
+ {
24
+ ref,
25
+ "data-slot": "radio-group-item",
26
+ className: _chunkKWLSBTB2cjs.cn.call(void 0,
27
+ "aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
28
+ className
29
+ ),
30
+ ...props,
31
+ children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
32
+ RadioGroupPrimitive.Indicator,
33
+ {
34
+ "data-slot": "radio-group-indicator",
35
+ className: "relative flex items-center justify-center",
36
+ children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.CircleIcon, { className: "absolute left-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" })
37
+ }
38
+ )
39
+ }
40
+ ));
41
+ RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
42
+
43
+
44
+
45
+
46
+ exports.RadioGroup = RadioGroup; exports.RadioGroupItem = RadioGroupItem;
@@ -0,0 +1,46 @@
1
+ "use client";
2
+ import {
3
+ cn
4
+ } from "./chunk-G6SRSWXB.js";
5
+
6
+ // src/primitives/radio-group.tsx
7
+ import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
8
+ import * as React from "react";
9
+ import { CircleIcon } from "lucide-react";
10
+ import { jsx } from "react/jsx-runtime";
11
+ var RadioGroup = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
12
+ RadioGroupPrimitive.Root,
13
+ {
14
+ ref,
15
+ "data-slot": "radio-group",
16
+ className: cn("grid gap-3", className),
17
+ ...props
18
+ }
19
+ ));
20
+ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
21
+ var RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
22
+ RadioGroupPrimitive.Item,
23
+ {
24
+ ref,
25
+ "data-slot": "radio-group-item",
26
+ className: cn(
27
+ "aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
28
+ className
29
+ ),
30
+ ...props,
31
+ children: /* @__PURE__ */ jsx(
32
+ RadioGroupPrimitive.Indicator,
33
+ {
34
+ "data-slot": "radio-group-indicator",
35
+ className: "relative flex items-center justify-center",
36
+ children: /* @__PURE__ */ jsx(CircleIcon, { className: "absolute left-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" })
37
+ }
38
+ )
39
+ }
40
+ ));
41
+ RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
42
+
43
+ export {
44
+ RadioGroup,
45
+ RadioGroupItem
46
+ };