@chesapeake/stack 0.1.6 → 0.1.8

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/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to
7
7
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.1.8] - 2026-11-08
10
+
11
+ ### Added
12
+
13
+ - `lib/layoutTier`, `lib/useMediaQuery`, `lib/useLayoutTier`: compact /
14
+ hybrid / comfortable viewport tiers (Tailwind-aligned breakpoints)
15
+ - `StandardLayout` sets `data-layout-tier` and slightly tighter shell padding on
16
+ small viewports
17
+
18
+ ## [0.1.7] - 2026-10-11
19
+
20
+ ### Added
21
+
22
+ - Retail P0 blocks in `components/retail/`: `LocationHero`,
23
+ `HoursStatusStrip`, `StickyShopBar`, and `StoreSwitcher`
24
+ - `types/retail-location.ts` — `RetailLocation` and supporting types
25
+ (`Address`, `WeeklyHours`, `FaqItem`, `CommercePlatform`)
26
+ - `examples/retail-location-page.tsx` composing the P0 blocks
27
+ - `isOpenAt` and `hoursForDay` helpers for local weekly hours
28
+
9
29
  ## [0.1.6] - 2026-09-19
10
30
 
11
31
  ### Added
@@ -90,8 +110,10 @@ and this project adheres to
90
110
  - Full dark mode support via CSS custom properties
91
111
  - Apache 2.0 license
92
112
 
93
- [0.1.4]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.4
113
+ [0.1.7]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.7
94
114
  [0.1.6]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.6
115
+ [0.1.5]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.5
116
+ [0.1.4]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.4
95
117
  [0.1.3]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.3
96
118
  [0.1.2]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.2
97
119
  [0.1.1]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.1
package/README.md CHANGED
@@ -17,6 +17,10 @@ system consists of:
17
17
  - `styles/index.css` that includes Tailwind CSS, design tokens, base styles, and
18
18
  component styles
19
19
  - `tailwind.preset.ts` for use in `tailwind.config.ts`
20
+ - **Layout tiers** (`compact` / `hybrid` / `comfortable`): `useLayoutTier`,
21
+ `LAYOUT_TIER_*` constants, and `data-layout-tier` on `StandardLayout`. See
22
+ denoise-ui `docs/responsive-layout.md` for product rules; apps own feature
23
+ toolbars.
20
24
 
21
25
  ## Installation
22
26
 
@@ -335,6 +339,7 @@ installed and configured.
335
339
  | [`feedback-and-notifications.tsx`](https://github.com/chesapeakedev/stack/blob/main/examples/feedback-and-notifications.tsx) | FeedbackDrawer, toast variants (success / error / promise), confirmation Dialog |
336
340
  | [`data-display.tsx`](https://github.com/chesapeakedev/stack/blob/main/examples/data-display.tsx) | Cards, badges, avatars, tooltips, dropdown menus, and EmptyState |
337
341
  | [`user-management.tsx`](https://github.com/chesapeakedev/stack/blob/main/examples/user-management.tsx) | UserSelector multi-select search, FriendList with presence indicators |
342
+ | [`retail-location-page.tsx`](https://github.com/chesapeakedev/stack/blob/main/examples/retail-location-page.tsx) | LocationHero, HoursStatusStrip, StoreSwitcher, and StickyShopBar |
338
343
 
339
344
  ## Package Contents
340
345
 
@@ -344,7 +349,8 @@ When installed via npm you get:
344
349
  @chesapeake/stack/
345
350
  ├── components/
346
351
  │ ├── ui/ # Primitive UI components (Button, Input, Card, …)
347
- │ └── ux/ # Higher-level UX components (auth, layout, feedback)
352
+ │ ├── ux/ # Higher-level UX components (auth, layout, feedback)
353
+ │ └── retail/ # Location page blocks (hero, hours, store switcher)
348
354
  ├── styles/
349
355
  │ ├── index.css # Main stylesheet entry point
350
356
  │ ├── tokens.css # Design tokens (CSS custom properties)
@@ -0,0 +1,55 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { WeeklyHours } from "../../types/retail-location.ts";
5
+ import { Badge } from "../ui/badge.tsx";
6
+ import { cn } from "../../lib/utils.ts";
7
+ import { hoursForDay, isOpenAt } from "./hours.ts";
8
+
9
+ /**
10
+ * Props for {@link HoursStatusStrip}.
11
+ */
12
+ export interface HoursStatusStripProps {
13
+ /** Weekly hours. A `null` day is closed. */
14
+ hours: WeeklyHours;
15
+ /**
16
+ * Instant used to decide open/closed and which day's range to show.
17
+ * Defaults to the current local time.
18
+ */
19
+ now?: Date;
20
+ /** Pre-order cutoff label, already formatted (for example `21:00`). */
21
+ preOrderCutoff?: string;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * Open/closed badge, today's hours, and an optional pre-order cutoff.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <HoursStatusStrip
31
+ * hours={location.hours}
32
+ * preOrderCutoff={location.status?.preOrderCutoff}
33
+ * />
34
+ * ```
35
+ */
36
+ export function HoursStatusStrip({
37
+ hours,
38
+ now = new Date(),
39
+ preOrderCutoff,
40
+ className,
41
+ }: HoursStatusStripProps) {
42
+ const open = isOpenAt(hours, now);
43
+ const today = hoursForDay(hours, now);
44
+ const range = today ? `${today.open}–${today.close}` : "Closed today";
45
+
46
+ return (
47
+ <div className={cn("flex flex-wrap items-center gap-2 text-sm", className)}>
48
+ <Badge variant={open ? "default" : "secondary"}>
49
+ {open ? "Open" : "Closed"}
50
+ </Badge>
51
+ <span>{range}</span>
52
+ {preOrderCutoff ? <span>Order by {preOrderCutoff}</span> : null}
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,64 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Button } from "../ui/button.tsx";
5
+ import { cn } from "../../lib/utils.ts";
6
+
7
+ /**
8
+ * Props for {@link LocationHero}.
9
+ */
10
+ export interface LocationHeroProps {
11
+ /** Local page headline. */
12
+ headline: string;
13
+ /** Neighborhood or district shown above the headline. */
14
+ neighborhood?: string;
15
+ /** Primary commerce action label, such as "Order pickup". */
16
+ primaryLabel: string;
17
+ /** Called when the primary commerce button is clicked. */
18
+ onPrimaryClick: () => void;
19
+ /** Secondary action label, such as "Directions". */
20
+ secondaryLabel: string;
21
+ /** Called when the directions button is clicked. */
22
+ onSecondaryClick: () => void;
23
+ className?: string;
24
+ }
25
+
26
+ /**
27
+ * Location headline with a primary commerce action and a directions action.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * <LocationHero
32
+ * neighborhood="Canton"
33
+ * headline="Welcome to The Forest Baltimore"
34
+ * primaryLabel="Order pickup"
35
+ * onPrimaryClick={() => {}}
36
+ * secondaryLabel="Directions"
37
+ * onSecondaryClick={() => {}}
38
+ * />
39
+ * ```
40
+ */
41
+ export function LocationHero({
42
+ headline,
43
+ neighborhood,
44
+ primaryLabel,
45
+ onPrimaryClick,
46
+ secondaryLabel,
47
+ onSecondaryClick,
48
+ className,
49
+ }: LocationHeroProps) {
50
+ return (
51
+ <section className={cn("flex flex-col gap-4", className)}>
52
+ {neighborhood ? (
53
+ <p className="text-sm text-muted-foreground">{neighborhood}</p>
54
+ ) : null}
55
+ <h2 className="text-3xl font-bold tracking-tight">{headline}</h2>
56
+ <div className="flex flex-wrap gap-2">
57
+ <Button onClick={onPrimaryClick}>{primaryLabel}</Button>
58
+ <Button variant="outline" onClick={onSecondaryClick}>
59
+ {secondaryLabel}
60
+ </Button>
61
+ </div>
62
+ </section>
63
+ );
64
+ }
@@ -0,0 +1,44 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Button } from "../ui/button.tsx";
5
+ import { cn } from "../../lib/utils.ts";
6
+
7
+ /**
8
+ * Props for {@link StickyShopBar}.
9
+ */
10
+ export interface StickyShopBarProps {
11
+ /** Label on the persistent commerce button. */
12
+ label: string;
13
+ /** Fired when the shop button is pressed. */
14
+ onShopClick: () => void;
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * Mobile-only bar fixed to the bottom of the viewport with a commerce CTA.
20
+ * Hidden from the `md` breakpoint up.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <StickyShopBar label="Order pickup" onShopClick={() => {}} />
25
+ * ```
26
+ */
27
+ export function StickyShopBar({
28
+ label,
29
+ onShopClick,
30
+ className,
31
+ }: StickyShopBarProps) {
32
+ return (
33
+ <div
34
+ className={cn(
35
+ "fixed inset-x-0 bottom-0 z-40 border-t bg-background p-3 md:hidden",
36
+ className
37
+ )}
38
+ >
39
+ <Button className="w-full" onClick={onShopClick}>
40
+ {label}
41
+ </Button>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,122 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { useEffect } from "react";
5
+
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "../ui/select.tsx";
13
+ import { cn } from "../../lib/utils.ts";
14
+
15
+ /** One row in {@link StoreSwitcher}. */
16
+ export interface StoreOption {
17
+ slug: string;
18
+ name: string;
19
+ }
20
+
21
+ /**
22
+ * Props for {@link StoreSwitcher}.
23
+ *
24
+ * Nearest-store detection is not implemented. `RetailLocation` has no
25
+ * coordinates, so this control only persists an explicit choice.
26
+ */
27
+ export interface StoreSwitcherProps {
28
+ /** Stores the shopper can choose. */
29
+ locations: StoreOption[];
30
+ /** Selected slug. Omit it to restore the last stored slug on mount. */
31
+ selectedSlug?: string;
32
+ /** Called with the slug when the shopper picks a store, or when a stored slug is restored. */
33
+ onSelect: (slug: string) => void;
34
+ /**
35
+ * `localStorage` key and cookie name.
36
+ * @default "chesapeake.retail.location"
37
+ */
38
+ storageKey?: string;
39
+ className?: string;
40
+ }
41
+
42
+ const DEFAULT_STORAGE_KEY = "chesapeake.retail.location";
43
+
44
+ function readStoredSlug(storageKey: string): string | null {
45
+ try {
46
+ const stored = localStorage.getItem(storageKey);
47
+ if (stored) {
48
+ return stored;
49
+ }
50
+ } catch {
51
+ // Private mode and disabled storage still allow the in-memory selection.
52
+ }
53
+ const prefix = `${encodeURIComponent(storageKey)}=`;
54
+ const parts = document.cookie.split("; ");
55
+ for (const part of parts) {
56
+ if (part.startsWith(prefix)) {
57
+ return decodeURIComponent(part.slice(prefix.length));
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function writeStoredSlug(storageKey: string, slug: string): void {
64
+ try {
65
+ localStorage.setItem(storageKey, slug);
66
+ } catch {
67
+ // Cookie write below still records the choice when storage is blocked.
68
+ }
69
+ document.cookie = `${encodeURIComponent(storageKey)}=${encodeURIComponent(slug)}; Path=/; SameSite=Lax`;
70
+ }
71
+
72
+ /**
73
+ * Store picker that remembers the selection in `localStorage` and a
74
+ * `SameSite=Lax` cookie. Use it to keep the page's shopping context on
75
+ * the location in the URL.
76
+ *
77
+ * @example
78
+ * ```tsx
79
+ * <StoreSwitcher
80
+ * locations={[{ slug: "baltimore-md", name: "Baltimore" }]}
81
+ * selectedSlug={slug}
82
+ * onSelect={setSlug}
83
+ * />
84
+ * ```
85
+ */
86
+ export function StoreSwitcher({
87
+ locations,
88
+ selectedSlug,
89
+ onSelect,
90
+ storageKey = DEFAULT_STORAGE_KEY,
91
+ className,
92
+ }: StoreSwitcherProps) {
93
+ useEffect(() => {
94
+ if (selectedSlug) {
95
+ return;
96
+ }
97
+ const stored = readStoredSlug(storageKey);
98
+ if (stored && locations.some((location) => location.slug === stored)) {
99
+ onSelect(stored);
100
+ }
101
+ }, [locations, onSelect, selectedSlug, storageKey]);
102
+
103
+ const handleChange = (slug: string) => {
104
+ writeStoredSlug(storageKey, slug);
105
+ onSelect(slug);
106
+ };
107
+
108
+ return (
109
+ <Select value={selectedSlug} onValueChange={handleChange}>
110
+ <SelectTrigger className={cn("w-full max-w-xs", className)}>
111
+ <SelectValue placeholder="Choose a store" />
112
+ </SelectTrigger>
113
+ <SelectContent>
114
+ {locations.map((location) => (
115
+ <SelectItem key={location.slug} value={location.slug}>
116
+ {location.name}
117
+ </SelectItem>
118
+ ))}
119
+ </SelectContent>
120
+ </Select>
121
+ );
122
+ }
@@ -0,0 +1,56 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type {
5
+ DayHours,
6
+ Weekday,
7
+ WeeklyHours,
8
+ } from "../../types/retail-location.ts";
9
+
10
+ const WEEKDAYS: readonly Weekday[] = [
11
+ "sun",
12
+ "mon",
13
+ "tue",
14
+ "wed",
15
+ "thu",
16
+ "fri",
17
+ "sat",
18
+ ];
19
+
20
+ /** Hours for the local weekday of `at`. `null` means that day is closed. */
21
+ export function hoursForDay(hours: WeeklyHours, at: Date): DayHours | null {
22
+ const key = WEEKDAYS[at.getDay()];
23
+ return hours[key];
24
+ }
25
+
26
+ function minutesOfDay(hhmm: string): number | null {
27
+ const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
28
+ if (!match) {
29
+ return null;
30
+ }
31
+ const hour = Number(match[1]);
32
+ const minute = Number(match[2]);
33
+ if (hour > 23 || minute > 59) {
34
+ return null;
35
+ }
36
+ return hour * 60 + minute;
37
+ }
38
+
39
+ /**
40
+ * Whether `at` falls inside that local day's open range.
41
+ * A `null` day, an unparsable `HH:MM` value, or a time outside
42
+ * `[open, close)` is closed.
43
+ */
44
+ export function isOpenAt(hours: WeeklyHours, at: Date): boolean {
45
+ const day = hoursForDay(hours, at);
46
+ if (!day) {
47
+ return false;
48
+ }
49
+ const open = minutesOfDay(day.open);
50
+ const close = minutesOfDay(day.close);
51
+ if (open === null || close === null) {
52
+ return false;
53
+ }
54
+ const now = at.getHours() * 60 + at.getMinutes();
55
+ return now >= open && now < close;
56
+ }
@@ -0,0 +1,16 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ export { hoursForDay, isOpenAt } from "./hours.ts";
5
+
6
+ export { LocationHero } from "./LocationHero.tsx";
7
+ export type { LocationHeroProps } from "./LocationHero.tsx";
8
+
9
+ export { HoursStatusStrip } from "./HoursStatusStrip.tsx";
10
+ export type { HoursStatusStripProps } from "./HoursStatusStrip.tsx";
11
+
12
+ export { StickyShopBar } from "./StickyShopBar.tsx";
13
+ export type { StickyShopBarProps } from "./StickyShopBar.tsx";
14
+
15
+ export { StoreSwitcher } from "./StoreSwitcher.tsx";
16
+ export type { StoreOption, StoreSwitcherProps } from "./StoreSwitcher.tsx";
@@ -5,6 +5,7 @@
5
5
  import { useState } from "react";
6
6
  import { Button } from "../../../components/ui/button";
7
7
  import { useAuth } from "../auth";
8
+ import { useLayoutTier } from "../../../lib/useLayoutTier.ts";
8
9
  import { ProfilePage } from "./ProfilePage";
9
10
  import type { AuthUser } from "../auth/context";
10
11
 
@@ -46,6 +47,8 @@ export function StandardLayout({
46
47
  maxWidth,
47
48
  }: StandardLayoutProps) {
48
49
  const [showProfile, setShowProfile] = useState(false);
50
+ const layoutTier = useLayoutTier();
51
+ const shellPadding = "p-3 md:p-4";
49
52
  const { user: authUser } = useAuth();
50
53
 
51
54
  // Determine if user is logged in based on authUser
@@ -94,11 +97,14 @@ export function StandardLayout({
94
97
  return (
95
98
  <div
96
99
  className={`h-screen flex flex-col overflow-hidden ${className ?? ""}`}
100
+ data-layout-tier={layoutTier}
97
101
  >
98
102
  {/* Toolbar/Header */}
99
103
  <div className="border-b bg-background flex-shrink-0">
100
104
  <div className="flex justify-center">
101
- <div className={`w-full ${getMaxWidthClass(maxWidth)} p-4`}>
105
+ <div
106
+ className={`w-full ${getMaxWidthClass(maxWidth)} ${shellPadding}`}
107
+ >
102
108
  <div className="flex justify-between items-center">
103
109
  {headerContent ? (
104
110
  <div className="flex items-center gap-2 w-full">
@@ -242,11 +248,14 @@ export function StandardLayout({
242
248
  return (
243
249
  <div
244
250
  className={`h-screen flex flex-col overflow-hidden ${className ?? ""}`}
251
+ data-layout-tier={layoutTier}
245
252
  >
246
253
  {/* Toolbar/Header */}
247
254
  <div className="border-b bg-background flex-shrink-0">
248
255
  <div className="flex justify-center">
249
- <div className={`w-full ${getMaxWidthClass(maxWidth)} p-4`}>
256
+ <div
257
+ className={`w-full ${getMaxWidthClass(maxWidth)} ${shellPadding}`}
258
+ >
250
259
  <div className="flex justify-between items-center">
251
260
  {headerContent ? (
252
261
  <div className="flex items-center gap-2 w-full">
@@ -14,6 +14,20 @@ export {
14
14
  PageHeader,
15
15
  PageLayout,
16
16
  } from "./PageLayout.tsx";
17
+
18
+ export {
19
+ LAYOUT_TIER_COMPACT_MAX_PX,
20
+ LAYOUT_TIER_COMFORTABLE_MIN_PX,
21
+ LAYOUT_TIER_DIALOG_MIN_MEDIA,
22
+ LAYOUT_TIER_MEDIA,
23
+ resolveLayoutTier,
24
+ type LayoutTier,
25
+ } from "../../../lib/layoutTier.ts";
26
+ export {
27
+ useLayoutTier,
28
+ useLayoutTierWidth,
29
+ } from "../../../lib/useLayoutTier.ts";
30
+ export { useMediaQuery } from "../../../lib/useMediaQuery.ts";
17
31
  export type {
18
32
  PageContainerProps,
19
33
  PageContentProps,
@@ -1,23 +1,52 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { Card } from "../../ui/card.tsx";
3
3
 
4
+ /** Per-checkout readiness reported by the paired runner. */
5
+ export interface RepositoryReadinessEntry {
6
+ repository: string;
7
+ ready: boolean;
8
+ reason?: string;
9
+ }
10
+
4
11
  export interface RunnerStatusCardProps {
5
12
  name: string;
6
13
  state: string;
7
14
  details?: string;
8
15
  repositories?: string[];
16
+ /** Slug highlighted as the kickstart target for this page. */
17
+ targetRepository?: string;
18
+ /** Optional readiness per registered slug. */
19
+ repositoryReadiness?: RepositoryReadinessEntry[];
9
20
  children?: ReactNode;
10
21
  jobs?: ReactNode;
11
22
  }
12
23
 
24
+ function readinessForSlug(
25
+ slug: string,
26
+ entries: RepositoryReadinessEntry[] | undefined
27
+ ): RepositoryReadinessEntry | undefined {
28
+ if (!entries?.length) return undefined;
29
+ const lower = slug.toLowerCase();
30
+ return entries.find((entry) => entry.repository.toLowerCase() === lower);
31
+ }
32
+
13
33
  export function RunnerStatusCard({
14
34
  name,
15
35
  state,
16
36
  details,
17
37
  repositories = [],
38
+ targetRepository,
39
+ repositoryReadiness,
18
40
  children,
19
41
  jobs,
20
42
  }: RunnerStatusCardProps) {
43
+ const targetLower = targetRepository?.trim().toLowerCase();
44
+ const slugs = repositories.length
45
+ ? repositories
46
+ : targetRepository
47
+ ? [targetRepository]
48
+ : [];
49
+
21
50
  return (
22
51
  <Card
23
52
  className="space-y-3 rounded-md border p-4"
@@ -39,11 +68,35 @@ export function RunnerStatusCard({
39
68
  </div>
40
69
  <div>
41
70
  <p className="text-sm font-medium">Repository access</p>
42
- {repositories.length ? (
71
+ {slugs.length ? (
43
72
  <ul className="text-muted-foreground mt-1 list-inside list-disc text-sm">
44
- {repositories.map((repository) => (
45
- <li key={repository}>{repository}</li>
46
- ))}
73
+ {slugs.map((repository) => {
74
+ const readiness = readinessForSlug(
75
+ repository,
76
+ repositoryReadiness
77
+ );
78
+ const isTarget =
79
+ targetLower != null && repository.toLowerCase() === targetLower;
80
+ return (
81
+ <li
82
+ key={repository}
83
+ className={
84
+ isTarget ? "text-foreground font-medium" : undefined
85
+ }
86
+ title={readiness?.reason}
87
+ >
88
+ {repository}
89
+ {isTarget ? " · kickstart target" : ""}
90
+ {readiness != null
91
+ ? readiness.ready
92
+ ? " · ready"
93
+ : ` · not ready${
94
+ readiness.reason ? `: ${readiness.reason}` : ""
95
+ }`
96
+ : ""}
97
+ </li>
98
+ );
99
+ })}
47
100
  </ul>
48
101
  ) : (
49
102
  <p className="text-muted-foreground mt-1 text-sm">
package/index.ts CHANGED
@@ -25,6 +25,11 @@
25
25
  // Re-export everything from subdirectories for convenient imports
26
26
  export * from "./components/ui/index.ts";
27
27
  export * from "./components/ux/index.ts";
28
+ export * from "./components/retail/index.ts";
29
+ export * from "./types/retail-location.ts";
28
30
  export * from "./lib/utils.ts";
29
31
  export * from "./lib/githubIssue.ts";
32
+ export * from "./lib/layoutTier.ts";
33
+ export * from "./lib/useMediaQuery.ts";
34
+ export * from "./lib/useLayoutTier.ts";
30
35
  export { default as tailwindPreset } from "./tailwind.preset.ts";
@@ -112,7 +112,9 @@ export function isGitHubPullRequestPayload(value: unknown): boolean {
112
112
  }
113
113
 
114
114
  export function isValidRepoName(value: string): boolean {
115
- return /^[A-Za-z0-9_.-]+$/.test(value) && value.length > 0 && value.length <= 100;
115
+ return (
116
+ /^[A-Za-z0-9_.-]+$/.test(value) && value.length > 0 && value.length <= 100
117
+ );
116
118
  }
117
119
 
118
120
  export function isValidIssueNumber(value: unknown): value is number {
@@ -0,0 +1,16 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import assert from "node:assert/strict";
5
+ import test from "node:test";
6
+ import { resolveLayoutTier } from "./layoutTier.ts";
7
+
8
+ await test("resolveLayoutTier boundaries", () => {
9
+ assert.equal(resolveLayoutTier(390), "compact");
10
+ assert.equal(resolveLayoutTier(767), "compact");
11
+ assert.equal(resolveLayoutTier(768), "hybrid");
12
+ assert.equal(resolveLayoutTier(683), "compact");
13
+ assert.equal(resolveLayoutTier(1023), "hybrid");
14
+ assert.equal(resolveLayoutTier(1024), "comfortable");
15
+ assert.equal(resolveLayoutTier(1920), "comfortable");
16
+ });
@@ -0,0 +1,26 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** Three-tier layout names aligned with Tailwind default breakpoints. */
5
+ export type LayoutTier = "compact" | "hybrid" | "comfortable";
6
+
7
+ /** Max width (px) for compact tier (below md). */
8
+ export const LAYOUT_TIER_COMPACT_MAX_PX = 767;
9
+
10
+ /** Min width (px) for comfortable tier (lg and up). */
11
+ export const LAYOUT_TIER_COMFORTABLE_MIN_PX = 1024;
12
+
13
+ export const LAYOUT_TIER_MEDIA = {
14
+ compact: `(max-width: ${String(LAYOUT_TIER_COMPACT_MAX_PX)}px)`,
15
+ hybrid: `(min-width: 768px) and (max-width: ${String(LAYOUT_TIER_COMFORTABLE_MIN_PX - 1)}px)`,
16
+ comfortable: `(min-width: ${String(LAYOUT_TIER_COMFORTABLE_MIN_PX)}px)`,
17
+ } as const;
18
+
19
+ /** Dialog vs drawer: at md and above use dialog shell. */
20
+ export const LAYOUT_TIER_DIALOG_MIN_MEDIA = "(min-width: 768px)";
21
+
22
+ export function resolveLayoutTier(viewportWidthPx: number): LayoutTier {
23
+ if (viewportWidthPx <= LAYOUT_TIER_COMPACT_MAX_PX) return "compact";
24
+ if (viewportWidthPx < LAYOUT_TIER_COMFORTABLE_MIN_PX) return "hybrid";
25
+ return "comfortable";
26
+ }
@@ -0,0 +1,32 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** @jsxImportSource react */
5
+ import {
6
+ LAYOUT_TIER_MEDIA,
7
+ type LayoutTier,
8
+ resolveLayoutTier,
9
+ } from "./layoutTier.ts";
10
+ import { useMediaQuery } from "./useMediaQuery.ts";
11
+ import { useMemo } from "react";
12
+
13
+ /**
14
+ * Current viewport layout tier (compact / hybrid / comfortable).
15
+ */
16
+ export function useLayoutTier(): LayoutTier {
17
+ const compact = useMediaQuery(LAYOUT_TIER_MEDIA.compact);
18
+ const comfortable = useMediaQuery(LAYOUT_TIER_MEDIA.comfortable);
19
+
20
+ return useMemo(() => {
21
+ if (compact) return "compact";
22
+ if (comfortable) return "comfortable";
23
+ return "hybrid";
24
+ }, [compact, comfortable]);
25
+ }
26
+
27
+ export function useLayoutTierWidth(): number {
28
+ if (typeof window === "undefined") return 1280;
29
+ return window.innerWidth;
30
+ }
31
+
32
+ export { resolveLayoutTier };
@@ -0,0 +1,29 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** @jsxImportSource react */
5
+ import { useEffect, useState } from "react";
6
+
7
+ /**
8
+ * Subscribe to a CSS media query; returns whether it currently matches.
9
+ */
10
+ export function useMediaQuery(query: string): boolean {
11
+ const [matches, setMatches] = useState(() => {
12
+ if (typeof window === "undefined") return false;
13
+ return window.matchMedia(query).matches;
14
+ });
15
+
16
+ useEffect(() => {
17
+ const media = window.matchMedia(query);
18
+ const onChange = () => {
19
+ setMatches(media.matches);
20
+ };
21
+ onChange();
22
+ media.addEventListener("change", onChange);
23
+ return () => {
24
+ media.removeEventListener("change", onChange);
25
+ };
26
+ }, [query]);
27
+
28
+ return matches;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chesapeake/stack",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "A simple, reusable React component library and design system built on shadcn/ui with Tailwind CSS",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.2.0",
@@ -78,6 +78,11 @@
78
78
  "./components/ui/textarea": "./components/ui/textarea.tsx",
79
79
  "./components/ui/tooltip": "./components/ui/tooltip.tsx",
80
80
  "./components/ux": "./components/ux/index.ts",
81
+ "./components/retail": "./components/retail/index.ts",
82
+ "./components/retail/LocationHero": "./components/retail/LocationHero.tsx",
83
+ "./components/retail/HoursStatusStrip": "./components/retail/HoursStatusStrip.tsx",
84
+ "./components/retail/StickyShopBar": "./components/retail/StickyShopBar.tsx",
85
+ "./components/retail/StoreSwitcher": "./components/retail/StoreSwitcher.tsx",
81
86
  "./components/ux/ActivityAnimation": "./components/ux/ActivityAnimation.tsx",
82
87
  "./components/ux/FireAnimation": "./components/ux/FireAnimation.tsx",
83
88
  "./components/ux/GifSearch": "./components/ux/GifSearch.tsx",
@@ -129,7 +134,11 @@
129
134
  "./components/ux/auth/github/types": "./components/ux/auth/github/types.ts",
130
135
  "./lib/utils": "./lib/utils.ts",
131
136
  "./lib/githubIssue": "./lib/githubIssue.ts",
137
+ "./lib/layoutTier": "./lib/layoutTier.ts",
138
+ "./lib/useMediaQuery": "./lib/useMediaQuery.ts",
139
+ "./lib/useLayoutTier": "./lib/useLayoutTier.ts",
132
140
  "./types/user": "./types/user.ts",
141
+ "./types/retail-location": "./types/retail-location.ts",
133
142
  "./styles": "./styles/index.css",
134
143
  "./tailwind.preset": "./tailwind.preset.ts",
135
144
  "./eslint.config": "./eslint.config.js",
@@ -0,0 +1,73 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Canonical retail location content model.
6
+ *
7
+ * Shared with the Chesapeake audit rubric (chesapeakedev/chesapeake#485).
8
+ * Phase 1 deep-links into `commerce.platform`; it does not replace checkout.
9
+ */
10
+
11
+ /** Street address for one store. */
12
+ export interface Address {
13
+ street: string;
14
+ city: string;
15
+ region: string;
16
+ postalCode: string;
17
+ country: string;
18
+ }
19
+
20
+ /** Lowercase weekday key used by {@link WeeklyHours}. */
21
+ export type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
22
+
23
+ /**
24
+ * Open and close as 24-hour local times (`HH:MM`).
25
+ * A closed day is `null` on {@link WeeklyHours}, not a zero-length range.
26
+ */
27
+ export interface DayHours {
28
+ open: string;
29
+ close: string;
30
+ }
31
+
32
+ /** One entry per weekday. `null` means the store is closed that day. */
33
+ export type WeeklyHours = Record<Weekday, DayHours | null>;
34
+
35
+ /** One question and answer on a location page. */
36
+ export interface FaqItem {
37
+ question: string;
38
+ answer: string;
39
+ }
40
+
41
+ /** Checkout platforms Phase 1 deep-links into. Phase 1 does not replace them. */
42
+ export type CommercePlatform =
43
+ | "dutchie"
44
+ | "toast"
45
+ | "shopify"
46
+ | "square"
47
+ | "custom";
48
+
49
+ /**
50
+ * Public facts for one retail location.
51
+ * `storeId` is the id the commerce platform publishes. When the page does
52
+ * not publish one, use a stable public slug.
53
+ */
54
+ export interface RetailLocation {
55
+ slug: string;
56
+ name: string;
57
+ headline: string;
58
+ neighborhood?: string;
59
+ address: Address;
60
+ phone: string;
61
+ email?: string;
62
+ hours: WeeklyHours;
63
+ status?: { preOrderCutoff?: string; badge?: string };
64
+ commerce: {
65
+ platform: CommercePlatform;
66
+ storeId: string;
67
+ shopUrl: string;
68
+ categoryLinks?: Record<string, string>;
69
+ };
70
+ faqs: FaqItem[];
71
+ localSeo?: { landmarks: string[]; serviceAreas: string[]; guide: string };
72
+ social?: { googlePlaceId?: string };
73
+ }