@bearmenu/ui 0.8.44 → 0.8.45

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.
@@ -36,35 +36,50 @@ function DriftRail({
36
36
  observer.observe(node);
37
37
  return () => observer.disconnect();
38
38
  }, []);
39
+ const half = useRef(0);
40
+ useEffect(() => {
41
+ const track = trackRef.current;
42
+ if (!track || typeof ResizeObserver === "undefined") return void 0;
43
+ const observer = new ResizeObserver(() => {
44
+ half.current = track.scrollWidth / 2;
45
+ });
46
+ observer.observe(track);
47
+ return () => observer.disconnect();
48
+ }, []);
49
+ useEffect(() => {
50
+ const scroller = scrollerRef.current;
51
+ if (!scroller) return void 0;
52
+ const onScroll = () => {
53
+ if (half.current > 0 && scroller.scrollLeft >= half.current) {
54
+ scroller.scrollLeft -= half.current;
55
+ }
56
+ };
57
+ scroller.addEventListener("scroll", onScroll, { passive: true });
58
+ return () => scroller.removeEventListener("scroll", onScroll);
59
+ }, []);
39
60
  useEffect(() => {
40
61
  const scroller = scrollerRef.current;
41
62
  const track = trackRef.current;
42
63
  if (!scroller || !track) return void 0;
64
+ if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
65
+ const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
66
+ if (!drifting) return void 0;
43
67
  let frame = 0;
44
68
  let last = performance.now();
45
- const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
46
- if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
47
69
  position.current = scroller.scrollLeft;
48
70
  const tick = (now) => {
49
- const half = track.scrollWidth / 2;
50
- if (half > 0) {
51
- const driftingNow = drifting && now >= heldUntil.current;
52
- if (driftingNow) {
53
- const perSecond = half / durationSeconds;
71
+ const width = half.current;
72
+ if (width > 0) {
73
+ if (now >= heldUntil.current) {
74
+ const perSecond = width / durationSeconds;
54
75
  const delta = perSecond * (now - last) / 1e3;
55
76
  position.current += reverse ? -delta : delta;
77
+ if (position.current >= width) position.current -= width;
78
+ else if (position.current < 0) position.current += width;
79
+ scroller.scrollLeft = position.current;
56
80
  } else {
57
81
  position.current = scroller.scrollLeft;
58
82
  }
59
- let wrapped = false;
60
- if (position.current >= half) {
61
- position.current -= half;
62
- wrapped = true;
63
- } else if (position.current < 0) {
64
- position.current += half;
65
- wrapped = true;
66
- }
67
- if (driftingNow || wrapped) scroller.scrollLeft = position.current;
68
83
  }
69
84
  last = now;
70
85
  frame = requestAnimationFrame(tick);
@@ -1,3 +1,3 @@
1
- export { DriftRail } from '../chunk-AHL45DD2.js';
1
+ export { DriftRail } from '../chunk-HJTENOB2.js';
2
2
  import '../chunk-FEK5XGZW.js';
3
3
  import '../chunk-MMUBH76A.js';
@@ -29,7 +29,7 @@ function EventIndexRow({
29
29
  const dot = /* @__PURE__ */ jsx("span", { className: "px-1 text-border", "aria-hidden": true, children: "\xB7" });
30
30
  return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
31
31
  !hideRule && /* @__PURE__ */ jsx(Separator, { className: "absolute inset-x-0 top-0" }),
32
- /* @__PURE__ */ jsxs("div", { className: "relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5", children: [
32
+ /* @__PURE__ */ jsxs("div", { className: "relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 active:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5", children: [
33
33
  /* @__PURE__ */ jsxs("div", { className: "relative aspect-square w-[72px] shrink-0 overflow-hidden rounded-[9px] bg-muted md:w-[104px]", children: [
34
34
  /* @__PURE__ */ jsx("div", { className: "absolute inset-0", children: fallback }),
35
35
  posterUrl && // eslint-disable-next-line @next/next/no-img-element
@@ -49,7 +49,7 @@ function EventIndexRow({
49
49
  LinkComponent,
50
50
  {
51
51
  href,
52
- className: "rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
52
+ className: "rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary active:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
53
53
  children: title
54
54
  }
55
55
  ) }),
@@ -61,7 +61,7 @@ function EventIndexRow({
61
61
  LinkComponent,
62
62
  {
63
63
  href: venueHref,
64
- className: "relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
64
+ className: "relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary active:text-primary active:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
65
65
  children: venue
66
66
  }
67
67
  ) : /* @__PURE__ */ jsx("span", { className: "font-semibold text-foreground/85", children: venue })
@@ -52,7 +52,7 @@ function MenuIndexList({
52
52
  LinkComponent,
53
53
  {
54
54
  href: entry.href,
55
- className: "group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none",
55
+ className: "group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent active:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none",
56
56
  children: [
57
57
  /* @__PURE__ */ jsx(
58
58
  "span",
@@ -67,7 +67,7 @@ function MenuIndexList({
67
67
  /* @__PURE__ */ jsx(
68
68
  ChevronRight,
69
69
  {
70
- className: "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none",
70
+ className: "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent group-active:text-accent motion-reduce:transition-none",
71
71
  "aria-hidden": true
72
72
  }
73
73
  )
@@ -50,6 +50,9 @@ type PosterStackPoster = {
50
50
  caption?: string;
51
51
  /** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
52
52
  fallback?: React.ReactNode;
53
+ /** Load now at high priority instead of lazily. For the poster that is the
54
+ * page's LCP element; every other poster stays lazy. */
55
+ eager?: boolean;
53
56
  };
54
57
  type PosterStackProps = {
55
58
  /** Candidates in order. The first three whose image loads are shown, so
@@ -85,7 +85,8 @@ function PosterStack({
85
85
  {
86
86
  src: poster.src,
87
87
  alt: poster.caption ? "" : poster.alt,
88
- loading: "lazy",
88
+ loading: poster.eager ? "eager" : "lazy",
89
+ fetchPriority: poster.eager ? "high" : void 0,
89
90
  decoding: "async",
90
91
  className: cn(
91
92
  "h-full w-full object-cover",
@@ -1,4 +1,4 @@
1
- import { DriftRail } from '../chunk-AHL45DD2.js';
1
+ import { DriftRail } from '../chunk-HJTENOB2.js';
2
2
  import { EventPosterFallback } from '../chunk-FDITZ2VM.js';
3
3
  import { cn } from '../chunk-FEK5XGZW.js';
4
4
  import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
@@ -14,10 +14,10 @@ var ACTION_BASE_CLASSES = cn(
14
14
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
15
15
  );
16
16
  var ACTION_TONE_CLASSES = {
17
- muted: "text-[14px] text-muted-foreground hover:text-foreground",
17
+ muted: "text-[14px] text-muted-foreground hover:text-foreground active:text-foreground",
18
18
  app: cn(
19
- "text-[12.5px] font-semibold text-accent hover:text-accent/80",
20
- "lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground"
19
+ "text-[12.5px] font-semibold text-accent hover:text-accent/80 active:text-accent/80",
20
+ "lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground lg:active:text-foreground"
21
21
  )
22
22
  };
23
23
  function SectionHeader({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.44",
3
+ "version": "0.8.45",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { act, fireEvent, render } from "@testing-library/react";
3
+ import { DriftRail } from "./drift-rail";
4
+
5
+ let intersect: (isIntersecting: boolean) => void = () => {};
6
+ let resize: () => void = () => {};
7
+ let frames = new Map<number, (now: number) => void>();
8
+ let nextFrame = 0;
9
+
10
+ beforeEach(() => {
11
+ frames = new Map();
12
+ vi.stubGlobal("matchMedia", () => ({
13
+ matches: false,
14
+ addEventListener() {},
15
+ removeEventListener() {},
16
+ }));
17
+ vi.stubGlobal(
18
+ "IntersectionObserver",
19
+ class {
20
+ constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
21
+ intersect = (isIntersecting) => callback([{ isIntersecting }]);
22
+ }
23
+ observe() {}
24
+ disconnect() {}
25
+ }
26
+ );
27
+ vi.stubGlobal(
28
+ "ResizeObserver",
29
+ class {
30
+ constructor(callback: () => void) {
31
+ resize = callback;
32
+ }
33
+ observe() {}
34
+ disconnect() {}
35
+ }
36
+ );
37
+ vi.stubGlobal("requestAnimationFrame", (callback: (now: number) => void) => {
38
+ nextFrame += 1;
39
+ frames.set(nextFrame, callback);
40
+ return nextFrame;
41
+ });
42
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
43
+ });
44
+ afterEach(() => {
45
+ vi.unstubAllGlobals();
46
+ });
47
+
48
+ /** A rail whose track is 1000px wide (two 500px copies). */
49
+ function renderRail() {
50
+ const { container } = render(
51
+ <DriftRail durationSeconds={10}>
52
+ <span>a</span>
53
+ <span>b</span>
54
+ </DriftRail>
55
+ );
56
+ const scroller = container.firstElementChild as HTMLDivElement;
57
+ const track = scroller.firstElementChild as HTMLDivElement;
58
+ const scrollWidth = vi.fn(() => 1000);
59
+ Object.defineProperty(track, "scrollWidth", { get: scrollWidth });
60
+ act(() => resize());
61
+ return { scroller, scrollWidth };
62
+ }
63
+
64
+ function runFrame(now: number) {
65
+ const pending = [...frames.values()];
66
+ frames.clear();
67
+ pending.forEach((frame) => frame(now));
68
+ }
69
+
70
+ describe("DriftRail", () => {
71
+ it("drifts at one copy per duration without measuring the track each frame", () => {
72
+ const { scroller, scrollWidth } = renderRail();
73
+ const readsAfterMount = scrollWidth.mock.calls.length;
74
+ const start = performance.now();
75
+
76
+ runFrame(start + 1000);
77
+ runFrame(start + 2000);
78
+
79
+ // 500px per 10s is 50px per second.
80
+ expect(scroller.scrollLeft).toBeGreaterThan(0);
81
+ expect(scrollWidth.mock.calls.length).toBe(readsAfterMount);
82
+ });
83
+
84
+ it("stops the frame loop entirely while off screen", () => {
85
+ renderRail();
86
+ act(() => intersect(false));
87
+ runFrame(performance.now() + 16);
88
+ expect(frames.size).toBe(0);
89
+
90
+ act(() => intersect(true));
91
+ expect(frames.size).toBeGreaterThan(0);
92
+ });
93
+
94
+ it("wraps the reader's own scroll by one copy, with no frame loop running", () => {
95
+ const { scroller } = renderRail();
96
+ act(() => intersect(false));
97
+ scroller.scrollLeft = 620;
98
+ fireEvent.scroll(scroller);
99
+ expect(scroller.scrollLeft).toBe(120);
100
+ });
101
+ });
@@ -88,44 +88,66 @@ export function DriftRail({
88
88
  return () => observer.disconnect();
89
89
  }, []);
90
90
 
91
- // The drift, and the wrap. Runs while mounted; the wrap has to run even
92
- // while the reader holds the rail, so the loop never pauses — it only stops
93
- // ADDING distance when something says it should.
91
+ // One copy's width, kept by a ResizeObserver. Read off the track on every
92
+ // frame, it forced a style and layout pass per frame (measured on /events at
93
+ // up to 558ms of recalc per second).
94
+ const half = useRef(0);
95
+ useEffect(() => {
96
+ const track = trackRef.current;
97
+ if (!track || typeof ResizeObserver === "undefined") return undefined;
98
+ const observer = new ResizeObserver(() => {
99
+ half.current = track.scrollWidth / 2;
100
+ });
101
+ observer.observe(track);
102
+ return () => observer.disconnect();
103
+ }, []);
104
+
105
+ // The wrap, for the reader's own scrolling: the two copies are identical,
106
+ // so jumping by exactly one copy's width is invisible even mid-swipe. On a
107
+ // scroll event rather than in the frame loop, so the loop can stop.
108
+ useEffect(() => {
109
+ const scroller = scrollerRef.current;
110
+ if (!scroller) return undefined;
111
+ const onScroll = () => {
112
+ if (half.current > 0 && scroller.scrollLeft >= half.current) {
113
+ scroller.scrollLeft -= half.current;
114
+ }
115
+ };
116
+ scroller.addEventListener("scroll", onScroll, { passive: true });
117
+ return () => scroller.removeEventListener("scroll", onScroll);
118
+ }, []);
119
+
120
+ // The drift. The frame loop runs only while the rail can drift: on screen,
121
+ // motion allowed, not hovered. Otherwise it does no work at all.
94
122
  useEffect(() => {
95
123
  const scroller = scrollerRef.current;
96
124
  const track = trackRef.current;
97
125
  if (!scroller || !track) return undefined;
98
126
 
99
- let frame = 0;
100
- let last = performance.now();
101
- const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
102
-
103
127
  // Start the reverse loop inside the second copy so it has room to travel.
104
128
  if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
129
+
130
+ const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
131
+ if (!drifting) return undefined;
132
+
133
+ let frame = 0;
134
+ let last = performance.now();
105
135
  position.current = scroller.scrollLeft;
106
136
 
107
137
  const tick = (now: number) => {
108
- const half = track.scrollWidth / 2;
109
- if (half > 0) {
110
- const driftingNow = drifting && now >= heldUntil.current;
111
- if (driftingNow) {
112
- const perSecond = half / durationSeconds;
138
+ const width = half.current;
139
+ if (width > 0) {
140
+ if (now >= heldUntil.current) {
141
+ const perSecond = width / durationSeconds;
113
142
  const delta = (perSecond * (now - last)) / 1000;
114
143
  position.current += reverse ? -delta : delta;
144
+ if (position.current >= width) position.current -= width;
145
+ else if (position.current < 0) position.current += width;
146
+ scroller.scrollLeft = position.current;
115
147
  } else {
148
+ // Held: follow the reader, never fight the finger.
116
149
  position.current = scroller.scrollLeft;
117
150
  }
118
- // The wrap applies in both modes: the two copies are identical, so
119
- // jumping by exactly one copy's width is invisible even mid-swipe.
120
- let wrapped = false;
121
- if (position.current >= half) {
122
- position.current -= half;
123
- wrapped = true;
124
- } else if (position.current < 0) {
125
- position.current += half;
126
- wrapped = true;
127
- }
128
- if (driftingNow || wrapped) scroller.scrollLeft = position.current;
129
151
  }
130
152
  last = now;
131
153
  frame = requestAnimationFrame(tick);
@@ -100,7 +100,7 @@ export function EventIndexRow({
100
100
  <div className={cn("relative", className)}>
101
101
  {!hideRule && <Separator className="absolute inset-x-0 top-0" />}
102
102
 
103
- <div className="relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5">
103
+ <div className="relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 active:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5">
104
104
  {/* Square poster at the same 72/104 the rest of the row family uses —
105
105
  the editorial surfaces above this one are 4:3, but a third ratio
106
106
  inside the ROW family would break the sibling-shape rule for one
@@ -125,7 +125,7 @@ export function EventIndexRow({
125
125
  <Heading className="text-[15px] font-bold leading-snug tracking-[-0.01em] text-foreground md:text-[16.5px]">
126
126
  <LinkComponent
127
127
  href={href}
128
- className="rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
128
+ className="rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary active:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
129
129
  >
130
130
  {title}
131
131
  </LinkComponent>
@@ -139,7 +139,7 @@ export function EventIndexRow({
139
139
  {venueHref ? (
140
140
  <LinkComponent
141
141
  href={venueHref}
142
- className="relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
142
+ className="relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary active:text-primary active:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
143
143
  >
144
144
  {venue}
145
145
  </LinkComponent>
@@ -112,7 +112,7 @@ export function MenuIndexList({
112
112
  <Separator />
113
113
  <LinkComponent
114
114
  href={entry.href}
115
- className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
115
+ className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent active:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
116
116
  >
117
117
  {/* The family's mark: a dot in its hue. */}
118
118
  <span
@@ -131,7 +131,7 @@ export function MenuIndexList({
131
131
  {entry.countLabel ?? String(entry.count)}
132
132
  </span>
133
133
  <ChevronRight
134
- className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none"
134
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent group-active:text-accent motion-reduce:transition-none"
135
135
  aria-hidden
136
136
  />
137
137
  </LinkComponent>
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { PosterStack } from "./poster-stack";
4
+
5
+ // jsdom has neither; the stack checks reduced motion, then observes itself.
6
+ beforeEach(() => {
7
+ vi.stubGlobal("matchMedia", () => ({ matches: false }));
8
+ vi.stubGlobal(
9
+ "IntersectionObserver",
10
+ class {
11
+ observe() {}
12
+ disconnect() {}
13
+ }
14
+ );
15
+ });
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals();
18
+ });
19
+
20
+ const posters = [
21
+ { src: "/a.jpg", alt: "A", eager: true },
22
+ { src: "/b.jpg", alt: "B" },
23
+ { src: "/c.jpg", alt: "C" },
24
+ ];
25
+
26
+ describe("PosterStack eager", () => {
27
+ it("loads only the eager poster now, at high priority", () => {
28
+ const { container } = render(<PosterStack posters={posters} />);
29
+ const [first, ...rest] = Array.from(container.querySelectorAll("img"));
30
+
31
+ expect(first.getAttribute("loading")).toBe("eager");
32
+ expect(first.getAttribute("fetchpriority")).toBe("high");
33
+ for (const img of rest) {
34
+ expect(img.getAttribute("loading")).toBe("lazy");
35
+ expect(img.hasAttribute("fetchpriority")).toBe(false);
36
+ }
37
+ });
38
+
39
+ it("stays lazy when no poster opts in", () => {
40
+ const { container } = render(
41
+ <PosterStack posters={posters.map(({ src, alt }) => ({ src, alt }))} />
42
+ );
43
+ for (const img of container.querySelectorAll("img")) {
44
+ expect(img.getAttribute("loading")).toBe("lazy");
45
+ }
46
+ });
47
+ });
@@ -60,6 +60,9 @@ export type PosterStackPoster = {
60
60
  caption?: string;
61
61
  /** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
62
62
  fallback?: React.ReactNode;
63
+ /** Load now at high priority instead of lazily. For the poster that is the
64
+ * page's LCP element; every other poster stays lazy. */
65
+ eager?: boolean;
63
66
  };
64
67
 
65
68
  export type PosterStackProps = {
@@ -182,7 +185,8 @@ export function PosterStack({
182
185
  <img
183
186
  src={poster.src}
184
187
  alt={poster.caption ? "" : poster.alt}
185
- loading="lazy"
188
+ loading={poster.eager ? "eager" : "lazy"}
189
+ fetchPriority={poster.eager ? "high" : undefined}
186
190
  decoding="async"
187
191
  className={cn(
188
192
  "h-full w-full object-cover",
@@ -79,10 +79,10 @@ const ACTION_BASE_CLASSES = cn(
79
79
  );
80
80
 
81
81
  const ACTION_TONE_CLASSES = {
82
- muted: "text-[14px] text-muted-foreground hover:text-foreground",
82
+ muted: "text-[14px] text-muted-foreground hover:text-foreground active:text-foreground",
83
83
  app: cn(
84
- "text-[12.5px] font-semibold text-accent hover:text-accent/80",
85
- "lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground"
84
+ "text-[12.5px] font-semibold text-accent hover:text-accent/80 active:text-accent/80",
85
+ "lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground lg:active:text-foreground"
86
86
  ),
87
87
  } as const;
88
88