@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.
- package/dist/chunk-2WDJR6K7.js +76 -0
- package/dist/components/icon.d.ts +18 -0
- package/dist/components/icon.js +31 -0
- package/dist/components/motion-icon-controls.d.ts +26 -0
- package/dist/components/motion-icon-controls.js +380 -0
- package/dist/components/motion-icon-set.d.ts +27 -0
- package/dist/components/motion-icon-set.js +139 -0
- package/dist/components/motion-icon.d.ts +44 -0
- package/dist/components/motion-icon.js +3 -0
- package/dist/lib/icon-registry.d.ts +58 -0
- package/dist/lib/icon-registry.js +39 -0
- package/package.json +2 -1
- package/src/components/accordion.stories.tsx +1 -1
- package/src/components/combobox.stories.tsx +1 -1
- package/src/components/currency-input.stories.tsx +3 -3
- package/src/components/empty-state.stories.tsx +3 -3
- package/src/components/form.stories.tsx +1 -1
- package/src/components/icon.tsx +57 -0
- package/src/components/input-otp.stories.tsx +1 -1
- package/src/components/markdown-renderer.stories.tsx +1 -1
- package/src/components/motion-icon-controls.test.tsx +78 -0
- package/src/components/motion-icon-controls.tsx +552 -0
- package/src/components/motion-icon-set.stories.tsx +185 -0
- package/src/components/motion-icon-set.tsx +219 -0
- package/src/components/motion-icon.test.tsx +159 -0
- package/src/components/motion-icon.tsx +163 -0
- package/src/components/multi-select-combobox.stories.tsx +1 -1
- package/src/components/phone-frame.stories.tsx +1 -1
- package/src/components/rating.stories.tsx +3 -3
- package/src/components/searchable-select.stories.tsx +1 -1
- package/src/components/selection-bar.stories.tsx +1 -1
- package/src/components/skeleton-card.stories.tsx +3 -3
- package/src/components/sliding-panels.stories.tsx +1 -1
- package/src/components/stepper.stories.tsx +1 -1
- package/src/components/sticky-container.stories.tsx +3 -3
- package/src/components/toggle-group.stories.tsx +1 -1
- package/src/components/tooltip.stories.tsx +2 -2
- package/src/globals.css +162 -40
- package/src/lib/icon-registry.ts +139 -0
- 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
|
+
}
|
|
@@ -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
|
-
}
|
|
8
|
+
};
|
|
9
9
|
|
|
10
10
|
export default meta;
|
|
11
|
-
type Story = StoryObj<typeof
|
|
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
|
|
13
|
+
type Story = StoryObj<typeof SearchableSelect>;
|
|
14
14
|
|
|
15
15
|
const frameworks = [
|
|
16
16
|
{ value: "next", label: "Next.js", description: "The React Framework" },
|
|
@@ -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
|
-
}
|
|
8
|
+
};
|
|
9
9
|
|
|
10
10
|
export default meta;
|
|
11
|
-
type Story = StoryObj<typeof
|
|
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
|
|
13
|
+
type Story = StoryObj<typeof SlidingPanels>;
|
|
14
14
|
|
|
15
15
|
function SlidingPanelsDemo() {
|
|
16
16
|
const [currentPanel, setCurrentPanel] = useState("panel-1");
|
|
@@ -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
|
-
}
|
|
8
|
+
};
|
|
9
9
|
|
|
10
10
|
export default meta;
|
|
11
|
-
type Story = StoryObj<typeof
|
|
11
|
+
type Story = StoryObj<typeof StickyContainer>;
|
|
12
12
|
|
|
13
13
|
export const Default: Story = {
|
|
14
14
|
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
|
-
}
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
45
|
-
--secondary-foreground: oklch(0.
|
|
46
|
-
--muted: oklch(0.
|
|
47
|
-
--muted-foreground: oklch(0.
|
|
48
|
-
--accent
|
|
49
|
-
|
|
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.
|
|
53
|
-
--input: oklch(0.
|
|
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
|
|
93
|
+
--surface: oklch(1 0 0);
|
|
61
94
|
--surface-foreground: var(--foreground);
|
|
62
|
-
--surface-2: oklch(0.
|
|
63
|
-
--line-soft: oklch(0.
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
108
|
-
--shadow-card-image-hover: 0 12px 28px
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
--
|
|
156
|
-
--
|
|
157
|
-
--
|
|
158
|
-
--
|
|
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.
|
|
161
|
-
--muted: oklch(0.
|
|
162
|
-
--muted-foreground: oklch(0.
|
|
163
|
-
--accent
|
|
164
|
-
--accent
|
|
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.
|
|
169
|
-
--input: oklch(0.
|
|
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.
|
|
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
|
-
|
|
417
|
+
scrollbar-gutter: stable;
|
|
299
418
|
}
|
|
300
419
|
|
|
301
420
|
body {
|
|
302
421
|
@apply bg-background text-foreground;
|
|
303
422
|
}
|
|
304
423
|
|
|
305
|
-
/*
|
|
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
|
}
|