@vamos-ui/react 0.2.0 → 0.4.0

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 (166) hide show
  1. package/dist/blocks/block-image.js +1 -1
  2. package/dist/blocks/block-link.js +1 -1
  3. package/dist/blocks/index.d.ts +2 -2
  4. package/dist/blocks/index.js +2 -2
  5. package/dist/blocks/site/_kit-1.d.ts +1 -0
  6. package/dist/blocks/site/_kit-1.js +3 -1
  7. package/dist/blocks/site/announcement-bar.js +1 -1
  8. package/dist/blocks/site/article-layout.d.ts +1 -0
  9. package/dist/blocks/site/article-layout.js +2 -1
  10. package/dist/blocks/site/blog-list.d.ts +1 -0
  11. package/dist/blocks/site/blog-list.js +3 -2
  12. package/dist/blocks/site/contact-dock.js +3 -1
  13. package/dist/blocks/site/contact-form.js +1 -1
  14. package/dist/blocks/site/cta.d.ts +2 -2
  15. package/dist/blocks/site/cta.js +8 -5
  16. package/dist/blocks/site/faq.js +2 -1
  17. package/dist/blocks/site/feature-grid.d.ts +1 -0
  18. package/dist/blocks/site/feature-grid.js +7 -5
  19. package/dist/blocks/site/footer.d.ts +1 -1
  20. package/dist/blocks/site/footer.js +8 -7
  21. package/dist/blocks/site/gallery-section.d.ts +6 -1
  22. package/dist/blocks/site/gallery-section.js +6 -4
  23. package/dist/blocks/site/hero.d.ts +3 -2
  24. package/dist/blocks/site/hero.js +14 -6
  25. package/dist/blocks/site/logo-strip.d.ts +6 -3
  26. package/dist/blocks/site/logo-strip.js +8 -7
  27. package/dist/blocks/site/map-hours.d.ts +2 -2
  28. package/dist/blocks/site/map-hours.js +4 -2
  29. package/dist/blocks/site/media-text.d.ts +4 -1
  30. package/dist/blocks/site/media-text.js +6 -4
  31. package/dist/blocks/site/menu-list.d.ts +7 -3
  32. package/dist/blocks/site/menu-list.js +34 -7
  33. package/dist/blocks/site/navbar.d.ts +1 -0
  34. package/dist/blocks/site/navbar.js +6 -5
  35. package/dist/blocks/site/not-found.js +2 -1
  36. package/dist/blocks/site/page-intro.d.ts +1 -0
  37. package/dist/blocks/site/page-intro.js +2 -1
  38. package/dist/blocks/site/pricing-table.js +2 -1
  39. package/dist/blocks/site/process.d.ts +1 -0
  40. package/dist/blocks/site/process.js +4 -2
  41. package/dist/blocks/site/registry-1.d.ts +1 -1
  42. package/dist/blocks/site/registry-2.d.ts +5 -0
  43. package/dist/blocks/site/registry-2.js +2 -2
  44. package/dist/blocks/site/rich-text-section.js +2 -1
  45. package/dist/blocks/site/shared/heading-lines.d.ts +9 -0
  46. package/dist/blocks/site/shared/heading-lines.js +32 -0
  47. package/dist/blocks/site/shared/image-sizes.d.ts +25 -0
  48. package/dist/blocks/site/shared/image-sizes.js +37 -0
  49. package/dist/blocks/site/shared/island-keys.d.ts +1 -1
  50. package/dist/blocks/site/shared/island-keys.js +1 -1
  51. package/dist/blocks/site/shared/labels-2.d.ts +9 -1
  52. package/dist/blocks/site/shared/labels-2.js +24 -6
  53. package/dist/blocks/site/shared/open-now.d.ts +1 -1
  54. package/dist/blocks/site/shared/open-now.js +1 -1
  55. package/dist/blocks/site/shared/opening-hours.d.ts +18 -2
  56. package/dist/blocks/site/shared/opening-hours.js +36 -7
  57. package/dist/blocks/site/shared/tap.d.ts +9 -0
  58. package/dist/blocks/site/shared/tap.js +16 -0
  59. package/dist/blocks/site/site-shell.d.ts +11 -0
  60. package/dist/blocks/site/site-shell.js +43 -16
  61. package/dist/blocks/site/stats-band.d.ts +3 -1
  62. package/dist/blocks/site/stats-band.js +11 -7
  63. package/dist/blocks/site/team.d.ts +1 -1
  64. package/dist/blocks/site/team.js +4 -3
  65. package/dist/blocks/site/testimonials.d.ts +2 -2
  66. package/dist/blocks/site/testimonials.js +5 -4
  67. package/dist/business/index.d.ts +1 -0
  68. package/dist/business/index.js +2 -0
  69. package/dist/business/signer-status.d.ts +53 -0
  70. package/dist/business/signer-status.js +76 -0
  71. package/dist/charts/bar-chart.js +6 -2
  72. package/dist/charts/chart-shell.d.ts +9 -2
  73. package/dist/charts/chart-shell.js +10 -4
  74. package/dist/charts/chart.js +1 -1
  75. package/dist/charts/combo-chart.js +5 -7
  76. package/dist/charts/utils.d.ts +10 -0
  77. package/dist/charts/utils.js +46 -0
  78. package/dist/components/_qr-core.d.ts +26 -0
  79. package/dist/components/_qr-core.js +376 -0
  80. package/dist/components/activity-timeline.js +5 -5
  81. package/dist/components/aspect-ratio.d.ts +26 -0
  82. package/dist/components/aspect-ratio.js +42 -0
  83. package/dist/components/backup-codes.d.ts +42 -0
  84. package/dist/components/backup-codes.js +67 -0
  85. package/dist/components/breadcrumb.d.ts +2 -2
  86. package/dist/components/breadcrumb.js +1 -1
  87. package/dist/components/carousel.d.ts +12 -3
  88. package/dist/components/carousel.js +8 -5
  89. package/dist/components/collapsible.d.ts +36 -0
  90. package/dist/components/collapsible.js +37 -0
  91. package/dist/components/command.d.ts +8 -2
  92. package/dist/components/command.js +7 -2
  93. package/dist/components/image.d.ts +18 -2
  94. package/dist/components/image.js +12 -5
  95. package/dist/components/input.d.ts +1 -1
  96. package/dist/components/input.js +1 -1
  97. package/dist/components/menubar.d.ts +96 -0
  98. package/dist/components/menubar.js +123 -0
  99. package/dist/components/pin-map.d.ts +58 -0
  100. package/dist/components/pin-map.js +167 -0
  101. package/dist/components/placeholder-art.d.ts +51 -0
  102. package/dist/components/placeholder-art.js +82 -0
  103. package/dist/components/qr-code.d.ts +36 -0
  104. package/dist/components/qr-code.js +27 -0
  105. package/dist/components/record-detail-layout.d.ts +2 -2
  106. package/dist/components/record-detail-layout.js +1 -1
  107. package/dist/components/sidebar.js +8 -8
  108. package/dist/components/sla-timer.d.ts +75 -0
  109. package/dist/components/sla-timer.js +142 -0
  110. package/dist/components/slug-input.js +1 -1
  111. package/dist/components/tour.d.ts +60 -0
  112. package/dist/components/tour.js +139 -0
  113. package/dist/index.d.ts +9 -0
  114. package/dist/index.js +9 -0
  115. package/dist/lib/public.d.ts +1 -0
  116. package/dist/lib/public.js +1 -0
  117. package/dist/lib/value-format.js +1 -1
  118. package/dist/locale-data/core/holidays.d.ts +1 -1
  119. package/dist/locale-data/core/holidays.js +1 -1
  120. package/dist/locales/ar.d.ts +2087 -0
  121. package/dist/locales/ar.js +2088 -0
  122. package/dist/locales/de.d.ts +2087 -0
  123. package/dist/locales/de.js +2088 -0
  124. package/dist/locales/en.d.ts +1 -0
  125. package/dist/locales/en.js +1 -0
  126. package/dist/locales/es.d.ts +2087 -0
  127. package/dist/locales/es.js +2087 -0
  128. package/dist/locales/fr.d.ts +2087 -0
  129. package/dist/locales/fr.js +2088 -0
  130. package/dist/locales/tr.d.ts +1 -0
  131. package/dist/locales/tr.js +1 -0
  132. package/dist/media/gallery.js +3 -1
  133. package/dist/media/media-library.js +1 -1
  134. package/dist/motion/core.d.ts +32 -0
  135. package/dist/motion/core.js +77 -0
  136. package/dist/motion/count-up.d.ts +29 -0
  137. package/dist/motion/count-up.js +92 -0
  138. package/dist/motion/index.d.ts +11 -0
  139. package/dist/motion/index.js +13 -0
  140. package/dist/motion/motion-scope.d.ts +17 -0
  141. package/dist/motion/motion-scope.js +16 -0
  142. package/dist/motion/page-transition.d.ts +33 -0
  143. package/dist/motion/page-transition.js +61 -0
  144. package/dist/motion/parallax.d.ts +37 -0
  145. package/dist/motion/parallax.js +90 -0
  146. package/dist/motion/pointer.d.ts +38 -0
  147. package/dist/motion/pointer.js +117 -0
  148. package/dist/motion/reveal.d.ts +72 -0
  149. package/dist/motion/reveal.js +133 -0
  150. package/dist/motion/scroll-shrink.d.ts +18 -0
  151. package/dist/motion/scroll-shrink.js +47 -0
  152. package/dist/motion/skeleton-swap.d.ts +17 -0
  153. package/dist/motion/skeleton-swap.js +43 -0
  154. package/dist/motion/text-reveal.d.ts +32 -0
  155. package/dist/motion/text-reveal.js +31 -0
  156. package/dist/motion/ticker.d.ts +35 -0
  157. package/dist/motion/ticker.js +16 -0
  158. package/dist/render/block-renderer.js +1 -1
  159. package/dist/sortable/sortable-tree.d.ts +4 -0
  160. package/dist/sortable/sortable-tree.js +16 -4
  161. package/dist/styles/blocks.css +21 -12
  162. package/dist/styles/editors.css +2 -1
  163. package/dist/styles/media.css +31 -5
  164. package/dist/styles/motion.css +389 -0
  165. package/dist/styles.css +41 -10
  166. package/package.json +5 -1
@@ -4,6 +4,8 @@ import { Container, Section } from "../section.js";
4
4
  import { SectionHeader } from "../section-header.js";
5
5
  import { BlockImage } from "../block-image.js";
6
6
  import { CtaList, indexLabel, useBlockIds } from "./_kit-1.js";
7
+ import { Reveal } from "../../motion/reveal.js";
8
+ import { columnSizes } from "./shared/image-sizes.js";
7
9
  /**
8
10
  * "How we work" — discovery → design → launch; a first spa visit; SaaS setup. An ordered list
9
11
  * (the order is announced; the numbers are visual). Server component.
@@ -24,7 +26,7 @@ export function Steps({ variant = "vertical", eyebrow, title, lead, steps, actio
24
26
  return (_jsx(Tag, { className: cn("text-body text-fg-muted text-pretty", Tag === "p" ? "max-w-[52ch]" : "flex min-w-0 flex-col gap-3"), ...f(i, "text"), children: s.text }));
25
27
  };
26
28
  const duration = (s, i) => s.duration ? (_jsx("span", { className: "inline-flex w-fit items-center rounded-full border border-line px-2.5 py-0.5 font-mono text-[0.6875rem] tabular tracking-[0.04em] text-fg-muted", ...f(i, "duration"), children: s.duration })) : null;
27
- const media = (s) => (s.media ? _jsx(BlockImage, { ...s.media, aspect: "3:2", sizes: "(min-width: 56rem) 30vw, 100vw", className: "mt-2 max-w-md rounded-ui", components: components, labels: labels }) : null);
29
+ const media = (s) => (s.media ? _jsx(BlockImage, { ...s.media, aspect: "3:2", sizes: columnSizes(4 / 12, { narrow: "min(100vw, 28rem)" }), className: "mt-2 max-w-md rounded-ui", components: components, labels: labels }) : null);
28
30
  const header = title || eyebrow || lead ? _jsx(SectionHeader, { eyebrow: eyebrow, title: title, lead: lead, align: variant === "horizontal" && lead ? "split" : "start", headingLevel: headingLevel, headingId: headingId, editMode: editMode, labels: labels, locale: locale }) : null;
29
31
  const actionsEl = actions?.length ? _jsx(CtaList, { actions: actions, components: components, labels: labels, locale: locale, editMode: editMode }) : null;
30
32
  let content;
@@ -37,5 +39,5 @@ export function Steps({ variant = "vertical", eyebrow, title, lead, steps, actio
37
39
  else {
38
40
  content = (_jsxs(Container, { className: "grid gap-x-(--gutter) gap-y-12 @4xl:grid-cols-12", children: [_jsxs("div", { className: "flex flex-col gap-8 @4xl:col-span-5", children: [header, actionsEl] }), _jsx("ol", { className: "vb-steps-v flex flex-col @4xl:col-span-6 @4xl:col-start-7", children: steps.map((s, i) => (_jsxs("li", { className: "vb-step relative flex flex-col gap-3 ps-12 pb-10 last:pb-0", children: [_jsx("span", { "aria-hidden": true, className: cn("vb-step-badge absolute start-0 top-0 inline-flex size-8 items-center justify-center rounded-full border border-line bg-scene font-mono text-[0.6875rem] tabular text-fg-muted", i === 0 && "vb-step-badge-first"), children: indexLabel(i) }), _jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2 pt-1", children: [stepTitle(s, i), duration(s, i)] }), stepText(s, i), media(s)] }, i))) })] }));
39
41
  }
40
- return (_jsx(Section, { theme: theme, anchor: anchor, headingId: title ? headingId : undefined, blockId: base, blockType: "steps", editMode: editMode, className: cn("vb-steps", className), "data-variant": variant, children: _jsx("div", { className: "vb-reveal", children: content }) }));
42
+ return (_jsx(Section, { theme: theme, anchor: anchor, headingId: title ? headingId : undefined, blockId: base, blockType: "steps", editMode: editMode, className: cn("vb-steps", className), "data-variant": variant, children: _jsx(Reveal, { disabled: editMode, children: content }) }));
41
43
  }
@@ -535,7 +535,7 @@ export declare const mediaTextBlock: import("../schema/index.js").BlockDefinitio
535
535
  "4:5": "4:5";
536
536
  "3:2": "3:2";
537
537
  }>>;
538
- }, z.core.$strip>, "side" | "overlap" | "bleed">;
538
+ }, z.core.$strip>, "side" | "bleed" | "overlap">;
539
539
  export declare const ctaBlock: import("../schema/index.js").BlockDefinition<z.ZodObject<{
540
540
  eyebrow: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
541
541
  title: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
@@ -589,9 +589,14 @@ export declare const menuBlock: import("../schema/index.js").BlockDefinition<z.Z
589
589
  vegetarian: "vegetarian";
590
590
  vegan: "vegan";
591
591
  "gluten-free": "gluten-free";
592
+ "dairy-free": "dairy-free";
593
+ "nut-free": "nut-free";
594
+ halal: "halal";
592
595
  spicy: "spicy";
593
596
  new: "new";
594
597
  "chef-pick": "chef-pick";
598
+ "to-share": "to-share";
599
+ seasonal: "seasonal";
595
600
  }>>>;
596
601
  allergens: z.ZodOptional<z.ZodArray<z.ZodEnum<{
597
602
  sesame: "sesame";
@@ -10,7 +10,7 @@ import { BlogList } from "./blog-list.js";
10
10
  import { ArticleLayout } from "./article-layout.js";
11
11
  import { ContactForm } from "./contact-form.js";
12
12
  import { MapHours, localBusinessJsonLd } from "./map-hours.js";
13
- import { MenuList, menuJsonLd, ALLERGENS } from "./menu-list.js";
13
+ import { MenuList, menuJsonLd, ALLERGENS, MENU_TAGS } from "./menu-list.js";
14
14
  import { GallerySection } from "./gallery-section.js";
15
15
  import { ConsentBanner } from "./consent-banner.js";
16
16
  import { ContactDock } from "./contact-dock.js";
@@ -367,7 +367,7 @@ const MenuItemSchema = f.group({
367
367
  description: f.text({ max: 160 }).optional(),
368
368
  prices: f.list(f.group({ label: f.text({ max: 20 }).optional(), amount: f.money().optional() }), { min: 1, max: 4 }),
369
369
  duration: f.text({ max: 12 }).optional(),
370
- tags: z.array(z.enum(["vegetarian", "vegan", "gluten-free", "spicy", "new", "chef-pick"])).optional(),
370
+ tags: z.array(z.enum(MENU_TAGS)).optional(),
371
371
  allergens: z.array(z.enum(ALLERGENS)).optional(),
372
372
  image: f.image({ aspect: "1:1" }).optional(),
373
373
  unavailable: f.toggle().optional(),
@@ -4,6 +4,7 @@ import { Container, Section } from "../section.js";
4
4
  import { BlockHeading } from "../block-heading.js";
5
5
  import { RichText } from "../rich-text.js";
6
6
  import { field, useBlockIds } from "./_kit-1.js";
7
+ import { headingLines } from "./shared/heading-lines.js";
7
8
  /**
8
9
  * Long text — an about page, a privacy notice, a service description. Pure server rendering of
9
10
  * the RichDoc format; no editor code reaches the site.
@@ -15,7 +16,7 @@ import { field, useBlockIds } from "./_kit-1.js";
15
16
  export function RichTextSection({ variant = "column", eyebrow, title, body, dropCap, note, size = "md", id, theme, anchor, className, headingLevel = 2, labels, locale, components, editMode }) {
16
17
  const { base, headingId } = useBlockIds(id, "text");
17
18
  const offset = (title ? 1 : 0);
18
- const heading = title ? _jsx(BlockHeading, { id: headingId, level: headingLevel, size: "display-m", text: title, editMode: editMode, field: "props.title", labels: labels, locale: locale }) : null;
19
+ const heading = title ? _jsx(BlockHeading, { id: headingId, level: headingLevel, size: "display-m", text: headingLines(title), editMode: editMode, field: "props.title", labels: labels, locale: locale }) : null;
19
20
  const eyebrowEl = eyebrow ? (_jsx("p", { className: "font-mono text-eyebrow uppercase tracking-[0.08em] text-fg-muted", ...field(editMode, "eyebrow"), children: eyebrow })) : null;
20
21
  const text = (cls) => (_jsx(RichText, { doc: body, size: size, headingOffset: offset, components: components, labels: labels, locale: locale, editMode: editMode, field: "props.body", className: cls }));
21
22
  let content;
@@ -0,0 +1,9 @@
1
+ import * as React from "react";
2
+ /**
3
+ * A heading string with hand-set line breaks: `"Ateşte közde,\nmuhabbet sofrada."`. Each line is
4
+ * a block `<span>` and the lines are joined by a real space, so `textContent` (search engines,
5
+ * screen readers, reader mode, copy-paste) reads "közde, muhabbet", never "közde,muhabbet".
6
+ * `*emphasis*` works across and within lines. Strings without `\n` and ReactNodes pass through
7
+ * unchanged (BlockHeading renders their emphasis). Server-safe.
8
+ */
9
+ export declare function headingLines(text: React.ReactNode): React.ReactNode;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { parseEmphasis } from "../../schema/emphasis.js";
4
+ /**
5
+ * A heading string with hand-set line breaks: `"Ateşte közde,\nmuhabbet sofrada."`. Each line is
6
+ * a block `<span>` and the lines are joined by a real space, so `textContent` (search engines,
7
+ * screen readers, reader mode, copy-paste) reads "közde, muhabbet", never "közde,muhabbet".
8
+ * `*emphasis*` works across and within lines. Strings without `\n` and ReactNodes pass through
9
+ * unchanged (BlockHeading renders their emphasis). Server-safe.
10
+ */
11
+ export function headingLines(text) {
12
+ if (typeof text !== "string" || !text.includes("\n"))
13
+ return text;
14
+ const lines = [[]];
15
+ for (const part of parseEmphasis(text)) {
16
+ part.text.split("\n").forEach((chunk, i) => {
17
+ if (i > 0)
18
+ lines.push([]);
19
+ if (chunk)
20
+ lines[lines.length - 1].push({ text: chunk, em: part.em });
21
+ });
22
+ }
23
+ const filled = lines
24
+ .map((parts) => parts.map((p, i, all) => {
25
+ let t = i === 0 ? p.text.trimStart() : p.text;
26
+ if (i === all.length - 1)
27
+ t = t.trimEnd();
28
+ return { ...p, text: t };
29
+ }).filter((p) => p.text))
30
+ .filter((parts) => parts.length);
31
+ return filled.map((parts, i) => (_jsxs(React.Fragment, { children: [i > 0 ? " " : null, _jsx("span", { className: "vb-heading-line block", children: parts.map((p, j) => (p.em ? _jsx("em", { children: p.text }, j) : _jsx(React.Fragment, { children: p.text }, j))) })] }, i)));
32
+ }
@@ -0,0 +1,25 @@
1
+ /** `--container` (76rem) — the content column every Container caps at. */
2
+ export declare const CONTAINER_REM = 76;
3
+ /**
4
+ * A column that spans `fraction` of the container from `from` up, and `narrow` below.
5
+ * `columnSizes(5 / 12)` → `"(min-width: 56rem) min(42vw, 32rem), 100vw"`.
6
+ */
7
+ export declare function columnSizes(fraction: number, opts?: {
8
+ from?: "42rem" | "56rem";
9
+ narrow?: string;
10
+ bleed?: boolean;
11
+ }): string;
12
+ /** Common presets. */
13
+ export declare const SITE_SIZES: {
14
+ /** Edge-to-edge photo (full-bleed hero, editorial strip). */
15
+ readonly bleed: "100vw";
16
+ /** A photo as wide as the content container. */
17
+ readonly container: "min(100vw, 76rem)";
18
+ /** A photo only shown on narrow containers (< 56rem), full width there. */
19
+ readonly narrowOnly: "min(100vw, 56rem)";
20
+ };
21
+ /**
22
+ * Filmstrip cell: fixed height `clamp(15rem, 38cqi, 30rem)`, width = height × ratio.
23
+ * `filmstripSizes(4 / 5)` → `"max(12rem, min(31vw, 24rem))"`.
24
+ */
25
+ export declare function filmstripSizes(ratio: number): string;
@@ -0,0 +1,37 @@
1
+ // Responsive `sizes` for site-block photos. Every photo in a site block passes one of these to
2
+ // BlockImage → the kit Image / `components.Image` (e.g. next/image), so a CDN loader picks a
3
+ // 400–800 px file on a phone instead of the 3840 px top step. Fractions follow the block's 12-col
4
+ // grid and are capped at the content container (`--container`, 76rem): a 2560 px monitor does
5
+ // not fetch a 1500 px file for a 33% column that is never wider than 25rem.
6
+ // Server-safe, no React.
7
+ /** `--container` (76rem) — the content column every Container caps at. */
8
+ export const CONTAINER_REM = 76;
9
+ /**
10
+ * A column that spans `fraction` of the container from `from` up, and `narrow` below.
11
+ * `columnSizes(5 / 12)` → `"(min-width: 56rem) min(42vw, 32rem), 100vw"`.
12
+ */
13
+ export function columnSizes(fraction, opts = {}) {
14
+ const { from = "56rem", narrow = "100vw", bleed = false } = opts;
15
+ const vw = Math.ceil(fraction * 100);
16
+ // A column bleeding to the section edge grows past the container on very wide screens.
17
+ const wide = bleed ? `${vw}vw` : `min(${vw}vw, ${Math.ceil(fraction * CONTAINER_REM)}rem)`;
18
+ return `(min-width: ${from}) ${wide}, ${narrow}`;
19
+ }
20
+ /** Common presets. */
21
+ export const SITE_SIZES = {
22
+ /** Edge-to-edge photo (full-bleed hero, editorial strip). */
23
+ bleed: "100vw",
24
+ /** A photo as wide as the content container. */
25
+ container: `min(100vw, ${CONTAINER_REM}rem)`,
26
+ /** A photo only shown on narrow containers (< 56rem), full width there. */
27
+ narrowOnly: "min(100vw, 56rem)",
28
+ };
29
+ /**
30
+ * Filmstrip cell: fixed height `clamp(15rem, 38cqi, 30rem)`, width = height × ratio.
31
+ * `filmstripSizes(4 / 5)` → `"max(12rem, min(31vw, 24rem))"`.
32
+ */
33
+ export function filmstripSizes(ratio) {
34
+ const r = ratio > 0 ? ratio : 0.8;
35
+ const n = (x) => Math.round(x * 10) / 10;
36
+ return `max(${n(15 * r)}rem, min(${Math.ceil(38 * r)}vw, ${n(30 * r)}rem))`;
37
+ }
@@ -1,6 +1,6 @@
1
1
  export declare const carouselLabelKeys: readonly ["previous", "next", "slideCounter", "slideLabel", "carousel", "slide"];
2
2
  export type CarouselLabels = Record<(typeof carouselLabelKeys)[number], string>;
3
- export declare const openNowLabelKeys: readonly ["openNow", "closesAt", "closingSoon", "closed", "closedToday", "opensAt", "opensTodayAt", "open24h", "today"];
3
+ export declare const openNowLabelKeys: readonly ["openNow", "openUntil", "closingSoon", "closingSoonUntil", "openAllDay", "closed", "closedToday", "opensAt", "opensTodayAt", "opensTomorrowAt"];
4
4
  export type OpenNowLabels = Record<(typeof openNowLabelKeys)[number], string>;
5
5
  export declare const contactIslandLabelKeys: readonly ["send", "sending", "sent", "optional", "errorSummary", "errorServer", "errorServerCall", "privacyRead", "privacyLink", "privacyRequired", "marketingConsent", "honeypot", "choose", "errorRequired", "errorEmail", "errorPhone", "errorTooLong"];
6
6
  export type ContactIslandLabels = Record<(typeof contactIslandLabelKeys)[number], string>;
@@ -1,7 +1,7 @@
1
1
  // Label keys each client island needs. Kept out of the "use client" files: a server component
2
2
  // importing a plain value from a client module would receive a client reference, not the array.
3
3
  export const carouselLabelKeys = ["previous", "next", "slideCounter", "slideLabel", "carousel", "slide"];
4
- export const openNowLabelKeys = ["openNow", "closesAt", "closingSoon", "closed", "closedToday", "opensAt", "opensTodayAt", "open24h", "today"];
4
+ export const openNowLabelKeys = ["openNow", "openUntil", "closingSoon", "closingSoonUntil", "openAllDay", "closed", "closedToday", "opensAt", "opensTodayAt", "opensTomorrowAt"];
5
5
  export const contactIslandLabelKeys = [
6
6
  "send",
7
7
  "sending",
@@ -86,12 +86,15 @@ export declare const site2Labels: {
86
86
  email: string;
87
87
  hours: string;
88
88
  openNow: string;
89
- closesAt: string;
89
+ openUntil: string;
90
90
  closingSoon: string;
91
+ closingSoonUntil: string;
92
+ openAllDay: string;
91
93
  closed: string;
92
94
  closedToday: string;
93
95
  opensAt: string;
94
96
  opensTodayAt: string;
97
+ opensTomorrowAt: string;
95
98
  open24h: string;
96
99
  openingHours: string;
97
100
  openingHoursOf: string;
@@ -119,6 +122,11 @@ export declare const site2Labels: {
119
122
  tagSpicy: string;
120
123
  tagNew: string;
121
124
  tagChefPick: string;
125
+ tagDairyFree: string;
126
+ tagNutFree: string;
127
+ tagHalal: string;
128
+ tagToShare: string;
129
+ tagSeasonal: string;
122
130
  allergen_gluten: string;
123
131
  allergen_crustaceans: string;
124
132
  allergen_eggs: string;
@@ -95,13 +95,17 @@ export const site2Labels = {
95
95
  email: "Email",
96
96
  hours: "Hours",
97
97
  // Map & hours
98
- openNow: "Open now",
99
- closesAt: "closes at {time}",
98
+ // Status line "head · tail" (OpenNow). Each part says it once: "Open · until 1 AM".
99
+ openNow: "Open",
100
+ openUntil: "until {time}",
100
101
  closingSoon: "Closing soon",
102
+ closingSoonUntil: "open until {time}",
103
+ openAllDay: "24 hours",
101
104
  closed: "Closed",
102
105
  closedToday: "Closed today",
103
106
  opensAt: "opens {day} at {time}",
104
107
  opensTodayAt: "opens at {time}",
108
+ opensTomorrowAt: "opens tomorrow at {time}",
105
109
  open24h: "Open 24 hours",
106
110
  openingHours: "Opening hours",
107
111
  openingHoursOf: "Opening hours, {name}",
@@ -130,6 +134,11 @@ export const site2Labels = {
130
134
  tagSpicy: "Spicy",
131
135
  tagNew: "New",
132
136
  tagChefPick: "Chef’s pick",
137
+ tagDairyFree: "Dairy-free",
138
+ tagNutFree: "Nut-free",
139
+ tagHalal: "Halal",
140
+ tagToShare: "To share",
141
+ tagSeasonal: "Seasonal",
133
142
  allergen_gluten: "Gluten",
134
143
  allergen_crustaceans: "Crustaceans",
135
144
  allergen_eggs: "Eggs",
@@ -295,13 +304,17 @@ export const site2LabelsTr = {
295
304
  phone: "Telefon",
296
305
  email: "E-posta",
297
306
  hours: "Çalışma saatleri",
298
- openNow: "Şu an açık",
299
- closesAt: "{time}’de kapanıyor",
307
+ // {timeTo} = "01:00’e", {timeAt} = "12:00’de" — the suffix follows the spoken time.
308
+ openNow: "Açık",
309
+ openUntil: "{timeTo} kadar",
300
310
  closingSoon: "Birazdan kapanıyor",
311
+ closingSoonUntil: "{timeTo} kadar açık",
312
+ openAllDay: "24 saat",
301
313
  closed: "Kapalı",
302
314
  closedToday: "Bugün kapalı",
303
- opensAt: "{day} {time}’de açılıyor",
304
- opensTodayAt: "{time}’de açılıyor",
315
+ opensAt: "{day} {timeAt} açılıyor",
316
+ opensTodayAt: "bugün {timeAt} açılıyor",
317
+ opensTomorrowAt: "yarın {timeAt} açılıyor",
305
318
  open24h: "24 saat açık",
306
319
  openingHours: "Çalışma saatleri",
307
320
  openingHoursOf: "Çalışma saatleri, {name}",
@@ -329,6 +342,11 @@ export const site2LabelsTr = {
329
342
  tagSpicy: "Acılı",
330
343
  tagNew: "Yeni",
331
344
  tagChefPick: "Şefin seçimi",
345
+ tagDairyFree: "Sütsüz",
346
+ tagNutFree: "Kuruyemişsiz",
347
+ tagHalal: "Helal",
348
+ tagToShare: "Paylaşımlık",
349
+ tagSeasonal: "Mevsimlik",
332
350
  allergen_gluten: "Gluten",
333
351
  allergen_crustaceans: "Kabuklular",
334
352
  allergen_eggs: "Yumurta",
@@ -15,7 +15,7 @@ export interface OpenNowProps {
15
15
  className?: string;
16
16
  }
17
17
  /**
18
- * "Open now · closes at 22:00" — computed in the business's time zone on the client, refreshed
18
+ * "Open · until 10 PM" / "Closed · opens tomorrow at 12 PM" — computed in the business's time zone on the client, refreshed
19
19
  * every minute. Renders nothing on the server and without JS: a cached page never shows a stale
20
20
  * status. ~1 KB.
21
21
  */
@@ -4,7 +4,7 @@ import * as React from "react";
4
4
  import { cn2 as cn } from "./kit-2.js";
5
5
  import { describeStatus, hoursStatus } from "./opening-hours.js";
6
6
  /**
7
- * "Open now · closes at 22:00" — computed in the business's time zone on the client, refreshed
7
+ * "Open · until 10 PM" / "Closed · opens tomorrow at 12 PM" — computed in the business's time zone on the client, refreshed
8
8
  * every minute. Renders nothing on the server and without JS: a cached page never shows a stale
9
9
  * status. ~1 KB.
10
10
  */
@@ -110,8 +110,24 @@ export declare function summarizeHours(hours: OpeningHours, locale?: string): {
110
110
  }[];
111
111
  /** schema.org `OpeningHoursSpecification[]` for JSON-LD (weekly + special days). */
112
112
  export declare function openingHoursJsonLd(hours: OpeningHours): Record<string, unknown>[];
113
- /** The status sentence: "Open now · closes at 22:00", "Closed · opens Thu at 12:00". */
114
- export declare function describeStatus(s: OpenStatus, labels: Record<"openNow" | "closesAt" | "closingSoon" | "closed" | "closedToday" | "opensAt" | "opensTodayAt" | "open24h", string>, locale?: string): {
113
+ /** Label keys `describeStatus` reads. */
114
+ export type StatusLabelKey = "openNow" | "openUntil" | "closingSoon" | "closingSoonUntil" | "openAllDay" | "closed" | "closedToday" | "opensAt" | "opensTodayAt" | "opensTomorrowAt";
115
+ /**
116
+ * Variables for a time in a status template: `{time}` ("01:00", "1 AM"), plus `{timeTo}` /
117
+ * `{timeAt}` — the time with the Turkish dative / locative suffix ("01:00’e", "12:00’de") so the
118
+ * TR strings read naturally; other locales get the plain time.
119
+ */
120
+ export declare function timeVars(hhmm: string, locale?: string): {
121
+ time: string;
122
+ timeTo: string;
123
+ timeAt: string;
124
+ };
125
+ /**
126
+ * The status as `{ head, tail }`, each said once: "Open · until 1 AM", "Closing soon · open until
127
+ * 1 AM", "Open · 24 hours", "Closed · opens tomorrow at 12 PM", "Closed today · Staff day · opens
128
+ * Thu at 12 PM". TR: "Açık · 01:00’e kadar", "Kapalı · yarın 12:00’de açılıyor".
129
+ */
130
+ export declare function describeStatus(s: OpenStatus, labels: Record<StatusLabelKey, string>, locale?: string): {
115
131
  head: string;
116
132
  tail?: string;
117
133
  };
@@ -227,19 +227,48 @@ export function openingHoursJsonLd(hours) {
227
227
  }
228
228
  return out;
229
229
  }
230
- /** The status sentence: "Open now · closes at 22:00", "Closed · opens Thu at 12:00". */
230
+ // Turkish case suffixes on a clock time follow the last spoken word: "01:00" is read "bir" →
231
+ // "01:00’e" (dative) / "01:00’de" (locative); "22:30" ends in "otuz" → "22:30’a" / "22:30’da".
232
+ const TR_UNITS_DAT = ["", "e", "ye", "e", "e", "e", "ya", "ye", "e", "a"];
233
+ const TR_UNITS_LOC = ["", "de", "de", "te", "te", "te", "da", "de", "de", "da"];
234
+ const TR_TENS_DAT = ["a", "a", "ye", "a", "a", "ye"]; // sıfır, on, yirmi, otuz, kırk, elli
235
+ const TR_TENS_LOC = ["da", "da", "de", "da", "ta", "de"];
236
+ function trSuffix(hhmm, kind) {
237
+ const [h = 0, m = 0] = hhmm.split(":").map(Number);
238
+ const n = m === 0 ? h % 24 : m; // the last word read aloud
239
+ const unit = n % 10;
240
+ if (unit)
241
+ return (kind === "dat" ? TR_UNITS_DAT : TR_UNITS_LOC)[unit];
242
+ return (kind === "dat" ? TR_TENS_DAT : TR_TENS_LOC)[Math.floor(n / 10)];
243
+ }
244
+ /**
245
+ * Variables for a time in a status template: `{time}` ("01:00", "1 AM"), plus `{timeTo}` /
246
+ * `{timeAt}` — the time with the Turkish dative / locative suffix ("01:00’e", "12:00’de") so the
247
+ * TR strings read naturally; other locales get the plain time.
248
+ */
249
+ export function timeVars(hhmm, locale = "en-US") {
250
+ const time = formatTime(hhmm, locale);
251
+ if (!/^tr\b/i.test(locale))
252
+ return { time, timeTo: time, timeAt: time };
253
+ return { time, timeTo: `${time}’${trSuffix(hhmm, "dat")}`, timeAt: `${time}’${trSuffix(hhmm, "loc")}` };
254
+ }
255
+ /**
256
+ * The status as `{ head, tail }`, each said once: "Open · until 1 AM", "Closing soon · open until
257
+ * 1 AM", "Open · 24 hours", "Closed · opens tomorrow at 12 PM", "Closed today · Staff day · opens
258
+ * Thu at 12 PM". TR: "Açık · 01:00’e kadar", "Kapalı · yarın 12:00’de açılıyor".
259
+ */
231
260
  export function describeStatus(s, labels, locale = "en-US") {
232
261
  const f = (tpl, vars) => formatMessage(tpl, vars, locale);
233
262
  if (s.state !== "closed") {
234
263
  if (s.allDay)
235
- return { head: f(labels.openNow), tail: f(labels.open24h) };
236
- const tail = f(labels.closesAt, { time: formatTime(s.closesAt, locale) });
237
- return { head: f(s.state === "closing-soon" ? labels.closingSoon : labels.openNow), tail };
264
+ return { head: f(labels.openNow), tail: f(labels.openAllDay) };
265
+ const vars = timeVars(s.closesAt, locale);
266
+ return s.state === "closing-soon" ? { head: f(labels.closingSoon), tail: f(labels.closingSoonUntil, vars) } : { head: f(labels.openNow), tail: f(labels.openUntil, vars) };
238
267
  }
239
268
  const head = s.special?.closed ? (s.special.note ? `${f(labels.closedToday)} · ${s.special.note}` : f(labels.closedToday)) : f(labels.closed);
240
269
  if (!s.opensAt)
241
270
  return { head };
242
- const time = formatTime(s.opensAt.time, locale);
243
- const tail = s.opensAt.dayOffset === 0 ? f(labels.opensTodayAt, { time }) : f(labels.opensAt, { day: weekdayName(s.opensAt.weekday, locale, "short"), time });
244
- return { head, tail };
271
+ const vars = { ...timeVars(s.opensAt.time, locale), day: weekdayName(s.opensAt.weekday, locale, "short") };
272
+ const tpl = s.opensAt.dayOffset === 0 ? labels.opensTodayAt : s.opensAt.dayOffset === 1 ? labels.opensTomorrowAt : labels.opensAt;
273
+ return { head, tail: f(tpl, vars) };
245
274
  }
@@ -0,0 +1,9 @@
1
+ /** ≥ 24×24 CSS px everywhere (WCAG 2.5.8), ≥ 44px tall on touch. For text links in lists. */
2
+ export declare const TAP = "inline-flex min-h-6 items-center pointer-coarse:min-h-11";
3
+ /** A square icon control: ≥ 24px everywhere, 44×44 on touch. Combine with the control's own size. */
4
+ export declare const TAP_ICON = "min-h-6 min-w-6 pointer-coarse:min-h-11 pointer-coarse:min-w-11";
5
+ /**
6
+ * WCAG 2.5.3 Label in Name: an `aria-label` must contain the visible label. Returns `name` when
7
+ * it already does (case-insensitive), else `"<visible>, <name>"`.
8
+ */
9
+ export declare function nameWithLabel(visible: string | undefined, name: string): string;
@@ -0,0 +1,16 @@
1
+ // Tap-target sizing for site blocks (WCAG 2.5.8 + touch comfort). Padding/min-size only — the
2
+ // visual stays the same on a mouse; on touch (`pointer: coarse`) primary controls grow to 44px.
3
+ /** ≥ 24×24 CSS px everywhere (WCAG 2.5.8), ≥ 44px tall on touch. For text links in lists. */
4
+ export const TAP = "inline-flex min-h-6 items-center pointer-coarse:min-h-11";
5
+ /** A square icon control: ≥ 24px everywhere, 44×44 on touch. Combine with the control's own size. */
6
+ export const TAP_ICON = "min-h-6 min-w-6 pointer-coarse:min-h-11 pointer-coarse:min-w-11";
7
+ /**
8
+ * WCAG 2.5.3 Label in Name: an `aria-label` must contain the visible label. Returns `name` when
9
+ * it already does (case-insensitive), else `"<visible>, <name>"`.
10
+ */
11
+ export function nameWithLabel(visible, name) {
12
+ const v = visible?.trim();
13
+ if (!v)
14
+ return name;
15
+ return name.toLocaleLowerCase().includes(v.toLocaleLowerCase()) ? name : `${v}, ${name}`;
16
+ }
@@ -36,6 +36,13 @@ export interface SiteShellProps extends Omit<React.HTMLAttributes<HTMLDivElement
36
36
  pageParam?: string;
37
37
  /** Query key for the locale. Default `"lang"`. */
38
38
  localeParam?: string;
39
+ /**
40
+ * Motion level for the whole site → `data-motion`. subtle (default): short fades and small
41
+ * rises, count-ups, the logo ticker. full: longer, wider, a wiping hero title, magnetic
42
+ * calls-to-action. off: final state everywhere. Reduced motion always wins. Page and
43
+ * language switches cross-fade (View Transitions where available, a fade otherwise).
44
+ */
45
+ motion?: "full" | "subtle" | "off";
39
46
  }
40
47
  export interface SiteShellContextValue {
41
48
  page: string;
@@ -46,6 +53,10 @@ export interface SiteShellContextValue {
46
53
  navigate: (to: SiteLocation) => void;
47
54
  /** The href for a page (respects `routing` and `pageParam`). */
48
55
  hrefFor: (page: string, anchor?: string) => string;
56
+ /** The shell's motion level. */
57
+ motion: "full" | "subtle" | "off";
58
+ /** True after a switch that should fade the new page in (no View Transitions available). */
59
+ pageEnter: boolean;
49
60
  }
50
61
  /** Page, locale and navigation of the closest Site shell. Throws outside one. */
51
62
  export declare function useSiteShell(): SiteShellContextValue;
@@ -5,6 +5,8 @@ import { cn } from "../../lib/cn.js";
5
5
  import { ThemeScope } from "../../lib/theme-scope.js";
6
6
  import { useControllableState } from "../../lib/use-controllable-state.js";
7
7
  import { isBuiltInAccent, siteAccent, siteAccentCss, siteAccentKey } from "./shared/site-accent.js";
8
+ import { motionAllowed } from "../../motion/core.js";
9
+ import { startPageTransition, viewTransitionsSupported } from "../../motion/page-transition.js";
8
10
  import { parseSiteHref, readSiteLocation, sitePageHref, writeSiteLocation } from "./shared/site-routing.js";
9
11
  const SiteShellContext = React.createContext(null);
10
12
  /** Page, locale and navigation of the closest Site shell. Throws outside one. */
@@ -32,7 +34,7 @@ export function useSiteShell() {
32
34
  * </SiteShell>
33
35
  * ```
34
36
  */
35
- export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDark, font, material, density, theme, dir, locales = ["en", "tr"], locale: localeProp, defaultLocale, onLocaleChange, pages = ["home"], page: pageProp, defaultPage, onPageChange, routing = "search", pageParam = "page", localeParam = "lang", className, onClick, children, ...props }, ref) {
37
+ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDark, font, material, density, theme, dir, locales = ["en", "tr"], locale: localeProp, defaultLocale, onLocaleChange, pages = ["home"], page: pageProp, defaultPage, onPageChange, routing = "search", pageParam = "page", localeParam = "lang", motion = "subtle", className, onClick, children, ...props }, ref) {
36
38
  const homeLocale = defaultLocale ?? locales[0] ?? "en";
37
39
  const homePage = defaultPage ?? pages[0] ?? "home";
38
40
  const [locale, setLocale] = useControllableState({ value: localeProp, defaultValue: homeLocale, onChange: onLocaleChange, name: "SiteShell" });
@@ -47,6 +49,19 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
47
49
  }, [ref]);
48
50
  /** What to do after the next render: focus the h1, scroll to the top or to an anchor. */
49
51
  const pending = React.useRef(null);
52
+ const [pageEnter, setPageEnter] = React.useState(false);
53
+ /** Swap page/locale as a page transition: a View Transition, or a fade on the new page. */
54
+ const transition = React.useCallback((commit) => {
55
+ const root = rootRef.current;
56
+ const animate = motion !== "off" && motionAllowed(root);
57
+ setPageEnter(animate && !viewTransitionsSupported());
58
+ if (animate)
59
+ startPageTransition(commit, { scope: root });
60
+ else
61
+ commit();
62
+ }, [motion]);
63
+ const transitionRef = React.useRef(transition);
64
+ transitionRef.current = transition;
50
65
  const pageKey = pages.join("|");
51
66
  const localeKey = locales.join("|");
52
67
  const params = React.useMemo(() => ({ pageParam, localeParam }), [pageParam, localeParam]);
@@ -63,10 +78,14 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
63
78
  }
64
79
  if (pageChanged)
65
80
  pending.current = { focus: !to.anchor, scroll: true, anchor: to.anchor };
66
- if (nextLocale !== locale)
67
- setLocale(nextLocale);
68
- if (pageChanged)
69
- setPage(nextPage);
81
+ if (pageChanged || nextLocale !== locale) {
82
+ transition(() => {
83
+ if (nextLocale !== locale)
84
+ setLocale(nextLocale);
85
+ if (pageChanged)
86
+ setPage(nextPage);
87
+ });
88
+ }
70
89
  else if (to.anchor)
71
90
  scrollToAnchor(rootRef.current, to.anchor, false);
72
91
  // The Navbar's mobile menu is a native popover: close it after any switch.
@@ -80,7 +99,7 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
80
99
  });
81
100
  },
82
101
  // eslint-disable-next-line react-hooks/exhaustive-deps
83
- [page, locale, pageKey, localeKey, routing, homePage, homeLocale, params, setLocale, setPage]);
102
+ [page, locale, pageKey, localeKey, routing, homePage, homeLocale, params, setLocale, setPage, transition]);
84
103
  // URL → state on mount and on back/forward.
85
104
  React.useEffect(() => {
86
105
  if (routing === "memory")
@@ -90,12 +109,18 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
90
109
  const nextPage = loc.page && pages.includes(loc.page) ? loc.page : homePage;
91
110
  const nextLocale = loc.locale && locales.includes(loc.locale) ? loc.locale : homeLocale;
92
111
  const anchor = routing === "search" && window.location.hash.length > 1 ? decodeURIComponent(window.location.hash.slice(1)) : undefined;
93
- setLocale(nextLocale);
94
- setPage((prev) => {
95
- if (prev !== nextPage)
96
- pending.current = { focus: !initial && !anchor, scroll: !initial, anchor: initial ? anchor : undefined };
97
- return nextPage;
98
- });
112
+ const commit = () => {
113
+ setLocale(nextLocale);
114
+ setPage((prev) => {
115
+ if (prev !== nextPage)
116
+ pending.current = { focus: !initial && !anchor, scroll: !initial, anchor: initial ? anchor : undefined };
117
+ return nextPage;
118
+ });
119
+ };
120
+ if (initial)
121
+ commit();
122
+ else
123
+ transitionRef.current(commit);
99
124
  };
100
125
  // A deep link (`?page=order`) is server-rendered as home and switched here. As a transition,
101
126
  // React finishes hydrating the home tree (and any Suspense boundary still dehydrated) before
@@ -167,9 +192,11 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
167
192
  locales,
168
193
  navigate: (to) => apply(to, "push"),
169
194
  hrefFor: (p, anchor) => sitePageHref(p, anchor, { routing, pageParam }),
195
+ motion,
196
+ pageEnter,
170
197
  }),
171
198
  // eslint-disable-next-line react-hooks/exhaustive-deps
172
- [page, locale, pageKey, localeKey, apply, routing, pageParam]);
199
+ [page, locale, pageKey, localeKey, apply, routing, pageParam, motion, pageEnter]);
173
200
  // A colour → scoped variables. A name (built-in preset or your own, like "brand") goes
174
201
  // straight to data-accent.
175
202
  const custom = accent && !isBuiltInAccent(accent) && looksLikeColour(accent) ? accent : undefined;
@@ -186,7 +213,7 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
186
213
  return null;
187
214
  }
188
215
  }, [custom, accentDark]);
189
- return (_jsx(SiteShellContext.Provider, { value: ctx, children: _jsxs(ThemeScope, { ref: setRoot, theme: theme, material: material, density: density, font: font, dir: dir, accent: accentStyle ? "site" : custom ? undefined : accent, "data-site-accent": accentStyle?.key, "data-site-shell": "", "data-page": page, lang: locale, className: cn("vb-scope font-sans text-fg", className), onClick: handleClick, ...props, children: [accentStyle ? _jsx("style", { children: accentStyle.css }) : null, children] }) }));
216
+ return (_jsx(SiteShellContext.Provider, { value: ctx, children: _jsxs(ThemeScope, { ref: setRoot, theme: theme, material: material, density: density, font: font, dir: dir, accent: accentStyle ? "site" : custom ? undefined : accent, "data-site-accent": accentStyle?.key, "data-site-shell": "", "data-motion": motion, "data-page": page, lang: locale, className: cn("vb-scope font-sans text-fg", className), onClick: handleClick, ...props, children: [accentStyle ? _jsx("style", { children: accentStyle.css }) : null, children] }) }));
190
217
  });
191
218
  const looksLikeColour = (v) => v.trim().startsWith("#") || v.includes("(");
192
219
  const cssString = (s) => s.replace(/["\\]/g, "\\$&");
@@ -198,7 +225,7 @@ function scrollToAnchor(root, anchor, instant) {
198
225
  }
199
226
  /** One inner page: rendered only while it is the shell's current page. */
200
227
  export function SiteShellPage({ id, title, children }) {
201
- const { page } = useSiteShell();
228
+ const { page, pageEnter } = useSiteShell();
202
229
  const active = page === id;
203
230
  const first = React.useRef(true);
204
231
  React.useEffect(() => {
@@ -208,7 +235,7 @@ export function SiteShellPage({ id, title, children }) {
208
235
  }, [active, title]);
209
236
  if (!active)
210
237
  return null;
211
- return (_jsx("div", { "data-site-page": id, className: "contents", children: children }));
238
+ return (_jsx("div", { "data-site-page": id, "data-page-enter": pageEnter ? "" : undefined, className: "contents", children: children }));
212
239
  }
213
240
  /** One language version: rendered only while it is the shell's current locale (with `lang`). */
214
241
  export function SiteShellLocale({ code, children }) {
@@ -31,7 +31,9 @@ export interface StatsBandProps extends Omit<BlockBaseProps, "variant" | "labels
31
31
  }
32
32
  /**
33
33
  * Concrete, sourced numbers ("4.8 on Google · 1,240 reviews"). Semantic `dl`, Intl formatting,
34
- * units spelled out for screen readers, no count-up animation. Server component.
34
+ * units spelled out for screen readers. Numeric figures count up once when they scroll into view
35
+ * (motion kit CountUp: width reserved, final value for screen readers, still under reduced
36
+ * motion / `motion="off"`); the items reveal in sequence. Server component.
35
37
  *
36
38
  * ```tsx
37
39
  * <StatsBand items={[{ value: 4.8, label: "Google rating", source: "1,240 reviews, Nov 2026" }]} />