saasaloy 0.2.0 → 0.3.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.
@@ -0,0 +1,132 @@
1
+ import * as React from "react";
2
+ import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
3
+
4
+ import { cn } from "@repo/ui/lib/utils";
5
+ import { Button } from "@repo/ui/components/button";
6
+ import { XIcon } from "lucide-react";
7
+
8
+ function Sheet({ ...props }: SheetPrimitive.Root.Props) {
9
+ return <SheetPrimitive.Root data-slot="sheet" {...props} />;
10
+ }
11
+
12
+ function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
13
+ return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
14
+ }
15
+
16
+ function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
17
+ return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
18
+ }
19
+
20
+ function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
21
+ return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
22
+ }
23
+
24
+ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
25
+ return (
26
+ <SheetPrimitive.Backdrop
27
+ data-slot="sheet-overlay"
28
+ className={cn(
29
+ "fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
30
+ className
31
+ )}
32
+ {...props}
33
+ />
34
+ );
35
+ }
36
+
37
+ function SheetContent({
38
+ className,
39
+ children,
40
+ side = "right",
41
+ showCloseButton = true,
42
+ ...props
43
+ }: SheetPrimitive.Popup.Props & {
44
+ side?: "top" | "right" | "bottom" | "left";
45
+ showCloseButton?: boolean;
46
+ }) {
47
+ return (
48
+ <SheetPortal>
49
+ <SheetOverlay />
50
+ <SheetPrimitive.Popup
51
+ data-slot="sheet-content"
52
+ data-side={side}
53
+ className={cn(
54
+ "bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
55
+ className
56
+ )}
57
+ {...props}
58
+ >
59
+ {children}
60
+ {showCloseButton && (
61
+ <SheetPrimitive.Close
62
+ data-slot="sheet-close"
63
+ render={
64
+ <Button
65
+ variant="ghost"
66
+ className="absolute top-3 right-3"
67
+ size="icon-sm"
68
+ />
69
+ }
70
+ >
71
+ <XIcon />
72
+ <span className="sr-only">Close</span>
73
+ </SheetPrimitive.Close>
74
+ )}
75
+ </SheetPrimitive.Popup>
76
+ </SheetPortal>
77
+ );
78
+ }
79
+
80
+ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
81
+ return (
82
+ <div
83
+ data-slot="sheet-header"
84
+ className={cn("flex flex-col gap-0.5 p-4", className)}
85
+ {...props}
86
+ />
87
+ );
88
+ }
89
+
90
+ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
91
+ return (
92
+ <div
93
+ data-slot="sheet-footer"
94
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
95
+ {...props}
96
+ />
97
+ );
98
+ }
99
+
100
+ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
101
+ return (
102
+ <SheetPrimitive.Title
103
+ data-slot="sheet-title"
104
+ className={cn("text-foreground text-base font-medium", className)}
105
+ {...props}
106
+ />
107
+ );
108
+ }
109
+
110
+ function SheetDescription({
111
+ className,
112
+ ...props
113
+ }: SheetPrimitive.Description.Props) {
114
+ return (
115
+ <SheetPrimitive.Description
116
+ data-slot="sheet-description"
117
+ className={cn("text-muted-foreground text-sm", className)}
118
+ {...props}
119
+ />
120
+ );
121
+ }
122
+
123
+ export {
124
+ Sheet,
125
+ SheetTrigger,
126
+ SheetClose,
127
+ SheetContent,
128
+ SheetHeader,
129
+ SheetFooter,
130
+ SheetTitle,
131
+ SheetDescription,
132
+ };
@@ -0,0 +1,114 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@repo/ui/lib/utils";
4
+
5
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
6
+ return (
7
+ <div
8
+ data-slot="table-container"
9
+ className="relative w-full overflow-x-auto"
10
+ >
11
+ <table
12
+ data-slot="table"
13
+ className={cn("w-full caption-bottom text-sm", className)}
14
+ {...props}
15
+ />
16
+ </div>
17
+ );
18
+ }
19
+
20
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
21
+ return (
22
+ <thead
23
+ data-slot="table-header"
24
+ className={cn("[&_tr]:border-b", className)}
25
+ {...props}
26
+ />
27
+ );
28
+ }
29
+
30
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
31
+ return (
32
+ <tbody
33
+ data-slot="table-body"
34
+ className={cn("[&_tr:last-child]:border-0", className)}
35
+ {...props}
36
+ />
37
+ );
38
+ }
39
+
40
+ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
41
+ return (
42
+ <tfoot
43
+ data-slot="table-footer"
44
+ className={cn(
45
+ "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
46
+ className
47
+ )}
48
+ {...props}
49
+ />
50
+ );
51
+ }
52
+
53
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
54
+ return (
55
+ <tr
56
+ data-slot="table-row"
57
+ className={cn(
58
+ "hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
59
+ className
60
+ )}
61
+ {...props}
62
+ />
63
+ );
64
+ }
65
+
66
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
67
+ return (
68
+ <th
69
+ data-slot="table-head"
70
+ className={cn(
71
+ "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0",
72
+ className
73
+ )}
74
+ {...props}
75
+ />
76
+ );
77
+ }
78
+
79
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
80
+ return (
81
+ <td
82
+ data-slot="table-cell"
83
+ className={cn(
84
+ "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
85
+ className
86
+ )}
87
+ {...props}
88
+ />
89
+ );
90
+ }
91
+
92
+ function TableCaption({
93
+ className,
94
+ ...props
95
+ }: React.ComponentProps<"caption">) {
96
+ return (
97
+ <caption
98
+ data-slot="table-caption"
99
+ className={cn("text-muted-foreground mt-4 text-sm", className)}
100
+ {...props}
101
+ />
102
+ );
103
+ }
104
+
105
+ export {
106
+ Table,
107
+ TableHeader,
108
+ TableBody,
109
+ TableFooter,
110
+ TableHead,
111
+ TableRow,
112
+ TableCell,
113
+ TableCaption,
114
+ };
@@ -0,0 +1,81 @@
1
+ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
2
+ import { cva } from "class-variance-authority";
3
+ import type { VariantProps } from "class-variance-authority";
4
+
5
+ import { cn } from "@repo/ui/lib/utils";
6
+
7
+ function Tabs({
8
+ className,
9
+ orientation = "horizontal",
10
+ ...props
11
+ }: TabsPrimitive.Root.Props) {
12
+ return (
13
+ <TabsPrimitive.Root
14
+ data-slot="tabs"
15
+ data-orientation={orientation}
16
+ className={cn(
17
+ "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
18
+ className
19
+ )}
20
+ {...props}
21
+ />
22
+ );
23
+ }
24
+
25
+ const tabsListVariants = cva(
26
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
27
+ {
28
+ variants: {
29
+ variant: {
30
+ default: "bg-muted",
31
+ line: "gap-1 bg-transparent",
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: "default",
36
+ },
37
+ }
38
+ );
39
+
40
+ function TabsList({
41
+ className,
42
+ variant = "default",
43
+ ...props
44
+ }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
45
+ return (
46
+ <TabsPrimitive.List
47
+ data-slot="tabs-list"
48
+ data-variant={variant}
49
+ className={cn(tabsListVariants({ variant }), className)}
50
+ {...props}
51
+ />
52
+ );
53
+ }
54
+
55
+ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
56
+ return (
57
+ <TabsPrimitive.Tab
58
+ data-slot="tabs-trigger"
59
+ className={cn(
60
+ "text-foreground/60 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
61
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
62
+ "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
63
+ "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
64
+ className
65
+ )}
66
+ {...props}
67
+ />
68
+ );
69
+ }
70
+
71
+ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
72
+ return (
73
+ <TabsPrimitive.Panel
74
+ data-slot="tabs-content"
75
+ className={cn("flex-1 text-sm outline-none", className)}
76
+ {...props}
77
+ />
78
+ );
79
+ }
80
+
81
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,64 @@
1
+ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
2
+
3
+ import { cn } from "@repo/ui/lib/utils";
4
+
5
+ function TooltipProvider({
6
+ delay = 0,
7
+ ...props
8
+ }: TooltipPrimitive.Provider.Props) {
9
+ return (
10
+ <TooltipPrimitive.Provider
11
+ data-slot="tooltip-provider"
12
+ delay={delay}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
19
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
20
+ }
21
+
22
+ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
23
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
24
+ }
25
+
26
+ function TooltipContent({
27
+ className,
28
+ side = "top",
29
+ sideOffset = 4,
30
+ align = "center",
31
+ alignOffset = 0,
32
+ children,
33
+ ...props
34
+ }: TooltipPrimitive.Popup.Props &
35
+ Pick<
36
+ TooltipPrimitive.Positioner.Props,
37
+ "align" | "alignOffset" | "side" | "sideOffset"
38
+ >) {
39
+ return (
40
+ <TooltipPrimitive.Portal>
41
+ <TooltipPrimitive.Positioner
42
+ align={align}
43
+ alignOffset={alignOffset}
44
+ side={side}
45
+ sideOffset={sideOffset}
46
+ className="isolate z-50"
47
+ >
48
+ <TooltipPrimitive.Popup
49
+ data-slot="tooltip-content"
50
+ className={cn(
51
+ "bg-foreground text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 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 z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm",
52
+ className
53
+ )}
54
+ {...props}
55
+ >
56
+ {children}
57
+ <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
58
+ </TooltipPrimitive.Popup>
59
+ </TooltipPrimitive.Positioner>
60
+ </TooltipPrimitive.Portal>
61
+ );
62
+ }
63
+
64
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -1,7 +1,7 @@
1
1
  // Canary for the tool repo's `scripts/verify-css` smoke test. Tailwind's class
2
2
  // detection is text-based — it scans source files as plain text and never imports them —
3
3
  // so this constant needs no consumer to make the utility below reach the built CSS. It
4
- // reaches it only if globals.css's `@source "../**/*.{ts,tsx}"` glob is actually
4
+ // reaches it only if globals.css's `@source "../lib/**/*.{ts,tsx}"` glob is actually
5
5
  // matching packages/ui, which is the exact failure the smoke test exists to catch.
6
6
  //
7
7
  // The utility is an arbitrary property (`[--saasaloy-css-probe:1]`) so it compiles to a
@@ -10,6 +10,18 @@
10
10
  /** localStorage key holding the visitor's explicit choice. `system` clears it. */
11
11
  export const THEME_STORAGE_KEY = "theme";
12
12
 
13
+ /**
14
+ * localStorage key marking that the visitor has used a toggle at least once.
15
+ *
16
+ * THEME_STORAGE_KEY alone cannot carry that fact, because `system` is spelled as an
17
+ * absent key — so a first visit and a deliberate `system` read the same. A host that
18
+ * wants a default other than the OS preference (the admin app starts on dark) needs to
19
+ * tell those two apart, and this key is the only difference between them.
20
+ *
21
+ * `installThemeToggle` writes it on every press. Nothing clears it.
22
+ */
23
+ export const THEME_CHOICE_KEY = "theme-chosen";
24
+
13
25
  /** Attribute on `<html>` carrying the *chosen* state — also the JS-present marker. */
14
26
  export const THEME_ATTRIBUTE = "data-theme";
15
27
 
@@ -72,8 +84,119 @@ export function setTheme(theme: Theme): void {
72
84
  root.setAttribute(THEME_ATTRIBUTE, theme);
73
85
  root.classList.toggle("dark", resolveTheme(theme) === "dark");
74
86
 
87
+ relabelThemeToggles(theme);
88
+ }
89
+
90
+ /**
91
+ * Give every `[data-theme-toggle]` in the document the accessible name of the state it is
92
+ * in. `setTheme` calls this, so a host only needs it directly for the one case `setTheme`
93
+ * cannot cover: a toggle that MOUNTS AFTER the theme was applied.
94
+ *
95
+ * That is the ordinary case in a React host. `theme-toggle.tsx` renders a static
96
+ * `aria-label` for `system` — the truthful value for a first-time visitor — and the theme
97
+ * is applied in the entry module, before React has mounted anything. Without this call the
98
+ * button announces "Theme: system" over a painted dark page until the first press.
99
+ *
100
+ * The Astro host gets the same repair from THEME_INIT_SCRIPT's `DOMContentLoaded` handler.
101
+ *
102
+ * `data-theme` is author-writable, so an unrecognised value falls back to the stored
103
+ * choice rather than setting `aria-label="undefined"`.
104
+ */
105
+ export function relabelThemeToggles(theme?: Theme): void {
106
+ const root = document.documentElement;
107
+ const painted = theme ?? root.getAttribute(THEME_ATTRIBUTE);
108
+ const named =
109
+ painted !== null && THEME_ORDER.includes(painted as Theme)
110
+ ? (painted as Theme)
111
+ : getStoredTheme();
112
+
75
113
  for (const trigger of root.querySelectorAll(`[${THEME_TOGGLE_ATTRIBUTE}]`)) {
76
- trigger.setAttribute("aria-label", THEME_LABELS[theme]);
114
+ trigger.setAttribute("aria-label", THEME_LABELS[named]);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Install the toggle's click behaviour on `document`, and answer with a function that
120
+ * removes it again.
121
+ *
122
+ * A host that inlines THEME_INIT_SCRIPT already has this and must NOT call it — the two
123
+ * listeners would both fire and the cycle would advance twice per press. It exists for the
124
+ * host that cannot inline the script: a Vite SPA's `index.html` substitutes only `%VITE_*%`
125
+ * values, so it has no way to reach a TypeScript constant. Such an app calls this once from
126
+ * its entry module instead of pasting a second copy of the cycle rule.
127
+ *
128
+ * It cycles from what is PAINTED, not from what is stored, for the reason the shared script
129
+ * gives: where storage is unwritable the write is a no-op, so reading storage would answer
130
+ * the same value forever and every press would land on the same theme.
131
+ */
132
+ export function installThemeToggle(): () => void {
133
+ function onClick(event: MouseEvent): void {
134
+ const { target } = event;
135
+ if (!(target instanceof Element)) {
136
+ return;
137
+ }
138
+ if (!target.closest(`[${THEME_TOGGLE_ATTRIBUTE}]`)) {
139
+ return;
140
+ }
141
+
142
+ const painted = document.documentElement.getAttribute(THEME_ATTRIBUTE);
143
+ // An unrecognised `data-theme` gives index -1 and starts the cycle at the head of
144
+ // THEME_ORDER.
145
+ const index = painted === null ? -1 : THEME_ORDER.indexOf(painted as Theme);
146
+ const next = THEME_ORDER[(index + 1) % THEME_ORDER.length] ?? "light";
147
+
148
+ setTheme(next);
149
+
150
+ // After `setTheme`, and unconditionally: a `system` press CLEARS THEME_STORAGE_KEY, so
151
+ // this key is the only record that the visitor pressed anything at all.
152
+ try {
153
+ localStorage.setItem(THEME_CHOICE_KEY, "1");
154
+ } catch {
155
+ // Unwritable storage costs persistence, not the current page.
156
+ }
157
+ }
158
+
159
+ // The OS half of the same job, and the reason this is not click-only. THEME_INIT_SCRIPT
160
+ // registers its own matchMedia listener; a host that calls this function instead does
161
+ // not have that script, so without this listener a visitor on `system` keeps the palette
162
+ // resolved at load until the next reload.
163
+ //
164
+ // It repaints only while the state is `system`. A visitor who picked light or dark said
165
+ // so deliberately, and the OS must not overrule that.
166
+ const media = window.matchMedia(OS_DARK_QUERY);
167
+
168
+ function onOsChange(): void {
169
+ if (getStoredTheme() === "system") {
170
+ setTheme("system");
171
+ }
172
+ }
173
+
174
+ document.addEventListener("click", onClick);
175
+ media.addEventListener("change", onOsChange);
176
+
177
+ return () => {
178
+ document.removeEventListener("click", onClick);
179
+ media.removeEventListener("change", onOsChange);
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Whether the visitor has ever used a toggle in this origin.
185
+ *
186
+ * A host whose default is the OS preference never needs this — `getStoredTheme()` already
187
+ * answers `system` for an unset key. A host with a different default (the admin app starts
188
+ * on dark) does: without it, a deliberate `system` choice and a first visit are the same
189
+ * absent key, and the default would overwrite the choice on every load.
190
+ */
191
+ export function hasChosenTheme(): boolean {
192
+ try {
193
+ return (
194
+ localStorage.getItem(THEME_CHOICE_KEY) !== null ||
195
+ localStorage.getItem(THEME_STORAGE_KEY) !== null
196
+ );
197
+ } catch {
198
+ // Unreadable storage keeps nothing, so nothing was ever chosen.
199
+ return false;
77
200
  }
78
201
  }
79
202
 
@@ -11,7 +11,34 @@
11
11
  `astro dev`/`astro build` run with cwd = apps/web. Without these globs every utility
12
12
  class written inside packages/ui is silently dropped — no error, no warning, just
13
13
  missing CSS. A non-matching @source glob is equally silent, so both paths matter. */
14
- @source "../**/*.{ts,tsx}";
14
+ @source "../*.{ts,tsx}";
15
+ @source "../blocks/**/*.{ts,tsx}";
16
+ @source "../containers/**/*.{ts,tsx}";
17
+ @source "../content/**/*.{ts,tsx}";
18
+ @source "../lib/**/*.{ts,tsx}";
19
+
20
+ /* Components are listed one by one, and that is deliberate. Tailwind scans CONTENT, not
21
+ imports: a primitive nobody renders still pushes every class string it contains into
22
+ every stylesheet that pulls this file in. A blanket `../components/**` glob therefore
23
+ charges the landing page for primitives only an admin screen uses — measured at 71.5 kB
24
+ against 45.7 kB for the same page with the unused ones out of the scan.
25
+
26
+ So this list is the set the landing page and src/blocks/ actually render. Vendor a
27
+ primitive with `shadcn add` and one of two things is true:
28
+
29
+ - a block or an app page renders it → add its line here;
30
+ - only one app renders it → add an `@source` line in THAT app's own stylesheet
31
+ instead, the way apps/admin/src/styles/admin.css does for the shell primitives.
32
+
33
+ Get this wrong and the failure is the silent one the comment above describes: the class
34
+ never reaches the CSS and the component renders unstyled. */
35
+ @source "../components/accordion.tsx";
36
+ @source "../components/badge.tsx";
37
+ @source "../components/button.tsx";
38
+ @source "../components/card.tsx";
39
+ @source "../components/input.tsx";
40
+ @source "../components/label.tsx";
41
+ @source "../components/separator.tsx";
15
42
 
16
43
  /* FOUR levels up, not three. From packages/ui/src/styles/ the path walks
17
44
  styles → src → ui → packages → <root>, so `../../../apps/**` would resolve to
@@ -29,6 +29,14 @@ export default {
29
29
  "import-notation": "string",
30
30
  "hue-degree-notation": "number",
31
31
  "lightness-notation": "number",
32
+
33
+ // Stylelint 17.15.0 added `at-rule-prelude-no-invalid`, which checks an at-rule's
34
+ // prelude against the CSS grammar. Tailwind's `@apply` takes a list of utility
35
+ // class names, which no grammar describes, so every use in `globals.css` is
36
+ // reported. Ignore that one at-rule rather than turning the rule off — it still
37
+ // validates `@media`, `@supports` and the rest.
38
+ "at-rule-prelude-no-invalid": [true, { ignoreAtRules: ["apply"] }],
39
+
32
40
  "at-rule-no-unknown": [
33
41
  true,
34
42
  {