@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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 (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -1,5 +1,6 @@
1
1
  import { useT } from "../../i18n";
2
2
  import { Reveal } from "../../ui/Reveal";
3
+ import { MobileCarousel, clampAutoplayMs } from "../../ui/MobileCarousel";
3
4
  import { resolveBlockIcon } from "../icons";
4
5
  import type { IconGridProps } from "../types";
5
6
 
@@ -19,6 +20,11 @@ const TONE: Record<string, string> = {
19
20
  /**
20
21
  * Icon cards: icon + title + optional meta (e.g. price) and body text.
21
22
  * One column on mobile, configurable 2–5 columns from `sm:`/`lg:` up.
23
+ *
24
+ * On phones the cards can instead be a swipeable carousel (`mobileLayout:
25
+ * "slider"`). The switch happens below `sm`, so the tablet and desktop grids
26
+ * are exactly what they were; a block that never sets `mobileLayout` keeps
27
+ * stacking.
22
28
  */
23
29
  export function IconGridBlock({ props }: { props: IconGridProps }) {
24
30
  const { t } = useT();
@@ -28,11 +34,57 @@ export function IconGridBlock({ props }: { props: IconGridProps }) {
28
34
  const bare = props.cardStyle === "bare";
29
35
  const plainIcon = props.iconStyle === "plain";
30
36
  const iconAlign = props.iconAlign ?? "center";
37
+ const slider = props.mobileLayout === "slider";
31
38
  const iconAlignClass =
32
39
  iconAlign === "left" ? "self-start" : iconAlign === "right" ? "self-end" : "self-center";
33
40
  const textAlignClass =
34
41
  iconAlign === "left" ? "text-left" : iconAlign === "right" ? "text-right" : "text-center";
35
42
 
43
+ const card = (item: (typeof items)[number], i: number) => {
44
+ const Icon = resolveBlockIcon(item.icon);
45
+ const inner = (
46
+ <>
47
+ <span
48
+ className={`${iconAlignClass} ${
49
+ plainIcon
50
+ ? "inline-flex text-sea"
51
+ : "inline-flex size-11 shrink-0 items-center justify-center rounded-full border border-sea/35 bg-sea/10 text-sea"
52
+ }`}
53
+ aria-hidden
54
+ >
55
+ <Icon className="size-5" strokeWidth={1.5} />
56
+ </span>
57
+ <div className="mt-5 min-w-0">
58
+ <h3 className="font-serif text-lg md:text-xl leading-snug text-ink whitespace-pre-line">
59
+ {item.title ? t(item.title) : null}
60
+ </h3>
61
+ {item.meta ? (
62
+ <div className="mt-1.5 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
63
+ {t(item.meta)}
64
+ </div>
65
+ ) : null}
66
+ {item.body ? (
67
+ <p className="mt-3 text-sm leading-relaxed text-ink/70 whitespace-pre-line">
68
+ {t(item.body)}
69
+ </p>
70
+ ) : null}
71
+ </div>
72
+ </>
73
+ );
74
+ const cls = bare
75
+ ? `flex h-full flex-col ${textAlignClass}`
76
+ : `flex h-full flex-col p-7 md:p-8 ${tone} ${textAlignClass} transition-colors hover:bg-ice/40`;
77
+ return item.href ? (
78
+ <a key={item.id ?? i} href={item.href} className={`${cls} group`}>
79
+ {inner}
80
+ </a>
81
+ ) : (
82
+ <div key={item.id ?? i} className={cls}>
83
+ {inner}
84
+ </div>
85
+ );
86
+ };
87
+
36
88
  return (
37
89
  <section id={props.anchor || undefined} className={`${tone} py-20 md:py-28`}>
38
90
  <div className="container-editorial">
@@ -58,59 +110,32 @@ export function IconGridBlock({ props }: { props: IconGridProps }) {
58
110
  </Reveal>
59
111
  )}
60
112
 
61
- <div
62
- className={`mt-12 md:mt-16 grid grid-cols-1 ${cols} ${
63
- bare ? "gap-8 md:gap-10" : "gap-px bg-ink/10"
64
- }`}
65
- >
66
-
67
- {items.map((item, i) => {
68
- const Icon = resolveBlockIcon(item.icon);
69
- const inner = (
70
- <>
71
- <span
72
- className={`${iconAlignClass} ${
73
- plainIcon
74
- ? "inline-flex text-sea"
75
- : "inline-flex size-11 shrink-0 items-center justify-center rounded-full border border-sea/35 bg-sea/10 text-sea"
76
- }`}
77
- aria-hidden
78
- >
79
- <Icon className="size-5" strokeWidth={1.5} />
80
- </span>
81
- <div className="mt-5 min-w-0">
82
- <h3 className="font-serif text-lg md:text-xl leading-snug text-ink whitespace-pre-line">
83
- {item.title ? t(item.title) : null}
84
- </h3>
85
- {item.meta ? (
86
- <div className="mt-1.5 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
87
- {t(item.meta)}
88
- </div>
89
- ) : null}
90
- {item.body ? (
91
- <p className="mt-3 text-sm leading-relaxed text-ink/70 whitespace-pre-line">
92
- {t(item.body)}
93
- </p>
94
- ) : null}
95
- </div>
96
- </>
97
- );
98
- const cls = bare
99
- ? `flex h-full flex-col ${textAlignClass}`
100
- : `flex h-full flex-col p-7 md:p-8 ${tone} ${textAlignClass} transition-colors hover:bg-ice/40`;
101
- return (
113
+ {slider ? (
114
+ <div className="mt-12 md:mt-16">
115
+ <MobileCarousel
116
+ label={props.title ? t(props.title) : "Cards"}
117
+ gridClassName={`grid-cols-1 ${cols} ${bare ? "" : "sm:bg-ink/10"}`}
118
+ gapClassName={bare ? "gap-8 md:gap-10" : "gap-4 sm:gap-px"}
119
+ slideClassName={bare ? "" : "border border-ink/10 sm:border-0"}
120
+ autoplay={props.mobileAutoplay === true}
121
+ autoplayMs={clampAutoplayMs(props.mobileAutoplayMs)}
122
+ >
123
+ {items.map((item, i) => card(item, i))}
124
+ </MobileCarousel>
125
+ </div>
126
+ ) : (
127
+ <div
128
+ className={`mt-12 md:mt-16 grid grid-cols-1 ${cols} ${
129
+ bare ? "gap-8 md:gap-10" : "gap-px bg-ink/10"
130
+ }`}
131
+ >
132
+ {items.map((item, i) => (
102
133
  <Reveal key={item.id ?? i} delay={i * 50}>
103
- {item.href ? (
104
- <a href={item.href} className={`${cls} group`}>
105
- {inner}
106
- </a>
107
- ) : (
108
- <div className={cls}>{inner}</div>
109
- )}
134
+ {card(item, i)}
110
135
  </Reveal>
111
- );
112
- })}
113
- </div>
136
+ ))}
137
+ </div>
138
+ )}
114
139
 
115
140
  {props.note ? (
116
141
  <Reveal>
@@ -0,0 +1,100 @@
1
+ import { useBlockChildren } from "../block-children";
2
+ import { readColumnsFromProps, layoutColumnCountFromProps } from "../block-tree";
3
+ import type { BlockProps, LayoutProps } from "../types";
4
+
5
+ /*
6
+ * Tailwind needs the finished class names in the source, so the responsive
7
+ * column counts are literal maps rather than generated strings.
8
+ */
9
+ const TABLET_COLS: Record<number, string> = {
10
+ 1: "sm:grid-cols-1",
11
+ 2: "sm:grid-cols-2",
12
+ 3: "sm:grid-cols-3",
13
+ 4: "sm:grid-cols-4",
14
+ };
15
+
16
+ const DESKTOP_COLS: Record<number, string> = {
17
+ 2: "lg:grid-cols-2",
18
+ 3: "lg:grid-cols-3",
19
+ 4: "lg:grid-cols-4",
20
+ 5: "lg:grid-cols-5",
21
+ 6: "lg:grid-cols-6",
22
+ 7: "lg:grid-cols-7",
23
+ 8: "lg:grid-cols-8",
24
+ };
25
+
26
+ const GAP: Record<string, string> = {
27
+ none: "gap-0",
28
+ sm: "gap-4",
29
+ md: "gap-8 md:gap-10",
30
+ lg: "gap-12 md:gap-16",
31
+ };
32
+
33
+ const ALIGN: Record<string, string> = {
34
+ start: "items-start",
35
+ center: "items-center",
36
+ stretch: "items-stretch",
37
+ };
38
+
39
+ const TONE: Record<string, string> = {
40
+ transparent: "",
41
+ white: "bg-white",
42
+ paper: "bg-paper",
43
+ ice: "bg-ice",
44
+ };
45
+
46
+ const PADDING: Record<string, string> = {
47
+ none: "",
48
+ compact: "py-8 md:py-10",
49
+ normal: "py-16 md:py-20",
50
+ roomy: "py-24 md:py-32",
51
+ };
52
+
53
+ /**
54
+ * A row of columns holding other blocks.
55
+ *
56
+ * It renders no content of its own: each column is a stack of child blocks,
57
+ * drawn through the page renderer so nested blocks behave exactly as they do
58
+ * at the top level. Columns always collapse to one on phones and every column
59
+ * is `min-w-0`, so a wide child (a table, a long word, an image) shrinks with
60
+ * its column instead of pushing the page sideways.
61
+ */
62
+ export function LayoutBlock({ props }: { props: LayoutProps }) {
63
+ const { render, edit } = useBlockChildren();
64
+ const raw = props as unknown as BlockProps;
65
+ const columns = readColumnsFromProps(raw);
66
+ const desktop = layoutColumnCountFromProps(raw);
67
+ const tablet = Math.min(4, Math.max(1, props.columnsTablet ?? Math.min(2, desktop)));
68
+
69
+ const tone = TONE[props.tone ?? "transparent"] ?? "";
70
+ const padding = PADDING[props.padding ?? "none"] ?? "";
71
+ const gap = GAP[props.gap ?? "md"] ?? GAP["md"];
72
+ const align = ALIGN[props.align ?? "stretch"] ?? ALIGN["stretch"];
73
+ const contained = props.contained !== false;
74
+
75
+ const grid = (
76
+ <div
77
+ className={`grid w-full grid-cols-1 ${TABLET_COLS[tablet] ?? TABLET_COLS[2]} ${
78
+ DESKTOP_COLS[desktop] ?? DESKTOP_COLS[2]
79
+ } ${gap} ${align}`}
80
+ >
81
+ {columns.map((column, i) => (
82
+ <div key={i} className="min-w-0">
83
+ {column.length === 0 && edit ? (
84
+ <div className="flex min-h-24 items-center justify-center border border-dashed border-ink/20 p-4 text-[0.7rem] uppercase tracking-[0.18em] text-ink/35">
85
+ {`Column ${i + 1}`}
86
+ </div>
87
+ ) : (
88
+ render(column)
89
+ )}
90
+ </div>
91
+ ))}
92
+ </div>
93
+ );
94
+
95
+ return (
96
+ <section id={props.anchor || undefined} className={`${tone} ${padding}`.trim()}>
97
+ {contained ? <div className="container-editorial">{grid}</div> : grid}
98
+ </section>
99
+ );
100
+ }
@@ -4,89 +4,34 @@
4
4
  * The editor stores a wall-clock date/time (`2026-09-07T16:00`) plus an IANA
5
5
  * timezone (`Europe/Stockholm`). That pair is resolved to one absolute instant,
6
6
  * so every visitor — wherever they are — counts down to the same moment.
7
- * Only `Intl` is used, no extra dependency.
7
+ *
8
+ * The zone maths itself lives in `../cms/timezone`, which is the platform's one
9
+ * scheduling engine (countdowns, scheduled menu items and scheduled footer
10
+ * links all share it). The names below are kept as they were so existing
11
+ * imports keep working.
8
12
  */
9
13
 
14
+ import {
15
+ DEFAULT_TIMEZONE,
16
+ TIMEZONE_OPTIONS,
17
+ formatInZone,
18
+ timezoneOffsetMs,
19
+ zonedLocalToInstant,
20
+ } from "../cms/timezone";
21
+
10
22
  /** A short list of zones offered in the editor; any IANA zone can be typed. */
11
- export const COUNTDOWN_TIMEZONES = [
12
- "Europe/Stockholm",
13
- "Europe/Oslo",
14
- "Europe/Copenhagen",
15
- "Europe/Helsinki",
16
- "Europe/London",
17
- "Europe/Berlin",
18
- "Europe/Madrid",
19
- "Europe/Athens",
20
- "UTC",
21
- "America/New_York",
22
- "America/Chicago",
23
- "America/Los_Angeles",
24
- "Asia/Dubai",
25
- "Asia/Bangkok",
26
- "Asia/Tokyo",
27
- "Australia/Sydney",
28
- ] as const;
23
+ export const COUNTDOWN_TIMEZONES = TIMEZONE_OPTIONS;
29
24
 
30
- export const COUNTDOWN_DEFAULT_TIMEZONE = "Europe/Stockholm";
25
+ export const COUNTDOWN_DEFAULT_TIMEZONE = DEFAULT_TIMEZONE;
31
26
 
32
- function safeZone(tz?: string): string {
33
- const zone = tz?.trim() || COUNTDOWN_DEFAULT_TIMEZONE;
34
- try {
35
- new Intl.DateTimeFormat("en-GB", { timeZone: zone });
36
- return zone;
37
- } catch {
38
- return "UTC";
39
- }
40
- }
41
-
42
- /** Offset (ms) of `tz` from UTC at the given instant. */
43
- export function timezoneOffsetMs(instant: number, tz: string): number {
44
- const zone = safeZone(tz);
45
- const parts = new Intl.DateTimeFormat("en-GB", {
46
- timeZone: zone,
47
- hour12: false,
48
- year: "numeric",
49
- month: "2-digit",
50
- day: "2-digit",
51
- hour: "2-digit",
52
- minute: "2-digit",
53
- second: "2-digit",
54
- }).formatToParts(new Date(instant));
55
- const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
56
- const asUtc = Date.UTC(
57
- get("year"),
58
- get("month") - 1,
59
- get("day"),
60
- get("hour") % 24,
61
- get("minute"),
62
- get("second"),
63
- );
64
- return asUtc - instant;
65
- }
27
+ export { timezoneOffsetMs, formatInZone };
66
28
 
67
29
  /**
68
30
  * Resolve `YYYY-MM-DDTHH:mm` in `tz` to an absolute timestamp (ms).
69
31
  * Returns `null` when the value is empty or unparsable.
70
32
  */
71
33
  export function resolveCountdownTarget(local?: string, tz?: string): number | null {
72
- const raw = local?.trim();
73
- if (!raw) return null;
74
- const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(raw);
75
- if (!m) return null;
76
- const [, y, mo, d, h, mi, s] = m;
77
- const naive = Date.UTC(
78
- Number(y),
79
- Number(mo) - 1,
80
- Number(d),
81
- Number(h ?? "0"),
82
- Number(mi ?? "0"),
83
- Number(s ?? "0"),
84
- );
85
- const zone = safeZone(tz);
86
- // Two passes settle DST boundaries (the offset itself depends on the instant).
87
- let instant = naive - timezoneOffsetMs(naive, zone);
88
- instant = naive - timezoneOffsetMs(instant, zone);
89
- return instant;
34
+ return zonedLocalToInstant(local, tz);
90
35
  }
91
36
 
92
37
  export type CountdownParts = {
@@ -111,18 +56,3 @@ export function countdownParts(target: number | null, now: number): CountdownPar
111
56
  expired: target != null && total <= 0,
112
57
  };
113
58
  }
114
-
115
- /** Human wall-clock text for an instant in a zone, e.g. "7 Sep 2026, 16:00 CEST". */
116
- export function formatInZone(instant: number, tz?: string, locale = "en-GB"): string {
117
- const zone = safeZone(tz);
118
- return new Intl.DateTimeFormat(locale, {
119
- timeZone: zone,
120
- day: "numeric",
121
- month: "short",
122
- year: "numeric",
123
- hour: "2-digit",
124
- minute: "2-digit",
125
- hour12: false,
126
- timeZoneName: "short",
127
- }).format(new Date(instant));
128
- }
@@ -15,6 +15,7 @@ import { CabinTabsBlock } from "./components/CabinTabsBlock";
15
15
  import { PriceTableBlock } from "./components/PriceTableBlock";
16
16
  import { FormBlock } from "./components/FormBlock";
17
17
  import { TripListBlock } from "./components/TripListBlock";
18
+ import { LayoutBlock } from "./components/LayoutBlock";
18
19
  import type {
19
20
  BlockProps,
20
21
  FaqProps,
@@ -33,6 +34,7 @@ import type {
33
34
  PriceTableProps,
34
35
  FormProps,
35
36
  TripListProps,
37
+ LayoutProps,
36
38
  } from "./types";
37
39
  import { BlockPropsProvider } from "./block-props-context";
38
40
 
@@ -66,6 +68,25 @@ export const wrapBlock = (C: ComponentType) =>
66
68
  * Default copy here is intentionally brand-neutral.
67
69
  */
68
70
  export const SHARED_BLOCK_DEFS: Record<string, BlockDef> = {
71
+ layout: {
72
+ type: "layout",
73
+ label: "Columns",
74
+ description:
75
+ "Container: puts other blocks side by side. 2\u20138 columns on desktop, configurable on tablet, stacked on phones.",
76
+ category: "section",
77
+ component: ({ props }) => <LayoutBlock props={props as LayoutProps} />,
78
+ defaultProps: {
79
+ columnsDesktop: 2,
80
+ columnsTablet: 2,
81
+ gap: "md",
82
+ align: "stretch",
83
+ tone: "transparent",
84
+ contained: true,
85
+ padding: "none",
86
+ columns: [[], []],
87
+ } satisfies LayoutProps as unknown as BlockProps,
88
+ hasInlineContent: false,
89
+ },
69
90
  faq: {
70
91
  type: "faq",
71
92
  label: "FAQ",
@@ -85,6 +85,15 @@ export type IconGridProps = {
85
85
  /** Horizontal placement of the icon above the card text. Defaults to centred. */
86
86
  iconAlign?: "left" | "center" | "right";
87
87
  cardStyle?: "bordered" | "bare";
88
+ /**
89
+ * How the cards behave on phones. `stack` is the original single-column
90
+ * stack and stays the default, so existing pages are untouched.
91
+ */
92
+ mobileLayout?: "stack" | "slider";
93
+ /** Advance the slider on its own. Off unless switched on. */
94
+ mobileAutoplay?: boolean;
95
+ /** Milliseconds between automatic advances. */
96
+ mobileAutoplayMs?: number;
88
97
  anchor?: string;
89
98
  items?: IconGridItem[];
90
99
  };
@@ -519,3 +528,34 @@ export type CountdownProps = {
519
528
 
520
529
  anchor?: string;
521
530
  };
531
+
532
+ /** ---- Layout (columns container) ---- */
533
+
534
+ /**
535
+ * A container block: it holds other blocks in columns instead of rendering
536
+ * content of its own. Children live in `columns` — one entry per desktop
537
+ * column — and are walked with the helpers in `./block-tree`.
538
+ */
539
+ export type LayoutProps = {
540
+ /** 2-8 columns side by side from the desktop breakpoint up. */
541
+ columnsDesktop?: 2 | 3 | 4 | 5 | 6 | 7 | 8;
542
+ /** Columns from the tablet breakpoint up. */
543
+ columnsTablet?: 1 | 2 | 3 | 4;
544
+ /**
545
+ * Legacy. Phones always show one column — two columns on a 375px screen is
546
+ * two unreadable ones — so this is tolerated in stored payloads and ignored.
547
+ * There is no editor control for it.
548
+ */
549
+ columnsMobile?: number;
550
+ /** Gap between columns, using the theme spacing scale. */
551
+ gap?: "none" | "sm" | "md" | "lg";
552
+ /** Vertical alignment of columns of unequal height. */
553
+ align?: "start" | "center" | "stretch";
554
+ tone?: "transparent" | "white" | "paper" | "ice";
555
+ /** Constrain the row to the site container width. */
556
+ contained?: boolean;
557
+ padding?: "none" | "compact" | "normal" | "roomy";
558
+ anchor?: string;
559
+ /** Child blocks, one array per column. Managed by the page builder. */
560
+ columns?: BlockInstance[][];
561
+ };