@bearmenu/ui 0.8.24 → 0.8.25

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 (89) hide show
  1. package/dist/{chunk-5LTILG3L.js → chunk-5HHRVJOT.js} +14 -1
  2. package/dist/{chunk-SDWN6L4O.js → chunk-AHHDC7ML.js} +1 -1
  3. package/dist/{chunk-I4RUOAU7.js → chunk-AHL45DD2.js} +11 -38
  4. package/dist/{chunk-5XREWOBB.js → chunk-IEIJP73R.js} +14 -1
  5. package/dist/{chunk-BKKQ7GCZ.js → chunk-QG6I2ILT.js} +1 -1
  6. package/dist/{chunk-TIQNM4XD.js → chunk-VEGFQSZA.js} +17 -1
  7. package/dist/components/alert-dialog.js +1 -1
  8. package/dist/components/badge.d.ts +1 -1
  9. package/dist/components/badge.js +1 -1
  10. package/dist/components/booking-row.d.ts +60 -0
  11. package/dist/components/booking-row.js +103 -0
  12. package/dist/components/booking-summary-card.d.ts +38 -0
  13. package/dist/components/booking-summary-card.js +67 -0
  14. package/dist/components/button.d.ts +2 -2
  15. package/dist/components/button.js +1 -1
  16. package/dist/components/calendar.d.ts +17 -3
  17. package/dist/components/calendar.js +100 -12
  18. package/dist/components/carousel.js +1 -1
  19. package/dist/components/coach-marks.js +1 -1
  20. package/dist/components/combobox.js +1 -1
  21. package/dist/components/concept-convergence.js +2 -2
  22. package/dist/components/drift-rail.d.ts +2 -29
  23. package/dist/components/drift-rail.js +1 -3
  24. package/dist/components/empty-state.js +1 -1
  25. package/dist/components/event-card.js +1 -1
  26. package/dist/components/event-compact-row.js +1 -1
  27. package/dist/components/event-detail-panel.js +1 -1
  28. package/dist/components/event-list-row-wide.js +2 -2
  29. package/dist/components/event-list-row.js +1 -1
  30. package/dist/components/filter-category-row.js +1 -1
  31. package/dist/components/hold-countdown.d.ts +43 -0
  32. package/dist/components/hold-countdown.js +90 -0
  33. package/dist/components/kitchen-dossier.js +1 -1
  34. package/dist/components/link-button.js +1 -1
  35. package/dist/components/menu-family-block.js +1 -1
  36. package/dist/components/menu-index-list.js +1 -1
  37. package/dist/components/menu-item.js +3 -3
  38. package/dist/components/motion-icon-set.d.ts +2 -1
  39. package/dist/components/motion-icon-set.js +27 -1
  40. package/dist/components/multi-select-combobox.js +2 -2
  41. package/dist/components/pagination.js +1 -1
  42. package/dist/components/party-counter.d.ts +36 -0
  43. package/dist/components/party-counter.js +61 -0
  44. package/dist/components/place-card.js +2 -2
  45. package/dist/components/place-details-mobile.js +1 -1
  46. package/dist/components/poster-tape.d.ts +2 -15
  47. package/dist/components/poster-tape.js +3 -12
  48. package/dist/components/searchable-select.js +1 -1
  49. package/dist/components/signature-chip.js +2 -2
  50. package/dist/components/sliding-panels.js +1 -1
  51. package/dist/components/time-slot.d.ts +73 -0
  52. package/dist/components/time-slot.js +106 -0
  53. package/dist/components/toggle-group.js +15 -2
  54. package/dist/components/toggle.d.ts +1 -1
  55. package/dist/components/toggle.js +1 -1
  56. package/dist/components/top-ranking-entry.js +3 -3
  57. package/dist/components/weekday-chip.d.ts +33 -0
  58. package/dist/components/weekday-chip.js +48 -0
  59. package/package.json +1 -1
  60. package/src/components/badge.stories.tsx +17 -0
  61. package/src/components/badge.tsx +15 -0
  62. package/src/components/booking-primitives.test.tsx +180 -0
  63. package/src/components/booking-row.stories.tsx +66 -0
  64. package/src/components/booking-row.tsx +168 -0
  65. package/src/components/booking-summary-card.stories.tsx +41 -0
  66. package/src/components/booking-summary-card.tsx +109 -0
  67. package/src/components/button.stories.tsx +23 -0
  68. package/src/components/button.tsx +13 -0
  69. package/src/components/calendar.stories.tsx +27 -0
  70. package/src/components/calendar.tsx +110 -3
  71. package/src/components/drift-rail.stories.tsx +1 -29
  72. package/src/components/drift-rail.tsx +4 -71
  73. package/src/components/hold-countdown.stories.tsx +68 -0
  74. package/src/components/hold-countdown.tsx +140 -0
  75. package/src/components/motion-icon-set.stories.tsx +2 -0
  76. package/src/components/motion-icon-set.tsx +42 -0
  77. package/src/components/party-counter.stories.tsx +57 -0
  78. package/src/components/party-counter.tsx +93 -0
  79. package/src/components/poster-tape.stories.tsx +0 -29
  80. package/src/components/poster-tape.tsx +5 -21
  81. package/src/components/time-slot.stories.tsx +85 -0
  82. package/src/components/time-slot.tsx +186 -0
  83. package/src/components/toggle-group.stories.tsx +27 -0
  84. package/src/components/toggle-group.tsx +14 -1
  85. package/src/components/toggle.tsx +16 -0
  86. package/src/components/weekday-chip.stories.tsx +54 -0
  87. package/src/components/weekday-chip.tsx +75 -0
  88. package/src/components/drift-rail.test.tsx +0 -125
  89. package/src/components/poster-tape.test.tsx +0 -42
@@ -7,23 +7,93 @@ import { DayPicker, getDefaultClassNames, type DayButton } from "react-day-picke
7
7
  import { cn } from "../lib/utils";
8
8
  import { Button, buttonVariants } from "./button";
9
9
 
10
+ /**
11
+ * `appearance="booking"` is the month grid of the booking flow's "When are you
12
+ * coming?" (desktop), measured from its design (`.cal`): seven equal columns
13
+ * 3px apart; single-letter weekdays at 9.5px bold, muted; days as 32px round
14
+ * cells in mono 12.5px medium, secondary ink; the chosen day filled with
15
+ * `--gradient-primary`, white and bold; past days at 30%; a day the venue is
16
+ * closed struck through in a lighter ink. No outside days. Mark closed days
17
+ * with `modifiers={{ closed: … }}` — the appearance styles the `closed`
18
+ * modifier; `disabled` covers past days and the booking window.
19
+ */
20
+ type CalendarAppearance = "default" | "booking";
21
+
22
+ const BOOKING_CLASSNAMES = {
23
+ root: "w-full",
24
+ months: "relative flex flex-col",
25
+ month: "flex w-full flex-col",
26
+ // The caption row is 15px with 7px under it; the chevrons sit in that row.
27
+ nav: "absolute inset-x-0 top-0 flex h-[15px] w-full items-center justify-between",
28
+ button_previous:
29
+ "grid size-6 -m-[4.5px] cursor-pointer place-items-center rounded-full text-muted-foreground hover:bg-surface-2 hover:text-foreground aria-disabled:pointer-events-none aria-disabled:opacity-30 [&_svg]:size-3.5",
30
+ button_next:
31
+ "grid size-6 -m-[4.5px] cursor-pointer place-items-center rounded-full text-muted-foreground hover:bg-surface-2 hover:text-foreground aria-disabled:pointer-events-none aria-disabled:opacity-30 [&_svg]:size-3.5",
32
+ month_caption: "mb-[7px] flex h-[15px] w-full items-center justify-center",
33
+ caption_label: "text-[12.5px] leading-[15px] font-bold text-foreground select-none",
34
+ month_grid: "w-full border-collapse",
35
+ weekdays: "grid grid-cols-7 gap-[3px]",
36
+ weekday: "pb-[3px] text-center text-[9.5px] font-bold text-muted-foreground select-none",
37
+ week: "mt-[3px] grid grid-cols-7 gap-[3px]",
38
+ day: "p-0 text-center select-none",
39
+ today: "",
40
+ outside: "invisible",
41
+ disabled: "",
42
+ hidden: "invisible",
43
+ } as const;
44
+
10
45
  function Calendar({
11
46
  className,
12
47
  classNames,
13
- showOutsideDays = true,
48
+ showOutsideDays,
14
49
  captionLayout = "label",
15
50
  buttonVariant = "ghost",
51
+ appearance = "default",
16
52
  formatters,
17
53
  components,
54
+ modifiersClassNames,
18
55
  ...props
19
56
  }: React.ComponentProps<typeof DayPicker> & {
20
57
  buttonVariant?: React.ComponentProps<typeof Button>["variant"];
58
+ appearance?: CalendarAppearance;
21
59
  }) {
22
60
  const defaultClassNames = getDefaultClassNames();
23
61
 
62
+ if (appearance === "booking") {
63
+ return (
64
+ <DayPicker
65
+ showOutsideDays={showOutsideDays ?? false}
66
+ className={cn("group/calendar w-full", className)}
67
+ captionLayout="label"
68
+ formatters={{
69
+ formatWeekdayName: (date) =>
70
+ date.toLocaleString("default", { weekday: "narrow" }),
71
+ ...formatters,
72
+ }}
73
+ classNames={{ ...BOOKING_CLASSNAMES, ...classNames }}
74
+ modifiersClassNames={{ closed: "is-closed", ...modifiersClassNames }}
75
+ components={{
76
+ Root: ({ className, rootRef, ...rootProps }) => (
77
+ <div data-slot="calendar" data-appearance="booking" ref={rootRef} className={cn(className)} {...rootProps} />
78
+ ),
79
+ Chevron: ({ className, orientation, ...chevronProps }) =>
80
+ orientation === "left" ? (
81
+ <ChevronLeftIcon className={cn("size-4", className)} {...chevronProps} />
82
+ ) : (
83
+ <ChevronRightIcon className={cn("size-4", className)} {...chevronProps} />
84
+ ),
85
+ DayButton: BookingDayButton,
86
+ ...components,
87
+ }}
88
+ {...props}
89
+ />
90
+ );
91
+ }
92
+
24
93
  return (
25
94
  <DayPicker
26
- showOutsideDays={showOutsideDays}
95
+ showOutsideDays={showOutsideDays ?? true}
96
+ modifiersClassNames={modifiersClassNames}
27
97
  className={cn(
28
98
  "group/calendar bg-background p-3 [--cell-size:--spacing(9)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
29
99
  String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
@@ -177,4 +247,41 @@ function CalendarDayButton({
177
247
  );
178
248
  }
179
249
 
180
- export { Calendar, CalendarDayButton };
250
+ /** The booking appearance's day: a 32px round cell, see `BOOKING_CLASSNAMES`. */
251
+ function BookingDayButton({
252
+ className,
253
+ day,
254
+ modifiers,
255
+ ...props
256
+ }: React.ComponentProps<typeof DayButton>) {
257
+ const ref = React.useRef<HTMLButtonElement>(null);
258
+ React.useEffect(() => {
259
+ if (modifiers.focused) ref.current?.focus();
260
+ }, [modifiers.focused]);
261
+
262
+ const closed = Boolean(modifiers.closed);
263
+
264
+ return (
265
+ <button
266
+ ref={ref}
267
+ type="button"
268
+ data-day={day.date.toLocaleDateString()}
269
+ data-selected={modifiers.selected || undefined}
270
+ data-closed={closed || undefined}
271
+ className={cn(
272
+ "grid h-8 w-full cursor-pointer place-items-center rounded-full font-mono text-[12.5px] leading-none font-medium text-ink-secondary tabular-nums",
273
+ "transition-colors duration-150 hover:bg-surface-2 motion-reduce:transition-none",
274
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
275
+ modifiers.selected &&
276
+ "bg-[image:var(--gradient-primary)] font-bold text-white hover:bg-transparent hover:brightness-[0.94]",
277
+ modifiers.disabled && !closed && "cursor-not-allowed opacity-30 hover:bg-transparent",
278
+ // A closed day is also disabled; it reads as closed, not as past.
279
+ closed && "cursor-not-allowed text-[oklch(0.7_0_0)] line-through hover:bg-transparent dark:text-ink-tertiary",
280
+ className
281
+ )}
282
+ {...props}
283
+ />
284
+ );
285
+ }
286
+
287
+ export { Calendar, CalendarDayButton, BookingDayButton };
@@ -32,7 +32,7 @@ const meta: Meta<typeof DriftRail> = {
32
32
  docs: {
33
33
  description: {
34
34
  component:
35
- "An infinite horizontal marquee the reader can take hold of. Duplicates its children once (the copy is `inert`) so the scroll loop is seamless — pass a fixed, ordered list. Pauses on hover, pauses when scrolled off-screen (`IntersectionObserver`), stops outright under `prefers-reduced-motion`, and with `labels` renders a pause/play latch (WCAG 2.2.2).",
35
+ "An infinite horizontal marquee. Duplicates its children once so a `translateX(-50%)` loop is seamless — pass a fixed, ordered list. Pauses on hover, pauses when scrolled off-screen (`IntersectionObserver`), and stops outright under `prefers-reduced-motion`.",
36
36
  },
37
37
  },
38
38
  },
@@ -90,31 +90,3 @@ export const WithFeaturedItems: Story = {
90
90
  </div>
91
91
  ),
92
92
  };
93
-
94
- /**
95
- * With `labels`, a pause/play toggle renders under the rail's right edge —
96
- * the same latch, glyph and 44px target as `WeekendPanel`'s (WCAG 2.2.2).
97
- * Pressing it holds the rail still until play is pressed; hover and touch
98
- * still pause it on their own. The control is not rendered under
99
- * `prefers-reduced-motion`, where nothing drifts. The duplicate pass is
100
- * `inert`, so Tab visits each item once.
101
- */
102
- export const WithPauseControl: Story = {
103
- render: () => (
104
- <div className="max-w-[420px]">
105
- <DriftRail durationSeconds={30} labels={{ pause: "Pause the rail", play: "Play the rail" }}>
106
- {["Warehouse Rave", "Jazz on the Rooftop", "Open Mic Night", "Street Food Market"].map(
107
- (title) => (
108
- <FeaturedRailItem
109
- key={title}
110
- title={title}
111
- venue="Atelier Café"
112
- note="Sat 5 · 20:00"
113
- href={`/events/${title.toLowerCase().replace(/\s+/g, "-")}`}
114
- />
115
- )
116
- )}
117
- </DriftRail>
118
- </div>
119
- ),
120
- };
@@ -3,7 +3,6 @@
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import type { PointerEvent as ReactPointerEvent } from "react";
5
5
  import { cn } from "../lib/utils";
6
- import { MotionPlayPause } from "./motion-icon-controls";
7
6
 
8
7
  /**
9
8
  * DriftRail — an infinite horizontal marquee for logos, tag chips or small
@@ -31,29 +30,7 @@ import { MotionPlayPause } from "./motion-icon-controls";
31
30
  * work nobody can see. `prefers-reduced-motion` removes the drift entirely;
32
31
  * the rail stays scrollable by hand, which is more than the static strip it
33
32
  * used to collapse to.
34
- *
35
- * THE READER CAN STOP IT FOR GOOD. Hover and touch are holds; a rail that
36
- * drifts for longer than five seconds also needs a control the reader can
37
- * press (WCAG 2.2.2), and a low-vision reader who cannot follow the motion
38
- * needs it to stay stopped. Pass `labels` and the rail renders a pause/play
39
- * toggle under its right edge — the same latch, glyph and 44px target as
40
- * `WeekendPanel`'s, so the hub's rails all answer to one control. It is not
41
- * rendered under `prefers-reduced-motion`, where there is nothing to stop.
42
- *
43
- * THE DUPLICATE IS INERT. The second copy is what makes the loop seamless,
44
- * but its links are the same links again: `aria-hidden` kept them out of the
45
- * accessibility tree and left them in the tab order, so a keyboard reader
46
- * tabbed through every item twice. `inert` removes them from focus and
47
- * hit-testing as well.
48
33
  */
49
- /** Accessible names for the pause/play toggle, injected so the DS owns no copy. */
50
- export type DriftRailLabels = {
51
- /** Name announced while the rail drifts (the button pauses it). */
52
- pause: string;
53
- /** Name announced while the rail is paused (the button resumes it). */
54
- play: string;
55
- };
56
-
57
34
  export type DriftRailProps = {
58
35
  children: React.ReactNode;
59
36
  /** Runs the loop right-to-left instead of left-to-right. */
@@ -62,12 +39,6 @@ export type DriftRailProps = {
62
39
  durationSeconds?: number;
63
40
  /** Pauses the loop while the pointer hovers the rail. */
64
41
  pauseOnHover?: boolean;
65
- /**
66
- * Accessible names for the pause/play toggle. When given, the toggle
67
- * renders whenever the rail drifts (never under `prefers-reduced-motion`);
68
- * without it there is no control, as before.
69
- */
70
- labels?: DriftRailLabels;
71
42
  className?: string;
72
43
  };
73
44
 
@@ -80,7 +51,6 @@ export function DriftRail({
80
51
  reverse = false,
81
52
  durationSeconds = DRIFT_RAIL_DEFAULT_DURATION_SECONDS,
82
53
  pauseOnHover = true,
83
- labels,
84
54
  className,
85
55
  }: DriftRailProps) {
86
56
  const scrollerRef = useRef<HTMLDivElement>(null);
@@ -88,9 +58,6 @@ export function DriftRail({
88
58
  const [isHovered, setIsHovered] = useState(false);
89
59
  const [inView, setInView] = useState(true);
90
60
  const [reducedMotion, setReducedMotion] = useState(false);
91
- // A latch, not a hold: it only clears when the reader presses play.
92
- const [isManuallyPaused, setIsManuallyPaused] = useState(false);
93
- const [playPauseReplayToken, setPlayPauseReplayToken] = useState(0);
94
61
  // `heldUntil` is a timestamp rather than a boolean so a native touch scroll
95
62
  // that keeps going after the finger lifts is not fought by the drift.
96
63
  const heldUntil = useRef(0);
@@ -131,8 +98,7 @@ export function DriftRail({
131
98
 
132
99
  let frame = 0;
133
100
  let last = performance.now();
134
- const drifting =
135
- !reducedMotion && !isManuallyPaused && inView && !(pauseOnHover && isHovered);
101
+ const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
136
102
 
137
103
  // Start the reverse loop inside the second copy so it has room to travel.
138
104
  if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
@@ -166,7 +132,7 @@ export function DriftRail({
166
132
  };
167
133
  frame = requestAnimationFrame(tick);
168
134
  return () => cancelAnimationFrame(frame);
169
- }, [durationSeconds, inView, isHovered, isManuallyPaused, pauseOnHover, reducedMotion, reverse]);
135
+ }, [durationSeconds, inView, isHovered, pauseOnHover, reducedMotion, reverse]);
170
136
 
171
137
  const hold = () => {
172
138
  heldUntil.current = Number.POSITIVE_INFINITY;
@@ -212,7 +178,7 @@ export function DriftRail({
212
178
  }
213
179
  };
214
180
 
215
- const scroller = (
181
+ return (
216
182
  <div
217
183
  ref={scrollerRef}
218
184
  onMouseEnter={() => setIsHovered(true)}
@@ -236,43 +202,10 @@ export function DriftRail({
236
202
  >
237
203
  <div ref={trackRef} className="flex w-max items-stretch gap-3.5">
238
204
  {children}
239
- {/* `inert` as well as `aria-hidden`: the copy's links must leave the
240
- tab order too, or a keyboard reader tabs every item twice. */}
241
- <div className="contents" aria-hidden="true" inert>
205
+ <div className="contents" aria-hidden="true">
242
206
  {children}
243
207
  </div>
244
208
  </div>
245
209
  </div>
246
210
  );
247
-
248
- // The toggle exists to stop a drift; under reduced motion there is none.
249
- // A sibling row, not an overlay: a button floated over the rail's corner
250
- // would sit on an item and intercept the grab that starts there.
251
- if (!labels || reducedMotion) return scroller;
252
-
253
- return (
254
- <>
255
- {scroller}
256
- <div className="flex justify-end">
257
- {/* `MotionPlayPause`, as in `WeekendPanel`: the triangle folding into
258
- two bars IS the state change. `active` is the bars, so it is
259
- `!isManuallyPaused`; `replayToken` bumps on pointer down so the
260
- gesture answers the touch. */}
261
- <button
262
- type="button"
263
- onClick={() => setIsManuallyPaused((current) => !current)}
264
- onPointerDown={() => setPlayPauseReplayToken((n) => n + 1)}
265
- aria-label={isManuallyPaused ? labels.play : labels.pause}
266
- aria-pressed={isManuallyPaused}
267
- className="flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none"
268
- >
269
- <MotionPlayPause
270
- active={!isManuallyPaused}
271
- replayToken={playPauseReplayToken}
272
- className="size-4"
273
- />
274
- </button>
275
- </div>
276
- </>
277
- );
278
211
  }
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button } from "./button";
3
+ import { HoldCountdown, type HoldCountdownLabels } from "./hold-countdown";
4
+
5
+ // A fixed clock so the story always reads 09:41 on first paint.
6
+ const NOW = Date.UTC(2026, 7, 14, 16, 0, 0);
7
+ const expiresAt = NOW + (9 * 60 + 41) * 1000;
8
+
9
+ const stackedLabels: HoldCountdownLabels = {
10
+ held: "Held for you",
11
+ explanation: "then it goes to the next person",
12
+ remaining: (m, s) => `${m} minutes ${s} seconds left`,
13
+ };
14
+
15
+ const inlineLabels: HoldCountdownLabels = {
16
+ ...stackedLabels,
17
+ explanation: "After the countdown the table goes to the next person on the list.",
18
+ };
19
+
20
+ const meta: Meta<typeof HoldCountdown> = {
21
+ title: "Booking/HoldCountdown",
22
+ component: HoldCountdown,
23
+ tags: ["autodocs"],
24
+ args: { expiresAt, now: () => NOW },
25
+ parameters: {
26
+ docs: {
27
+ description: {
28
+ component:
29
+ "How long a table offered from the waitlist stays held (\"A table opened up\"). Stacked on mobile; inline with its action on desktop. Ticks once a second and calls onExpire once, at zero.",
30
+ },
31
+ },
32
+ },
33
+ };
34
+
35
+ export default meta;
36
+ type Story = StoryObj<typeof HoldCountdown>;
37
+
38
+ /** B5a mobile. */
39
+ export const Stacked: Story = {
40
+ args: { labels: stackedLabels, layout: "stacked" },
41
+ decorators: [
42
+ (Story) => (
43
+ <div className="w-[343px]">
44
+ <Story />
45
+ </div>
46
+ ),
47
+ ],
48
+ };
49
+
50
+ /** B5a desktop. */
51
+ export const Inline: Story = {
52
+ args: {
53
+ labels: inlineLabels,
54
+ layout: "inline",
55
+ action: (
56
+ <Button variant="cta" size="cta-sm">
57
+ Confirm the table
58
+ </Button>
59
+ ),
60
+ },
61
+ decorators: [
62
+ (Story) => (
63
+ <div className="w-[520px]">
64
+ <Story />
65
+ </div>
66
+ ),
67
+ ],
68
+ };
@@ -0,0 +1,140 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * HoldCountdown — "HELD FOR YOU 09:41": how long a table offered from the
8
+ * waitlist stays reserved for this guest (the booking flow's "A table opened
9
+ * up").
10
+ *
11
+ * Measured from the booking design: a card with a warning-colour hairline,
12
+ * 14px radius, 15px inset.
13
+ * - `stacked` (mobile): centred. The label at 10.5px uppercase with 0.06em
14
+ * tracking, muted; the remaining time in mono at 34px bold; a muted
15
+ * 11.5px line under it ("then it goes to the next person").
16
+ * - `inline` (desktop): one row, 18px gaps. The label over the time at
17
+ * 30px, then the explanation at 12.5px muted taking the free width, then
18
+ * the action.
19
+ *
20
+ * Counts down from `expiresAt` once a second and calls `onExpire` once, at
21
+ * zero. It never shows a negative time.
22
+ */
23
+
24
+ export type HoldCountdownLabels = {
25
+ /** "Held for you" */
26
+ held: string;
27
+ /** Stacked: "then it goes to the next person". Inline: "After the countdown the table goes to the next person on the list." */
28
+ explanation: string;
29
+ /** Spoken form for assistive tech — "9 minutes 41 seconds left". */
30
+ remaining: (minutes: number, seconds: number) => string;
31
+ };
32
+
33
+ export type HoldCountdownProps = {
34
+ /** When the hold ends — ISO string or epoch milliseconds. */
35
+ expiresAt: string | number;
36
+ labels: HoldCountdownLabels;
37
+ layout?: "stacked" | "inline";
38
+ /** The inline layout's action, typically a `Button variant="cta" size="cta-sm"`. */
39
+ action?: React.ReactNode;
40
+ onExpire?: () => void;
41
+ /** Clock override for stories and tests. */
42
+ now?: () => number;
43
+ className?: string;
44
+ };
45
+
46
+ /**
47
+ * The design's hold amber (`--warnc`, oklch 0.62 0.15 70), deeper than the
48
+ * `--warning` token (0.72): on white the token reads as a yellow alert, the
49
+ * design's as a timer. Dark mode takes the lifted token, which already has
50
+ * the contrast against the dark card.
51
+ */
52
+ const HOLD_BORDER = "border-[oklch(0.62_0.15_70)] dark:border-warning";
53
+
54
+ function remainingSeconds(expiresAt: string | number, now: number): number {
55
+ const end = typeof expiresAt === "number" ? expiresAt : Date.parse(expiresAt);
56
+ return Math.max(0, Math.ceil((end - now) / 1000));
57
+ }
58
+
59
+ export function HoldCountdown({
60
+ expiresAt,
61
+ labels,
62
+ layout = "stacked",
63
+ action,
64
+ onExpire,
65
+ now = Date.now,
66
+ className,
67
+ }: HoldCountdownProps) {
68
+ const [left, setLeft] = React.useState(() => remainingSeconds(expiresAt, now()));
69
+ const expired = React.useRef(false);
70
+ const onExpireRef = React.useRef(onExpire);
71
+ onExpireRef.current = onExpire;
72
+
73
+ React.useEffect(() => {
74
+ expired.current = false;
75
+ const tick = () => {
76
+ const next = remainingSeconds(expiresAt, now());
77
+ setLeft(next);
78
+ if (next === 0 && !expired.current) {
79
+ expired.current = true;
80
+ onExpireRef.current?.();
81
+ }
82
+ };
83
+ tick();
84
+ const id = window.setInterval(tick, 1000);
85
+ return () => window.clearInterval(id);
86
+ }, [expiresAt, now]);
87
+
88
+ const minutes = Math.floor(left / 60);
89
+ const seconds = left % 60;
90
+ const display = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
91
+
92
+ const label = (
93
+ <div className="text-[10.5px] leading-[13px] tracking-[0.06em] text-muted-foreground uppercase">
94
+ {labels.held}
95
+ </div>
96
+ );
97
+ const time = (
98
+ <div
99
+ role="timer"
100
+ aria-label={labels.remaining(minutes, seconds)}
101
+ className={cn(
102
+ "font-mono font-bold tabular-nums text-foreground",
103
+ layout === "stacked" ? "mt-[5px] text-[34px] leading-[40px]" : "text-[30px] leading-[35px]"
104
+ )}
105
+ >
106
+ {display}
107
+ </div>
108
+ );
109
+
110
+ if (layout === "inline") {
111
+ return (
112
+ <div
113
+ data-slot="hold-countdown"
114
+ className={cn(
115
+ "flex items-center gap-[18px] rounded-[14px] border bg-card p-[15px] text-left",
116
+ HOLD_BORDER,
117
+ className
118
+ )}
119
+ >
120
+ <div className="shrink-0">
121
+ {label}
122
+ {time}
123
+ </div>
124
+ <p className="flex-1 text-[12.5px] leading-[15px] text-muted-foreground">{labels.explanation}</p>
125
+ {action}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ return (
131
+ <div
132
+ data-slot="hold-countdown"
133
+ className={cn("rounded-[14px] border bg-card p-[15px] text-center", HOLD_BORDER, className)}
134
+ >
135
+ {label}
136
+ {time}
137
+ <p className="mt-1 text-[11.5px] leading-[14px] text-muted-foreground">{labels.explanation}</p>
138
+ </div>
139
+ );
140
+ }
@@ -4,6 +4,7 @@ import type { Meta, StoryObj } from "@storybook/react";
4
4
  import {
5
5
  MotionHouse,
6
6
  MotionUtensils,
7
+ MotionCompass,
7
8
  MotionMapPin,
8
9
  MotionCalendar,
9
10
  MotionLayoutGrid,
@@ -62,6 +63,7 @@ type Glyph = { name: string; Icon: React.ComponentType<MotionIconProps>; gesture
62
63
  const OBJECTS: Glyph[] = [
63
64
  { name: "House", Icon: MotionHouse, gesture: "roof jumps, walls take the impact" },
64
65
  { name: "Utensils", Icon: MotionUtensils, gesture: "uncrosses into a place setting · holds" },
66
+ { name: "Compass", Icon: MotionCompass, gesture: "needle spins a turn, overshoots, hunts, settles · fills held" },
65
67
  { name: "MapPin", Icon: MotionMapPin, gesture: "jumps, fills on the descent · holds" },
66
68
  { name: "Calendar", Icon: MotionCalendar, gesture: "rolls four pages" },
67
69
  { name: "LayoutGrid", Icon: MotionLayoutGrid, gesture: "tiles shuffle one slot round the board" },
@@ -149,6 +149,48 @@ export function MotionUtensils(props: MotionIconProps) {
149
149
  );
150
150
  }
151
151
 
152
+ // ─── Compass — the needle spins hard, overshoots, wobbles, settles (cycle content)
153
+
154
+ const COMPASS_TIMES = [0, 0.14, 0.58, 0.76, 0.9, 1];
155
+
156
+ /**
157
+ * The dial holds still; the needle is the content that moves through it.
158
+ * It pulls back against the spin first (anticipation), goes round once and
159
+ * 20° past its bearing, swings back short of it, and settles — the way a
160
+ * real needle hunts for north. One full turn is what changes the silhouette
161
+ * at 22px: the diamond reads reversed at the half-way frame. Pivot at the
162
+ * dial's centre.
163
+ *
164
+ * The held poses sit at 360°, not 0°: the performance ends a full turn on
165
+ * from where it started, and a held pose at 0° would make the settle spin
166
+ * the needle backwards. 360° and 0° draw the same needle.
167
+ */
168
+ const compassNeedle: Variants = {
169
+ rest: { rotate: 360, fillOpacity: 0, transition: returnTransition },
170
+ active: { rotate: 360, fillOpacity: 1, transition: returnTransition },
171
+ play: {
172
+ rotate: [0, -24, 380, 352, 364, 360],
173
+ // The fill lands with the needle: it takes as the swing dies.
174
+ fillOpacity: [0, 0, 0, 0.6, 1, 1],
175
+ transition: beats(COMPASS_TIMES, 0.7),
176
+ },
177
+ };
178
+
179
+ export function MotionCompass(props: MotionIconProps) {
180
+ return (
181
+ <MotionIcon {...props}>
182
+ <circle cx="12" cy="12" r="10" />
183
+ <motion.path
184
+ variants={compassNeedle}
185
+ style={pivot(12, 12)}
186
+ fill="currentColor"
187
+ fillOpacity={0}
188
+ d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"
189
+ />
190
+ </MotionIcon>
191
+ );
192
+ }
193
+
152
194
  // ─── Map pin — jumps, and fills on the way down (fall and land) ───────────────
153
195
 
154
196
  const PIN_TIMES = [0, 0.16, 0.44, 0.72, 1];
@@ -0,0 +1,57 @@
1
+ import * as React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { PartyCounter, type PartyCounterLabels } from "./party-counter";
4
+
5
+ const labels: PartyCounterLabels = {
6
+ decrease: "Fewer people",
7
+ increase: "More people",
8
+ unit: (count) => (count === 1 ? "person" : "people"),
9
+ };
10
+
11
+ const meta: Meta<typeof PartyCounter> = {
12
+ title: "Booking/PartyCounter",
13
+ component: PartyCounter,
14
+ tags: ["autodocs"],
15
+ args: { labels, min: 1, max: 8 },
16
+ decorators: [
17
+ (Story) => (
18
+ <div className="w-[335px]">
19
+ <Story />
20
+ </div>
21
+ ),
22
+ ],
23
+ parameters: {
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "How many people, in \"When are you coming?\" and \"Change party size\". The plus is filled: it is the direction the screen is asking about. At an end the button disables rather than hides, so the value never moves.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof PartyCounter>;
35
+
36
+ function Controlled(args: React.ComponentProps<typeof PartyCounter>) {
37
+ const [value, setValue] = React.useState(args.value);
38
+ return <PartyCounter {...args} value={value} onValueChange={setValue} />;
39
+ }
40
+
41
+ /** B1: "2 people". */
42
+ export const Default: Story = {
43
+ args: { value: 2 },
44
+ render: (args) => <Controlled {...args} />,
45
+ };
46
+
47
+ /** At the minimum the minus disables; the value stays put. */
48
+ export const AtMinimum: Story = {
49
+ args: { value: 1 },
50
+ render: (args) => <Controlled {...args} />,
51
+ };
52
+
53
+ /** B9: "4 people". */
54
+ export const FourPeople: Story = {
55
+ args: { value: 4 },
56
+ render: (args) => <Controlled {...args} />,
57
+ };