@bearmenu/ui 0.8.2 → 0.8.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 (40) hide show
  1. package/dist/chunk-2WDJR6K7.js +76 -0
  2. package/dist/components/icon.d.ts +18 -0
  3. package/dist/components/icon.js +31 -0
  4. package/dist/components/motion-icon-controls.d.ts +26 -0
  5. package/dist/components/motion-icon-controls.js +380 -0
  6. package/dist/components/motion-icon-set.d.ts +27 -0
  7. package/dist/components/motion-icon-set.js +139 -0
  8. package/dist/components/motion-icon.d.ts +44 -0
  9. package/dist/components/motion-icon.js +3 -0
  10. package/dist/lib/icon-registry.d.ts +58 -0
  11. package/dist/lib/icon-registry.js +39 -0
  12. package/package.json +2 -1
  13. package/src/components/accordion.stories.tsx +1 -1
  14. package/src/components/combobox.stories.tsx +1 -1
  15. package/src/components/currency-input.stories.tsx +3 -3
  16. package/src/components/empty-state.stories.tsx +3 -3
  17. package/src/components/form.stories.tsx +1 -1
  18. package/src/components/icon.tsx +57 -0
  19. package/src/components/input-otp.stories.tsx +1 -1
  20. package/src/components/markdown-renderer.stories.tsx +1 -1
  21. package/src/components/motion-icon-controls.test.tsx +78 -0
  22. package/src/components/motion-icon-controls.tsx +552 -0
  23. package/src/components/motion-icon-set.stories.tsx +185 -0
  24. package/src/components/motion-icon-set.tsx +219 -0
  25. package/src/components/motion-icon.test.tsx +159 -0
  26. package/src/components/motion-icon.tsx +163 -0
  27. package/src/components/multi-select-combobox.stories.tsx +1 -1
  28. package/src/components/phone-frame.stories.tsx +1 -1
  29. package/src/components/rating.stories.tsx +3 -3
  30. package/src/components/searchable-select.stories.tsx +1 -1
  31. package/src/components/selection-bar.stories.tsx +1 -1
  32. package/src/components/skeleton-card.stories.tsx +3 -3
  33. package/src/components/sliding-panels.stories.tsx +1 -1
  34. package/src/components/stepper.stories.tsx +1 -1
  35. package/src/components/sticky-container.stories.tsx +3 -3
  36. package/src/components/toggle-group.stories.tsx +1 -1
  37. package/src/components/tooltip.stories.tsx +2 -2
  38. package/src/globals.css +162 -40
  39. package/src/lib/icon-registry.ts +139 -0
  40. package/src/test/setup.ts +5 -1
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MotionIcon — the root for character-animated icons.
5
+ *
6
+ * An animated icon performs a behaviour the object it depicts would actually
7
+ * perform (a roof jumps, utensils uncross, a pin drops and plants) rather than
8
+ * tweening a property. The rules that make container motion feel right — short
9
+ * durations, one or two properties, restraint — produce dead icons, so this
10
+ * primitive is built for the opposite:
11
+ *
12
+ * - Gestures run 400–700ms in five or six beats (anticipation, action, apex,
13
+ * impact, settle), which deliberately exceeds the usual UI ceiling. An icon
14
+ * gesture is a performance the user chose to trigger, not a transition they
15
+ * are waiting through.
16
+ * - Amplitudes are 25–40% of the icon box. At a 22px render on a 24-unit grid
17
+ * one unit is 0.92px, so anything under ~6 units is physically invisible.
18
+ * - The silhouette must change. If the outline is the same shape throughout,
19
+ * nothing reads at icon size.
20
+ *
21
+ * Three variants, not two:
22
+ * rest — the plain glyph.
23
+ * active — the held pose. Equal to `rest` for gestures that return
24
+ * (a roof that lands again); a genuinely different pose for ones
25
+ * that stay changed (uncrossed utensils, a filled pin).
26
+ * play — the performance: keyframe arrays that land on `active`.
27
+ *
28
+ * `play` is driven by `replayToken`, which callers bump on POINTER DOWN rather
29
+ * than on the resulting state change. Waiting for state means waiting for
30
+ * navigation or a network write, which disconnects the gesture from the finger
31
+ * and reads as dead at any amplitude.
32
+ */
33
+
34
+ import * as React from "react";
35
+ import { motion, useAnimationControls, type Transition, type Variants } from "framer-motion";
36
+
37
+ import { cn } from "../lib/utils";
38
+
39
+ export type MotionIconProps = {
40
+ /** The held state. Drives `active` vs `rest`, and the stroke weight. */
41
+ active?: boolean;
42
+ /**
43
+ * Bump on pointer down to perform the gesture. Changing this value replays
44
+ * `play` even when `active` has not changed, which is what makes the icon
45
+ * respond to the touch rather than to its consequence.
46
+ */
47
+ replayToken?: number;
48
+ className?: string;
49
+ };
50
+
51
+ export type MotionIconVariant = "rest" | "active" | "play";
52
+
53
+ /**
54
+ * A gesture is a performance with beats, so it is a tween with explicit
55
+ * `times`. A spring interpolates two keyframes and puts its overshoot at the
56
+ * end, which cannot express anticipation.
57
+ */
58
+ export function beats(times: number[], duration = 0.62): Transition {
59
+ return { duration, times, ease: "easeInOut" };
60
+ }
61
+
62
+ /** Leaving is not a performance — it returns the glyph, firmly and fast. */
63
+ export const returnTransition: Transition = { type: "spring", stiffness: 320, damping: 26 };
64
+
65
+ type PoseValues = Record<string, number>;
66
+
67
+ /**
68
+ * Builds the three variants for a moving part.
69
+ * `play` values are keyframe arrays; `rest`/`active` are the two held poses.
70
+ */
71
+ export function poses(
72
+ rest: PoseValues,
73
+ active: PoseValues,
74
+ play: Record<string, number[]>,
75
+ transition: Transition
76
+ ): Variants {
77
+ return {
78
+ rest: { ...rest, transition: returnTransition },
79
+ active: { ...active, transition: returnTransition },
80
+ play: { ...play, transition },
81
+ };
82
+ }
83
+
84
+ const rootVariants: Variants = {
85
+ rest: { strokeWidth: 1.75, transition: { duration: 0.18 } },
86
+ active: { strokeWidth: 2.25, transition: { duration: 0.18 } },
87
+ play: { strokeWidth: 2.25, transition: { duration: 0.12 } },
88
+ };
89
+
90
+ /**
91
+ * Mount poses without playing; pointer down plays; arriving plays unless the
92
+ * pointer already started it; leaving returns.
93
+ *
94
+ * Both effects compare against a ref rather than trusting the dependency array,
95
+ * so they fire once per actual change and not once per render.
96
+ */
97
+ export function useIconGesture(active: boolean, replayToken: number) {
98
+ const controls = useAnimationControls();
99
+ const lastPlayAt = React.useRef(0);
100
+ const seenToken = React.useRef(0);
101
+ // `null` until mounted, so the pose effect can tell a real change from the
102
+ // first run and never performs the gesture on page load.
103
+ const seenActive = React.useRef<boolean | null>(null);
104
+
105
+ React.useEffect(() => {
106
+ controls.set(active ? "active" : "rest");
107
+ seenActive.current = active;
108
+ // Mount only — the two effects below own everything after that.
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, []);
111
+
112
+ React.useEffect(() => {
113
+ if (replayToken === seenToken.current) return;
114
+ seenToken.current = replayToken;
115
+ lastPlayAt.current = Date.now();
116
+ void controls.start("play");
117
+ }, [replayToken, controls]);
118
+
119
+ React.useEffect(() => {
120
+ if (seenActive.current === null || seenActive.current === active) return;
121
+ seenActive.current = active;
122
+ if (!active) {
123
+ void controls.start("rest");
124
+ return;
125
+ }
126
+ // The pointer that caused this state change already started the gesture.
127
+ if (Date.now() - lastPlayAt.current < 600) return;
128
+ void controls.start("play");
129
+ }, [active, controls]);
130
+
131
+ return controls;
132
+ }
133
+
134
+ export function MotionIcon({
135
+ active = false,
136
+ replayToken = 0,
137
+ className,
138
+ children,
139
+ }: MotionIconProps & { children: React.ReactNode }) {
140
+ const controls = useIconGesture(active, replayToken);
141
+
142
+ return (
143
+ <motion.svg
144
+ viewBox="0 0 24 24"
145
+ // Parts leave the 24-unit box at the apex of a jump. Callers give the
146
+ // icon padding rather than clipping the gesture.
147
+ overflow="visible"
148
+ fill="none"
149
+ stroke="currentColor"
150
+ strokeLinecap="round"
151
+ strokeLinejoin="round"
152
+ variants={rootVariants}
153
+ // Without this the active icon replays on hydration and every other one
154
+ // flashes through its pose on first paint.
155
+ initial={false}
156
+ animate={controls}
157
+ className={cn("size-6", className)}
158
+ aria-hidden
159
+ >
160
+ {children}
161
+ </motion.svg>
162
+ );
163
+ }
@@ -10,7 +10,7 @@ const meta = {
10
10
  } satisfies Meta<typeof MultiSelectCombobox>;
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof MultiSelectCombobox>;
14
14
 
15
15
  const cuisines = [
16
16
  { value: "italian", label: "Italian" },
@@ -11,7 +11,7 @@ const meta = {
11
11
  } satisfies Meta<typeof PhoneFrame>;
12
12
 
13
13
  export default meta;
14
- type Story = StoryObj<typeof meta>;
14
+ type Story = StoryObj<typeof PhoneFrame>;
15
15
 
16
16
  export const Default: Story = {
17
17
  args: { width: 220 },
@@ -1,14 +1,14 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Rating } from "./rating";
3
3
 
4
- const meta = {
4
+ const meta: Meta<typeof Rating> = {
5
5
  title: "Components/Rating",
6
6
  component: Rating,
7
7
  tags: ["autodocs"],
8
- } satisfies Meta<typeof Rating>;
8
+ };
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof Rating>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => <Rating value={4} reviewCount={128} />,
@@ -10,7 +10,7 @@ const meta = {
10
10
  } satisfies Meta<typeof SearchableSelect>;
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof SearchableSelect>;
14
14
 
15
15
  const frameworks = [
16
16
  { value: "next", label: "Next.js", description: "The React Framework" },
@@ -9,7 +9,7 @@ const meta = {
9
9
  } satisfies Meta<typeof SelectionBar>;
10
10
 
11
11
  export default meta;
12
- type Story = StoryObj<typeof meta>;
12
+ type Story = StoryObj<typeof SelectionBar>;
13
13
 
14
14
  export const Default: Story = {
15
15
  render: () => (
@@ -1,14 +1,14 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { SkeletonCard } from "./skeleton-card";
3
3
 
4
- const meta = {
4
+ const meta: Meta<typeof SkeletonCard> = {
5
5
  title: "Components/SkeletonCard",
6
6
  component: SkeletonCard,
7
7
  tags: ["autodocs"],
8
- } satisfies Meta<typeof SkeletonCard>;
8
+ };
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof SkeletonCard>;
12
12
 
13
13
  export const Experience: Story = {
14
14
  render: () => <SkeletonCard variant="experience" className="w-[350px]" />,
@@ -10,7 +10,7 @@ const meta = {
10
10
  } satisfies Meta<typeof SlidingPanels>;
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof SlidingPanels>;
14
14
 
15
15
  function SlidingPanelsDemo() {
16
16
  const [currentPanel, setCurrentPanel] = useState("panel-1");
@@ -10,7 +10,7 @@ const meta = {
10
10
  } satisfies Meta<typeof Stepper>;
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof Stepper>;
14
14
 
15
15
  const steps = [
16
16
  { id: "account", title: "Account" },
@@ -1,14 +1,14 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { StickyContainer } from "./sticky-container";
3
3
 
4
- const meta = {
4
+ const meta: Meta<typeof StickyContainer> = {
5
5
  title: "Components/StickyContainer",
6
6
  component: StickyContainer,
7
7
  tags: ["autodocs"],
8
- } satisfies Meta<typeof StickyContainer>;
8
+ };
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof StickyContainer>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => (
@@ -9,7 +9,7 @@ const meta = {
9
9
  } satisfies Meta<typeof ToggleGroup>;
10
10
 
11
11
  export default meta;
12
- type Story = StoryObj<typeof meta>;
12
+ type Story = StoryObj<typeof ToggleGroup>;
13
13
 
14
14
  export const Default: Story = {
15
15
  render: () => (
@@ -3,7 +3,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tool
3
3
  import { Button } from "./button";
4
4
  import { Plus } from "lucide-react";
5
5
 
6
- const meta = {
6
+ const meta: Meta<typeof Tooltip> = {
7
7
  title: "Components/Tooltip",
8
8
  component: Tooltip,
9
9
  tags: ["autodocs"],
@@ -14,7 +14,7 @@ const meta = {
14
14
  </TooltipProvider>
15
15
  ),
16
16
  ],
17
- } satisfies Meta<typeof Tooltip>;
17
+ };
18
18
 
19
19
  export default meta;
20
20
  type Story = StoryObj<typeof meta>;
package/src/globals.css CHANGED
@@ -33,34 +33,77 @@ h6 {
33
33
  --sab: env(safe-area-inset-bottom);
34
34
  --sal: env(safe-area-inset-left);
35
35
 
36
- --background: oklch(0.97 0.012 80);
37
- --foreground: oklch(0.22 0.025 45);
38
- --card: oklch(0.99 0.006 70);
39
- --card-foreground: oklch(0.22 0.025 45);
40
- --popover: oklch(0.99 0.006 70);
41
- --popover-foreground: oklch(0.22 0.025 45);
36
+ /* ─── Light neutral ramp (0.8.3 — "warmth has edges") ─────────────────
37
+ Every surface and text token below is TRUE NEUTRAL: chroma exactly 0,
38
+ 8-bit R−B spread exactly 0. Grey. Not "warm grey", not "off-white" —
39
+ grey. That is deliberate and hard-won; read this before adding a tint.
40
+
41
+ These used to carry chroma 0.008-0.025 at hue 45-80, on the theory that a
42
+ faint warm tint everywhere expressed a warm brand. In practice a low-chroma
43
+ tint spread across large soft areas — page, chip fills, hover fills,
44
+ hairlines, shadows — does not read as warmth. It reads as a colour cast:
45
+ "tan", "peachy", "hurts my eyes", "unprofessional for a serious app".
46
+
47
+ It was walked down in stages — 0.012 → 0.005 → 0.0025 → 0.003 → 0 — and
48
+ was still being rejected at chroma 0.0025, an R−B spread of THREE, which
49
+ is about as small as an 8-bit tint gets. That is the finding: on a large
50
+ light field there is no amount of warm chroma small enough to be safe.
51
+ Any nonzero value at hue 45-80 eventually gets described as brown.
52
+
53
+ The rule that replaces it: warmth needs an EDGE and real saturation. It
54
+ lives in --accent, --gradient-primary, badges, CTAs and photography —
55
+ bounded things at chroma >= 0.09. Never in a fill, a blur or body text.
56
+ The contrast between a neutral field and a saturated accent is what makes
57
+ the accent read as intentional. Do not re-warm these. */
58
+ --background: oklch(0.986 0 0);
59
+ --foreground: oklch(0.18 0 0);
60
+ --card: oklch(1 0 0);
61
+ --card-foreground: oklch(0.18 0 0);
62
+ --popover: oklch(1 0 0);
63
+ --popover-foreground: oklch(0.18 0 0);
42
64
  --primary: oklch(0.25 0.05 35);
43
65
  --primary-foreground: oklch(0.98 0 0);
44
- --secondary: oklch(0.985 0.008 80);
45
- --secondary-foreground: oklch(0.22 0.025 45);
46
- --muted: oklch(0.985 0.008 80);
47
- --muted-foreground: oklch(0.62 0.015 45);
48
- --accent: oklch(0.94 0.04 50);
49
- --accent-foreground: oklch(0.22 0.025 45);
66
+ --secondary: oklch(0.955 0 0);
67
+ --secondary-foreground: oklch(0.18 0 0);
68
+ --muted: oklch(0.955 0 0);
69
+ --muted-foreground: oklch(0.52 0 0);
70
+ /* BREAKING in 0.8.3 — see CHANGELOG. --accent was oklch(0.94 0.04 50), a
71
+ pale peach FILL paired with a dark --accent-foreground. It is now the
72
+ brand terracotta, used as text and as a filled surface with WHITE
73
+ foreground. A consumer rendering `bg-accent text-accent-foreground` gets a
74
+ dark brown chip where it used to get a pale one. bearmenu-fe already ran
75
+ these values as a local override; promoting them stops Storybook and the
76
+ product disagreeing about what "accent" means. */
77
+ --accent: oklch(0.51 0.095 45);
78
+ --accent-foreground: oklch(1 0 0);
79
+ /* Recessed-well companion to --accent: a tinted surface for active chips,
80
+ wells and highlighted rows. Neutral on purpose — its call sites pair it
81
+ with `text-accent`, and the text is what carries the meaning. */
82
+ --accent-subtle: oklch(0.945 0 0);
50
83
  --destructive: oklch(0.577 0.245 27.325);
51
84
  --destructive-foreground: oklch(0.985 0 0);
52
- --border: oklch(0.9 0.012 60);
53
- --input: oklch(0.9 0.012 60);
85
+ --border: oklch(0.895 0 0);
86
+ --input: oklch(0.895 0 0);
54
87
  --ring: oklch(0.25 0.05 35);
55
88
  --chart-1: oklch(0.646 0.222 41.116);
56
89
  --chart-2: oklch(0.6 0.118 184.704);
57
90
  --chart-3: oklch(0.398 0.07 227.392);
58
91
  --chart-4: oklch(0.828 0.189 84.429);
59
92
  --chart-5: oklch(0.769 0.188 70.08);
60
- --surface: oklch(0.99 0.006 70);
93
+ --surface: oklch(1 0 0);
61
94
  --surface-foreground: var(--foreground);
62
- --surface-2: oklch(0.985 0.008 80);
63
- --line-soft: oklch(0.94 0.01 60);
95
+ --surface-2: oklch(0.968 0 0);
96
+ --line-soft: oklch(0.935 0 0);
97
+ /* The top plane. In light mode white IS the ceiling — "elevated" means card
98
+ + shadow, there is nothing above it to reach for — so this equals --card.
99
+ Dark reaches the same idea from below (see .dark). The asymmetry is
100
+ correct; do not "fix" the two into agreement. */
101
+ --surface-elevated: oklch(1 0 0);
102
+ /* Text tiers. --ink-secondary and --ink-tertiary sit between --foreground
103
+ and --muted-foreground for captions, counts and metadata. */
104
+ --ink-primary: var(--foreground);
105
+ --ink-secondary: oklch(0.45 0 0);
106
+ --ink-tertiary: oklch(0.52 0 0);
64
107
  --radius: 0.625rem;
65
108
  --sidebar: oklch(0.985 0 0);
66
109
  --sidebar-foreground: oklch(0.145 0 0);
@@ -75,6 +118,26 @@ h6 {
75
118
  --warning: oklch(0.72 0.18 68);
76
119
  --warning-foreground: oklch(0.18 0.04 45);
77
120
 
121
+ /* ─── Brand gradient ──────────────────────────────────────────────────
122
+ The terracotta ramp used by the FAB, primary buttons, banner CTAs and
123
+ featured surfaces. This — not any surface tint — is where the brand's
124
+ warmth lives: chroma 0.09-0.10, on bounded elements. */
125
+ --gradient-primary: linear-gradient(180deg, oklch(0.58 0.1 45) 0%, oklch(0.44 0.09 42) 100%);
126
+ --gradient-primary-from: oklch(0.58 0.1 45);
127
+ --gradient-primary-to: oklch(0.44 0.09 42);
128
+
129
+ /* ─── Generic elevation ───────────────────────────────────────────────
130
+ Pure neutral (chroma 0) at L 0.52. These were a saturated brown, which put
131
+ a soft-edged tan patch under every card, row and overlay — the single
132
+ largest warm area on a light page, and the hardest to attribute because a
133
+ blur has no boundary to blame it on. Alphas are the calibrated set; only
134
+ the tint changed. Apps compose these; they must not re-tint them. */
135
+ --shadow-card: 0 1px 2px oklch(0.52 0 0 / 0.05), 0 2px 6px -1px oklch(0.52 0 0 / 0.08);
136
+ --shadow-card-hover:
137
+ 0 2px 4px -1px oklch(0.52 0 0 / 0.07), 0 10px 24px -6px oklch(0.52 0 0 / 0.16);
138
+ --shadow-overlay:
139
+ 0 4px 12px -2px oklch(0.52 0 0 / 0.1), 0 16px 48px -12px oklch(0.52 0 0 / 0.2);
140
+
78
141
  /* Brown variant — replaces hardcoded #482E1F across button/badge */
79
142
  --brown: oklch(0.31 0.05 45);
80
143
  --brown-foreground: oklch(0.985 0 0);
@@ -90,10 +153,18 @@ h6 {
90
153
  --shadow-alpha-2xl: 0.25;
91
154
 
92
155
  /* ─── Card cover shadows (companion to lib/card-hover.ts) ─────────────
93
- The warm lift under a card's cover region, and its deepened hover state.
156
+ The lift under a card's cover region, and its deepened hover state.
94
157
  Deliberately separate from the --shadow-alpha-* ladder above: that is a
95
- neutral black ramp for surfaces, this is the warm brown cast that only
96
- reads correctly under photography and generated cover art.
158
+ pure-black ramp for surfaces, this is a softer, lighter cast tuned for
159
+ photography and generated cover art.
160
+
161
+ 0.8.3: was rgba(120, 80, 50, …) — an 8-bit R−B spread of 70, by far the
162
+ most saturated shadow in the system, compositing to spread 18-23 under
163
+ every card cover. It survived an entire light-theme neutralisation pass
164
+ because it lives here and nothing overrode it. Now oklch(0.45 0 0),
165
+ matched on LUMINANCE (0.091 vs the old 0.100) so the visual weight is
166
+ unchanged, with the tint gone entirely. Same reasoning as the light ramp
167
+ above: a wide blur cannot hold a tint without reading as a stain.
97
168
 
98
169
  --shadow-menu-cover-inset is the pressed-in "well" edge carried by covers
99
170
  that render a generated illustration instead of a photo. It has to live in
@@ -104,8 +175,8 @@ h6 {
104
175
  Consumer apps must not redefine these: their stylesheet is imported after
105
176
  this one and would silently win, which is the drift these tokens exist to
106
177
  end. */
107
- --shadow-card-image: 0 4px 14px rgba(120, 80, 50, 0.22);
108
- --shadow-card-image-hover: 0 12px 28px rgba(120, 80, 50, 0.3);
178
+ --shadow-card-image: 0 4px 14px oklch(0.45 0 0 / 0.22);
179
+ --shadow-card-image-hover: 0 12px 28px oklch(0.45 0 0 / 0.3);
109
180
  --shadow-menu-cover-inset:
110
181
  inset 0 1px 2px oklch(1 0 0 / 0.15), inset 0 -1px 4px oklch(0 0 0 / 0.18);
111
182
 
@@ -148,35 +219,52 @@ h6 {
148
219
  }
149
220
 
150
221
  .dark {
151
- --background: oklch(0.17 0.014 45);
152
- --foreground: oklch(0.97 0.008 60);
153
- --card: oklch(0.21 0.016 45);
154
- --card-foreground: oklch(0.97 0.008 60);
155
- --popover: oklch(0.21 0.016 45);
156
- --popover-foreground: oklch(0.97 0.008 60);
157
- --primary: oklch(0.98 0 0);
158
- --primary-foreground: oklch(0.25 0.05 35);
222
+ /* Dark keeps its warmth: on a dark canvas a low-chroma warm tint reads as
223
+ "espresso", not as a cast — the light-mode problem is a light-mode
224
+ problem. These are bearmenu-fe's shipped dark values, promoted in 0.8.3
225
+ alongside the light ramp so both themes have one source of truth. */
226
+ --background: oklch(0.155 0.01 45);
227
+ --foreground: oklch(0.96 0.005 60);
228
+ --card: oklch(0.205 0.01 45);
229
+ --card-foreground: oklch(0.96 0.005 60);
230
+ --popover: oklch(0.205 0.01 45);
231
+ --popover-foreground: oklch(0.96 0.005 60);
232
+ --primary: oklch(0.66 0.09 45);
233
+ --primary-foreground: oklch(0.155 0.01 45);
159
234
  --secondary: oklch(0.24 0.018 45);
160
- --secondary-foreground: oklch(0.97 0.008 60);
161
- --muted: oklch(0.24 0.018 45);
162
- --muted-foreground: oklch(0.6 0.012 60);
163
- --accent: oklch(0.3 0.04 50);
164
- --accent-foreground: oklch(0.97 0.008 60);
235
+ --secondary-foreground: oklch(0.96 0.005 60);
236
+ --muted: oklch(0.22 0.01 45);
237
+ --muted-foreground: oklch(0.72 0.008 60);
238
+ /* BREAKING alongside light: --accent is the terracotta, not a dark tint. */
239
+ --accent: oklch(0.66 0.09 45);
240
+ --accent-foreground: oklch(0.155 0.01 45);
241
+ --accent-subtle: oklch(0.255 0.022 45);
165
242
  /* AA-compliant on dark surfaces but dialed back from 0.68 to soften eye strain */
166
243
  --destructive: oklch(0.58 0.17 25.7);
167
244
  --destructive-foreground: oklch(0.985 0 0);
168
- --border: oklch(0.3 0.015 45);
169
- --input: oklch(0.3 0.015 45);
245
+ --border: oklch(0.32 0.01 45);
246
+ --input: oklch(0.32 0.01 45);
170
247
  --ring: oklch(0.98 0 0);
171
248
  --chart-1: oklch(0.488 0.243 264.376);
172
249
  --chart-2: oklch(0.696 0.17 162.48);
173
250
  --chart-3: oklch(0.769 0.188 70.08);
174
251
  --chart-4: oklch(0.627 0.265 303.9);
175
252
  --chart-5: oklch(0.645 0.246 16.439);
176
- --surface: oklch(0.21 0.016 45);
253
+ --surface: oklch(0.205 0.01 45);
177
254
  --surface-foreground: var(--foreground);
178
255
  --surface-2: oklch(0.24 0.018 45);
179
256
  --line-soft: oklch(0.26 0.014 45);
257
+ /* Dark reaches "top plane" from BELOW — a step up off the canvas — where
258
+ light reaches it by being white. Same token, opposite direction. */
259
+ --surface-elevated: oklch(0.225 0.012 45);
260
+ --ink-primary: var(--foreground);
261
+ --ink-secondary: oklch(0.78 0.005 60);
262
+ --ink-tertiary: oklch(0.6 0.006 60);
263
+ /* Black, not the light ramp's near-neutral grey: on a 0.155 canvas a grey
264
+ shadow has nothing to darken. */
265
+ --shadow-card: 0 1px 3px oklch(0 0 0 / 0.2), 0 1px 2px oklch(0 0 0 / 0.15);
266
+ --shadow-card-hover: 0 4px 16px oklch(0 0 0 / 0.3), 0 1px 4px oklch(0 0 0 / 0.2);
267
+ --shadow-overlay: 0 8px 32px oklch(0 0 0 / 0.4), 0 2px 8px oklch(0 0 0 / 0.25);
180
268
  --sidebar: oklch(0.205 0 0);
181
269
  --sidebar-foreground: oklch(0.985 0 0);
182
270
  --sidebar-primary: oklch(0.488 0.243 264.376);
@@ -262,7 +350,12 @@ h6 {
262
350
  --color-surface: var(--surface);
263
351
  --color-surface-foreground: var(--surface-foreground);
264
352
  --color-surface-2: var(--surface-2);
353
+ --color-surface-elevated: var(--surface-elevated);
265
354
  --color-line-soft: var(--line-soft);
355
+ --color-accent-subtle: var(--accent-subtle);
356
+ --color-ink-primary: var(--ink-primary);
357
+ --color-ink-secondary: var(--ink-secondary);
358
+ --color-ink-tertiary: var(--ink-tertiary);
266
359
 
267
360
  /* Theme-aware shadow utilities — override Tailwind defaults so they
268
361
  stay visible on dark surfaces */
@@ -294,15 +387,44 @@ h6 {
294
387
  @apply border-border outline-ring/50;
295
388
  }
296
389
 
390
+ /* Reserve the scrollbar gutter so content does not jump when a page grows
391
+ * past one viewport, or when a modal locks scrolling.
392
+ *
393
+ * This was `overflow-y: scroll`, which reserved the same space by forcing a
394
+ * permanent scrollbar — and silently disabled every scroll lock in every
395
+ * consumer app.
396
+ *
397
+ * WHY IT BROKE THEM. `react-remove-scroll` — which Radix Dialog and Sheet
398
+ * use, and which vaul's Drawer sits on top of — locks the page by setting
399
+ * `overflow: hidden !important` on BODY. That only reaches the viewport
400
+ * through CSS overflow propagation, and the propagation rule applies ONLY
401
+ * while the ROOT element's overflow is `visible`: give <html> any explicit
402
+ * overflow and the viewport takes its value from <html> instead, so the body
403
+ * rule merely clips the body box while the document scrolls on behind the
404
+ * open dialog. Symptom: the page scrolls under every drawer and sheet, in
405
+ * every browser except Safari — where vaul has a second, `position: fixed`
406
+ * lock that does not depend on propagation (`usePositionFixed`, Safari-gated),
407
+ * which is what makes this look intermittent.
408
+ *
409
+ * `scrollbar-gutter: stable` reserves the same space without touching
410
+ * overflow, so <html> stays `visible` and the lock works as designed. Safari
411
+ * below 18.2 ignores the property, but Safari's overlay scrollbars take no
412
+ * layout space there, so there is nothing to reserve and nothing to shift.
413
+ *
414
+ * DO NOT put an `overflow` of any kind back on <html>. An app that needs to
415
+ * clip an axis puts it on `body`, which propagates rather than blocking. */
297
416
  html {
298
- overflow-y: scroll; /* Always show vertical scrollbar to prevent layout shift */
417
+ scrollbar-gutter: stable;
299
418
  }
300
419
 
301
420
  body {
302
421
  @apply bg-background text-foreground;
303
422
  }
304
423
 
305
- /* Prevent Radix UI from adding padding when locking scroll */
424
+ /* `react-remove-scroll` compensates for the scrollbar it removes by adding an
425
+ * equal `margin-right`. The gutter above is reserved permanently and never
426
+ * goes away, so that compensation would double up and shove the page sideways
427
+ * by a scrollbar width every time a dialog opens. Keep this. */
306
428
  body[data-scroll-locked] {
307
429
  margin-right: 0px !important;
308
430
  }