@teamscala/service-boot 0.23.417 → 0.23.418

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/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.417",
9
+ "version": "0.23.418",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -1221,6 +1221,38 @@
1221
1221
  "./fn-routers/assessment-scoring.ts": {
1222
1222
  "types": "./src/fn-routers/assessment-scoring.d.ts",
1223
1223
  "default": "./src/fn-routers/assessment-scoring.ts"
1224
+ },
1225
+ "./register-registry-page-route/page-data-shared": {
1226
+ "types": "./src/register-registry-page-route/page-data-shared.d.ts",
1227
+ "default": "./src/register-registry-page-route/page-data-shared.ts"
1228
+ },
1229
+ "./register-registry-page-route/page-data-shared.ts": {
1230
+ "types": "./src/register-registry-page-route/page-data-shared.d.ts",
1231
+ "default": "./src/register-registry-page-route/page-data-shared.ts"
1232
+ },
1233
+ "./register-registry-page-route/region-slots-page-data": {
1234
+ "types": "./src/register-registry-page-route/region-slots-page-data.d.ts",
1235
+ "default": "./src/register-registry-page-route/region-slots-page-data.ts"
1236
+ },
1237
+ "./register-registry-page-route/region-slots-page-data.ts": {
1238
+ "types": "./src/register-registry-page-route/region-slots-page-data.d.ts",
1239
+ "default": "./src/register-registry-page-route/region-slots-page-data.ts"
1240
+ },
1241
+ "./register-registry-page-route/public-shell-page-data": {
1242
+ "types": "./src/register-registry-page-route/public-shell-page-data.d.ts",
1243
+ "default": "./src/register-registry-page-route/public-shell-page-data.ts"
1244
+ },
1245
+ "./register-registry-page-route/public-shell-page-data.ts": {
1246
+ "types": "./src/register-registry-page-route/public-shell-page-data.d.ts",
1247
+ "default": "./src/register-registry-page-route/public-shell-page-data.ts"
1248
+ },
1249
+ "./register-registry-page-route/single-page-data": {
1250
+ "types": "./src/register-registry-page-route/single-page-data.d.ts",
1251
+ "default": "./src/register-registry-page-route/single-page-data.ts"
1252
+ },
1253
+ "./register-registry-page-route/single-page-data.ts": {
1254
+ "types": "./src/register-registry-page-route/single-page-data.d.ts",
1255
+ "default": "./src/register-registry-page-route/single-page-data.ts"
1224
1256
  }
1225
1257
  },
1226
1258
  "repository": "https://github.com/teamgbg/service-boot",
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Shared Puck-page-data helpers for the resolved-graph → pageData conversion:
7
+ * the local PuckData structural type, JSON extraction with an honest null on
8
+ * malformed input, layout-default application, region root-prop picking, the
9
+ * RegionEntry shape, and the unresolved-region-slot warn. Plus the brand-token
10
+ * prologue shared by all three render branches.
11
+ *
12
+ * Split out of resolve-page-data.ts (547 lines) — byte-for-byte moves; only
13
+ * closure variables became explicit parameters.
14
+ */
15
+
16
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
17
+
18
+ /**
19
+ * Local structural type for Puck page data. Avoids a type-only import of
20
+ * `@puckeditor/core`, which is NOT a runtime dependency — `deps-derived-from-source`
21
+ * excludes whole-statement type-only imports, so the bare specifier has no
22
+ * installed package to resolve against at publish typecheck time. The shape used
23
+ * here (root/content/zones) is all this module reads or constructs.
24
+ */
25
+ export type PuckData = {
26
+ root: { props?: Record<string, unknown> };
27
+ content: unknown[];
28
+ zones?: Record<string, unknown>;
29
+ };
30
+
31
+ export const EMPTY_PUCK_DATA: PuckData = { root: { props: {} }, content: [] };
32
+
33
+ // Returns null on malformed JSON (observed via warn log) instead of a silent
34
+ // masking empty fallback — protective-layers-fail-explicitly. Callers default
35
+ // to EMPTY_PUCK_DATA where an empty render is the intended degradation.
36
+ export function extractPuckData(source: Record<string, unknown>): PuckData | null {
37
+ const raw = source.puck_data ?? source.puckData;
38
+ if (!raw) return EMPTY_PUCK_DATA;
39
+ if (typeof raw === "string") {
40
+ try {
41
+ return JSON.parse(raw) as PuckData;
42
+ } catch (err) {
43
+ getAppLogger().warn(
44
+ "[extractPuckData] malformed puck_data JSON — rendering empty",
45
+ { err },
46
+ );
47
+ return null;
48
+ }
49
+ }
50
+ return raw as PuckData;
51
+ }
52
+
53
+ export function applyLayoutDefaults(
54
+ puckData: PuckData,
55
+ defaults: Record<string, string>,
56
+ ): void {
57
+ if (!defaults || Object.keys(defaults).length === 0) return;
58
+ const data = puckData as { root?: { props?: Record<string, unknown> } };
59
+ if (!data.root) data.root = { props: {} };
60
+ if (!data.root.props) data.root.props = {};
61
+ for (const [key, value] of Object.entries(defaults)) {
62
+ if (data.root.props[key] == null) data.root.props[key] = value;
63
+ }
64
+ }
65
+
66
+ export function pickRootProp<T>(regions: RegionEntry[], key: string): T | undefined {
67
+ for (const region of regions) {
68
+ const props = (
69
+ region.puckData as { root?: { props?: Record<string, unknown> } } | null
70
+ )?.root?.props;
71
+ const value = props?.[key];
72
+ if (value != null && value !== "") return value as T;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ export interface RegionEntry {
78
+ slot: string;
79
+ puckData: PuckData;
80
+ pageId: string;
81
+ website?: { slug: string; name: string; organisation_id?: string | null };
82
+ metaTitle?: string | null;
83
+ metaDescription?: string | null;
84
+ }
85
+
86
+ // A region slot the operator DECLARED (resource_loader.region_slots) whose
87
+ // source did not resolve used to vanish with no signal anywhere — the page
88
+ // rendered minus a region while the page, source, attributes, template and
89
+ // tree builder all looked innocent (measured 2026-08-18: a footer region
90
+ // added to a report produced no footer and no diagnostic; the cause was the
91
+ // boot-time config capture named in the message). One warn per slot per
92
+ // process, throttled like the block-coverage guard in the caller, so the hot
93
+ // path never spams it.
94
+ const unresolvedRegionSlotsLogged = new Set<string>();
95
+
96
+ export function warnUnresolvedRegionSlot(slot: string): void {
97
+ if (unresolvedRegionSlotsLogged.has(slot)) return;
98
+ unresolvedRegionSlotsLogged.add(slot);
99
+ getAppLogger().warn(
100
+ `[resolve-page-data] region slot "${slot}" is declared in the resource_loader config but no source resolved for it — the region is omitted from this render. A resource_loader edit reaches a booted service only after a restart.`,
101
+ );
102
+ }
103
+
104
+ /** The resolved brand tokens every branch reads (the old file's shared prologue). */
105
+ export function readBrandTokens(resolved: Record<string, unknown>): {
106
+ brand: Record<string, unknown>;
107
+ primaryColor: string;
108
+ headingFont: string | null;
109
+ bodyFont: string | null;
110
+ logo: { light: string | null; dark: string | null; brand: string | null };
111
+ brandTheme: string | undefined;
112
+ } {
113
+ const brand = (resolved.brand as Record<string, unknown>) ?? {};
114
+ const primaryColor = (brand.primaryColor as string) ?? "";
115
+ const headingFont = (brand.headingFont as string) ?? null;
116
+ const bodyFont = (brand.bodyFont as string) ?? null;
117
+ const logo = (brand.logo as {
118
+ light: string | null;
119
+ dark: string | null;
120
+ brand: string | null;
121
+ }) ?? { light: null, dark: null, brand: null };
122
+ const brandTheme = typeof brand.theme === "string" ? brand.theme : undefined;
123
+ return { brand, primaryColor, headingFont, bodyFont, logo, brandTheme };
124
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Branch 2 of the resolved-graph → pageData conversion: the public shell
7
+ * (header/body/footer regions from header/footer sources).
8
+ *
9
+ * Split out of resolve-page-data.ts (547 lines) — byte-identical body; only
10
+ * the closure variables became explicit parameters.
11
+ */
12
+
13
+ import { resolveThemeMode } from "../resolve-theme-mode.ts";
14
+ import { composePageTitle } from "../compose-page-title.ts";
15
+ import { resolveEntityTitle } from "./meta-title.ts";
16
+ import {
17
+ applyLayoutDefaults,
18
+ EMPTY_PUCK_DATA,
19
+ extractPuckData,
20
+ pickRootProp,
21
+ readBrandTokens,
22
+ type PuckData,
23
+ type RegionEntry,
24
+ } from "./page-data-shared.ts";
25
+
26
+ export function publicShellPageData(
27
+ resolved: Record<string, unknown>,
28
+ meta: Record<string, unknown>,
29
+ themeOverride: "light" | "dark" | undefined,
30
+ orgPrefix: boolean | undefined,
31
+ ): Record<string, unknown> {
32
+ const { brand, primaryColor, headingFont, bodyFont, logo, brandTheme } =
33
+ readBrandTokens(resolved);
34
+ const headerSource = resolved.header as Record<string, unknown> | undefined;
35
+ const footerSource = resolved.footer as Record<string, unknown> | undefined;
36
+
37
+ const page = (resolved.page as Record<string, unknown>) ?? {};
38
+ const website =
39
+ (resolved.website_by_host as Record<string, unknown>) ??
40
+ (resolved.website as Record<string, unknown>) ??
41
+ {};
42
+ const layoutDefaults =
43
+ (website.layout_defaults as Record<string, string>) ??
44
+ (website.layout_settings as Record<string, string>) ??
45
+ {};
46
+ const layoutSettings = website.layout_settings as
47
+ | { theme?: unknown }
48
+ | null
49
+ | undefined;
50
+ const websiteTheme =
51
+ layoutSettings && typeof layoutSettings.theme === "string"
52
+ ? layoutSettings.theme
53
+ : undefined;
54
+
55
+ const regions: RegionEntry[] = [];
56
+
57
+ if (headerSource) {
58
+ const headerPuck = extractPuckData(headerSource) ?? EMPTY_PUCK_DATA;
59
+ applyLayoutDefaults(headerPuck, layoutDefaults);
60
+ regions.push({
61
+ slot: "header",
62
+ puckData: headerPuck,
63
+ pageId: (headerSource.id as string) ?? "",
64
+ });
65
+ }
66
+
67
+ const bodyPuck = extractPuckData(page) ?? EMPTY_PUCK_DATA;
68
+ applyLayoutDefaults(bodyPuck, layoutDefaults);
69
+ regions.push({
70
+ slot: "body",
71
+ puckData: bodyPuck,
72
+ pageId: (page.id as string) ?? "",
73
+ metaTitle: (page.meta_title as string) ?? null,
74
+ metaDescription: (page.meta_description as string) ?? null,
75
+ });
76
+
77
+ if (footerSource) {
78
+ const footerPuck = extractPuckData(footerSource) ?? EMPTY_PUCK_DATA;
79
+ applyLayoutDefaults(footerPuck, layoutDefaults);
80
+ regions.push({
81
+ slot: "footer",
82
+ puckData: footerPuck,
83
+ pageId: (footerSource.id as string) ?? "",
84
+ });
85
+ }
86
+
87
+ const rootTheme = pickRootProp<string>(regions, "theme");
88
+ const { theme } = resolveThemeMode({
89
+ override: themeOverride,
90
+ candidates: [rootTheme, brandTheme, websiteTheme],
91
+ });
92
+
93
+ const overrideColor = pickRootProp<string>(regions, "primaryColor");
94
+ const overrideBg = pickRootProp<string>(regions, "backgroundColor");
95
+ const overrideHeading = pickRootProp<string>(regions, "headingFont");
96
+ const overrideBody = pickRootProp<string>(regions, "bodyFont");
97
+
98
+ const pageName = (page.name as string) || null;
99
+ const entityTitle = resolveEntityTitle(resolved);
100
+ const bodyMetaTitle = regions.find((r) => r.slot === "body")?.metaTitle;
101
+ const pageRootTitle = pickRootProp<string>(regions, "title");
102
+ const pageDescription =
103
+ regions.find((r) => r.slot === "body")?.metaDescription ??
104
+ pickRootProp<string>(regions, "description") ??
105
+ null;
106
+
107
+ return {
108
+ puckData: { root: { props: {} }, content: [] } as PuckData,
109
+ regions,
110
+ // Region-shell blocks (e.g. the header Navigation deriving the body's
111
+ // document sections) read the resolved entity graph from
112
+ // pageData.loaderData — the same surface Branch 1 exposes. Without it a
113
+ // header/footer block serverLoader gets ctx.loaderData=undefined and
114
+ // cannot see the body page, so a server-derived nav produced no items
115
+ // and the menu was client-only (vanished on stale tabs / narrow views).
116
+ loaderData: resolved,
117
+ website: {
118
+ slug: website.slug as string,
119
+ name: website.name as string,
120
+ organisation_id: website.organisation_id as string | null,
121
+ // header_settings carries the DB-driven header config (e.g.
122
+ // backdropBlur) the public shell applies — PublicTemplate reads
123
+ // pageData.website.header_settings.backdropBlur. Dropping it here
124
+ // silently kills the header blur even though the DB switch is set
125
+ // (the 2026-06-17 regression). Pass it through verbatim.
126
+ header_settings:
127
+ (website.header_settings as Record<string, unknown> | null) ?? null,
128
+ },
129
+ orgId:
130
+ (page.organisation_id as string) ??
131
+ null,
132
+ brand: {
133
+ primaryColor: overrideColor ?? primaryColor,
134
+ // Thread the org brand's dark surface (brand_background_color /
135
+ // brand_dark_color) when the page sets no explicit override — so
136
+ // dark-mode pages use the brand's real dark colour, not a
137
+ // primary-derived tint. Uses the org brand's real dark colour.
138
+ backgroundColor:
139
+ overrideBg ?? (brand.backgroundColor as string | null) ?? null,
140
+ lightSurface: (brand.lightSurface as string | null) ?? null,
141
+ darkSurface: (brand.darkSurface as string | null) ?? null,
142
+ accentColor: (brand.accentColor as string | null) ?? null,
143
+ secondaryColor: (brand.secondaryColor as string | null) ?? null,
144
+ mutedColor: (brand.mutedColor as string | null) ?? null,
145
+ headingFont: overrideHeading ?? headingFont,
146
+ bodyFont: overrideBody ?? bodyFont,
147
+ logo,
148
+ companyName: (brand.companyName as string) ?? null,
149
+ companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
150
+ contactEmail: (brand.contactEmail as string) ?? null,
151
+ favicon: (brand.favicon as string) ?? null,
152
+ },
153
+ theme,
154
+ meta: {
155
+ title: composePageTitle(
156
+ {
157
+ pageMetaTitle: bodyMetaTitle,
158
+ templateTitle: (meta.title as string) || undefined,
159
+ websiteName: (website.name as string) || undefined,
160
+ organisationName: (brand.companyName as string) || undefined,
161
+ pageName: pageName ?? pageRootTitle,
162
+ entityTitle: entityTitle || undefined,
163
+ },
164
+ { orgPrefix },
165
+ ),
166
+ ...(pageDescription ? { description: pageDescription } : {}),
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Branch 1 of the resolved-graph → pageData conversion: arbitrary region-slots
7
+ * (portal/agents/docs) driven by resource_loader.region_slots.
8
+ *
9
+ * Split out of resolve-page-data.ts (547 lines) — byte-identical body; only
10
+ * the closure variables became explicit parameters.
11
+ */
12
+
13
+ import { resolveThemeMode } from "../resolve-theme-mode.ts";
14
+ import { composePageTitle } from "../compose-page-title.ts";
15
+ import { resolveEntityTitle } from "./meta-title.ts";
16
+ import {
17
+ applyLayoutDefaults,
18
+ EMPTY_PUCK_DATA,
19
+ extractPuckData,
20
+ pickRootProp,
21
+ readBrandTokens,
22
+ warnUnresolvedRegionSlot,
23
+ type PuckData,
24
+ type RegionEntry,
25
+ } from "./page-data-shared.ts";
26
+
27
+ export function regionSlotsPageData(
28
+ resolved: Record<string, unknown>,
29
+ meta: Record<string, unknown>,
30
+ regionSlots: string[],
31
+ themeOverride: "light" | "dark" | undefined,
32
+ orgPrefix: boolean | undefined,
33
+ ): Record<string, unknown> {
34
+ const { brand, primaryColor, headingFont, bodyFont, logo, brandTheme } =
35
+ readBrandTokens(resolved);
36
+ const website = (resolved.website as Record<string, unknown>) ?? {};
37
+ const layoutDefaults =
38
+ (website.layout_defaults as Record<string, string>) ??
39
+ (website.layout_settings as Record<string, string>) ??
40
+ {};
41
+ const layoutSettings = website.layout_settings as
42
+ | { theme?: unknown }
43
+ | null
44
+ | undefined;
45
+ const websiteTheme =
46
+ layoutSettings && typeof layoutSettings.theme === "string"
47
+ ? layoutSettings.theme
48
+ : undefined;
49
+
50
+ // Title derives from the primary resolved entity (e.g. the GPT agent),
51
+ // matching the flat-page branch — so a utility-shell GPT shows the agent
52
+ // name in <title>/OG, not the generic "Portal". Falls back to the website
53
+ // name, then "Portal". Without this, the region-slots branch returned
54
+ // early with a hardcoded "Portal" title (gpt-test: SSR/DOM/OG title FAIL).
55
+ const entityTitle = resolveEntityTitle(resolved);
56
+
57
+ const regions: RegionEntry[] = [];
58
+ for (const slot of regionSlots) {
59
+ const source = resolved[slot] as Record<string, unknown> | undefined;
60
+ if (!source) {
61
+ warnUnresolvedRegionSlot(slot);
62
+ continue;
63
+ }
64
+ const puckData = extractPuckData(source) ?? EMPTY_PUCK_DATA;
65
+ applyLayoutDefaults(puckData, layoutDefaults);
66
+ regions.push({
67
+ slot,
68
+ puckData,
69
+ pageId: (source.id as string) ?? "",
70
+ });
71
+ }
72
+
73
+ if (regions.length === 0) {
74
+ return {
75
+ puckData: { root: { props: {} }, content: [] } as PuckData,
76
+ theme: "dark" as const,
77
+ brand: {
78
+ primaryColor,
79
+ backgroundColor: (brand.backgroundColor as string | null) ?? null,
80
+ lightSurface: (brand.lightSurface as string | null) ?? null,
81
+ darkSurface: (brand.darkSurface as string | null) ?? null,
82
+ headingFont,
83
+ bodyFont,
84
+ logo,
85
+ companyName: (brand.companyName as string) ?? null,
86
+ companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
87
+ contactEmail: (brand.contactEmail as string) ?? null,
88
+ favicon: (brand.favicon as string) ?? null,
89
+ },
90
+ meta: {
91
+ title: composePageTitle(
92
+ {
93
+ templateTitle: (meta.title as string) || undefined,
94
+ websiteName: (website.name as string) || undefined,
95
+ organisationName: (brand.companyName as string) || undefined,
96
+ entityTitle: entityTitle || undefined,
97
+ },
98
+ { orgPrefix },
99
+ ),
100
+ },
101
+ };
102
+ }
103
+
104
+ const rootTheme = pickRootProp<string>(regions, "theme");
105
+ const { theme } = resolveThemeMode({
106
+ override: themeOverride,
107
+ candidates: [rootTheme, brandTheme, websiteTheme],
108
+ });
109
+
110
+ const overrideColor = pickRootProp<string>(regions, "primaryColor");
111
+ const overrideHeading = pickRootProp<string>(regions, "headingFont");
112
+ const overrideBody = pickRootProp<string>(regions, "bodyFont");
113
+ // The page the user is viewing is the main content region (center for
114
+ // portal, body for utility). Its name ("Dashboard") is the title subject.
115
+ const contentSlot = regionSlots.find((s) => s === "center" || s === "body");
116
+ const contentPageName = contentSlot
117
+ ? ((resolved[contentSlot] as Record<string, unknown> | undefined)?.name as
118
+ | string
119
+ | undefined)
120
+ : undefined;
121
+
122
+ return {
123
+ puckData: { root: { props: {} }, content: [] } as PuckData,
124
+ regions,
125
+ // Region-shell blocks (e.g. the GPT chat in the center region) read
126
+ // their resolved entities from pageData.loaderData — same surface the
127
+ // flat single-page branch exposes. Without it the chat block has no
128
+ // agent and renders "Custom GPT not found".
129
+ loaderData: resolved,
130
+ website: {
131
+ slug: website.slug as string,
132
+ name: website.name as string,
133
+ organisation_id: website.organisation_id as string | null,
134
+ // header_settings carries the DB-driven header config (e.g.
135
+ // backdropBlur) the public shell applies — PublicTemplate reads
136
+ // pageData.website.header_settings.backdropBlur. Dropping it here
137
+ // silently kills the header blur even though the DB switch is set
138
+ // (the 2026-06-17 regression). Pass it through verbatim.
139
+ header_settings:
140
+ (website.header_settings as Record<string, unknown> | null) ?? null,
141
+ },
142
+ orgId:
143
+ ((resolved.center as Record<string, unknown> | undefined)
144
+ ?.organisation_id as string | null) ??
145
+ ((resolved.agent as Record<string, unknown> | undefined)
146
+ ?.organisation_id as string | null) ??
147
+ null,
148
+ brand: {
149
+ primaryColor: overrideColor ?? primaryColor,
150
+ backgroundColor: (brand.backgroundColor as string | null) ?? null,
151
+ lightSurface: (brand.lightSurface as string | null) ?? null,
152
+ darkSurface: (brand.darkSurface as string | null) ?? null,
153
+ headingFont: overrideHeading ?? headingFont,
154
+ bodyFont: overrideBody ?? bodyFont,
155
+ logo,
156
+ companyName: (brand.companyName as string) ?? null,
157
+ companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
158
+ contactEmail: (brand.contactEmail as string) ?? null,
159
+ favicon: (brand.favicon as string) ?? null,
160
+ },
161
+ theme,
162
+ meta: {
163
+ title: composePageTitle(
164
+ {
165
+ templateTitle: (meta.title as string) || undefined,
166
+ websiteName: (website.name as string) || undefined,
167
+ organisationName: (brand.companyName as string) || undefined,
168
+ pageName: contentPageName,
169
+ entityTitle: entityTitle || undefined,
170
+ },
171
+ { orgPrefix },
172
+ ),
173
+ },
174
+ };
175
+ }
@@ -1,103 +1,20 @@
1
1
  /**
2
2
  * @system ui-service-boot
3
- * @status handwritten @indivisible-unit
3
+ * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
6
  * Converts the resolved loader-graph into the pageData shape the renderer
7
7
  * consumes — three branches: arbitrary region-slots (portal/utility), the
8
8
  * header/body/footer public shell, and the single-page fallback. Extracted
9
- * from register-registry-page-route.ts (single-purpose-file split).
9
+ * from register-registry-page-route.ts (single-purpose-file split); the three
10
+ * branches + shared helpers were split out of THIS file at 547 lines
11
+ * (region-slots-page-data.ts, public-shell-page-data.ts, single-page-data.ts,
12
+ * page-data-shared.ts).
10
13
  */
11
- import { resolveThemeMode } from "../resolve-theme-mode.ts";
12
- import { composePageTitle } from "../compose-page-title.ts";
13
- import { resolveEntityTitle } from "./meta-title.ts";
14
- import { getAppLogger } from "@teamscala/logger/app-loggers";
15
14
 
16
- /**
17
- * Local structural type for Puck page data. Avoids a type-only import of
18
- * `@puckeditor/core`, which is NOT a runtime dependency — `deps-derived-from-source`
19
- * excludes whole-statement type-only imports, so the bare specifier has no
20
- * installed package to resolve against at publish typecheck time. The shape used
21
- * here (root/content/zones) is all this module reads or constructs.
22
- */
23
- type PuckData = {
24
- root: { props?: Record<string, unknown> };
25
- content: unknown[];
26
- zones?: Record<string, unknown>;
27
- };
28
-
29
- const EMPTY_PUCK_DATA: PuckData = { root: { props: {} }, content: [] };
30
-
31
- // Returns null on malformed JSON (observed via warn log) instead of a silent
32
- // masking empty fallback — protective-layers-fail-explicitly. Callers default
33
- // to EMPTY_PUCK_DATA where an empty render is the intended degradation.
34
- function extractPuckData(source: Record<string, unknown>): PuckData | null {
35
- const raw = source.puck_data ?? source.puckData;
36
- if (!raw) return EMPTY_PUCK_DATA;
37
- if (typeof raw === "string") {
38
- try {
39
- return JSON.parse(raw) as PuckData;
40
- } catch (err) {
41
- getAppLogger().warn(
42
- "[extractPuckData] malformed puck_data JSON — rendering empty",
43
- { err },
44
- );
45
- return null;
46
- }
47
- }
48
- return raw as PuckData;
49
- }
50
-
51
- function applyLayoutDefaults(
52
- puckData: PuckData,
53
- defaults: Record<string, string>,
54
- ): void {
55
- if (!defaults || Object.keys(defaults).length === 0) return;
56
- const data = puckData as { root?: { props?: Record<string, unknown> } };
57
- if (!data.root) data.root = { props: {} };
58
- if (!data.root.props) data.root.props = {};
59
- for (const [key, value] of Object.entries(defaults)) {
60
- if (data.root.props[key] == null) data.root.props[key] = value;
61
- }
62
- }
63
-
64
- function pickRootProp<T>(regions: RegionEntry[], key: string): T | undefined {
65
- for (const region of regions) {
66
- const props = (
67
- region.puckData as { root?: { props?: Record<string, unknown> } } | null
68
- )?.root?.props;
69
- const value = props?.[key];
70
- if (value != null && value !== "") return value as T;
71
- }
72
- return undefined;
73
- }
74
-
75
- interface RegionEntry {
76
- slot: string;
77
- puckData: PuckData;
78
- pageId: string;
79
- website?: { slug: string; name: string; organisation_id?: string | null };
80
- metaTitle?: string | null;
81
- metaDescription?: string | null;
82
- }
83
-
84
- // A region slot the operator DECLARED (resource_loader.region_slots) whose
85
- // source did not resolve used to vanish with no signal anywhere — the page
86
- // rendered minus a region while the page, source, attributes, template and
87
- // tree builder all looked innocent (measured 2026-08-18: a footer region
88
- // added to a report produced no footer and no diagnostic; the cause was the
89
- // boot-time config capture named in the message). One warn per slot per
90
- // process, throttled like the block-coverage guard in the caller, so the hot
91
- // path never spams it.
92
- const unresolvedRegionSlotsLogged = new Set<string>();
93
-
94
- function warnUnresolvedRegionSlot(slot: string): void {
95
- if (unresolvedRegionSlotsLogged.has(slot)) return;
96
- unresolvedRegionSlotsLogged.add(slot);
97
- getAppLogger().warn(
98
- `[resolve-page-data] region slot "${slot}" is declared in the resource_loader config but no source resolved for it — the region is omitted from this render. A resource_loader edit reaches a booted service only after a restart.`,
99
- );
100
- }
15
+ import { regionSlotsPageData } from "./region-slots-page-data.ts";
16
+ import { publicShellPageData } from "./public-shell-page-data.ts";
17
+ import { singlePageData } from "./single-page-data.ts";
101
18
 
102
19
  export function resolvedToPageData(
103
20
  resolved: Record<string, unknown>,
@@ -113,435 +30,19 @@ export function resolvedToPageData(
113
30
  // prefixes carry the OWNING ORG's name instead of the website label.
114
31
  orgPrefix?: boolean,
115
32
  ): Record<string, unknown> {
116
- const brand = (resolved.brand as Record<string, unknown>) ?? {};
117
- const primaryColor = (brand.primaryColor as string) ?? "";
118
- const headingFont = (brand.headingFont as string) ?? null;
119
- const bodyFont = (brand.bodyFont as string) ?? null;
120
- const logo = (brand.logo as {
121
- light: string | null;
122
- dark: string | null;
123
- brand: string | null;
124
- }) ?? { light: null, dark: null, brand: null };
125
-
126
- const brandTheme = typeof brand.theme === "string" ? brand.theme : undefined;
127
-
128
33
  // Branch 1: arbitrary regions (portal/agents/docs) — region_slots from loader config
129
34
  if (regionSlots && regionSlots.length > 0) {
130
- const website = (resolved.website as Record<string, unknown>) ?? {};
131
- const layoutDefaults =
132
- (website.layout_defaults as Record<string, string>) ??
133
- (website.layout_settings as Record<string, string>) ??
134
- {};
135
- const layoutSettings = website.layout_settings as
136
- | { theme?: unknown }
137
- | null
138
- | undefined;
139
- const websiteTheme =
140
- layoutSettings && typeof layoutSettings.theme === "string"
141
- ? layoutSettings.theme
142
- : undefined;
143
-
144
- // Title derives from the primary resolved entity (e.g. the GPT agent),
145
- // matching the flat-page branch — so a utility-shell GPT shows the agent
146
- // name in <title>/OG, not the generic "Portal". Falls back to the website
147
- // name, then "Portal". Without this, the region-slots branch returned
148
- // early with a hardcoded "Portal" title (gpt-test: SSR/DOM/OG title FAIL).
149
- const entityTitle = resolveEntityTitle(resolved);
150
-
151
- const regions: RegionEntry[] = [];
152
- for (const slot of regionSlots) {
153
- const source = resolved[slot] as Record<string, unknown> | undefined;
154
- if (!source) {
155
- warnUnresolvedRegionSlot(slot);
156
- continue;
157
- }
158
- const puckData = extractPuckData(source) ?? EMPTY_PUCK_DATA;
159
- applyLayoutDefaults(puckData, layoutDefaults);
160
- regions.push({
161
- slot,
162
- puckData,
163
- pageId: (source.id as string) ?? "",
164
- });
165
- }
166
-
167
- if (regions.length === 0) {
168
- return {
169
- puckData: { root: { props: {} }, content: [] } as PuckData,
170
- theme: "dark" as const,
171
- brand: {
172
- primaryColor,
173
- backgroundColor: (brand.backgroundColor as string | null) ?? null,
174
- lightSurface: (brand.lightSurface as string | null) ?? null,
175
- darkSurface: (brand.darkSurface as string | null) ?? null,
176
- headingFont,
177
- bodyFont,
178
- logo,
179
- companyName: (brand.companyName as string) ?? null,
180
- companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
181
- contactEmail: (brand.contactEmail as string) ?? null,
182
- favicon: (brand.favicon as string) ?? null,
183
- },
184
- meta: {
185
- title: composePageTitle(
186
- {
187
- templateTitle: (meta.title as string) || undefined,
188
- websiteName: (website.name as string) || undefined,
189
- organisationName: (brand.companyName as string) || undefined,
190
- entityTitle: entityTitle || undefined,
191
- },
192
- { orgPrefix },
193
- ),
194
- },
195
- };
196
- }
197
-
198
- const rootTheme = pickRootProp<string>(regions, "theme");
199
- const { theme } = resolveThemeMode({
200
- override: themeOverride,
201
- candidates: [rootTheme, brandTheme, websiteTheme],
202
- });
203
-
204
- const overrideColor = pickRootProp<string>(regions, "primaryColor");
205
- const overrideHeading = pickRootProp<string>(regions, "headingFont");
206
- const overrideBody = pickRootProp<string>(regions, "bodyFont");
207
- // The page the user is viewing is the main content region (center for
208
- // portal, body for utility). Its name ("Dashboard") is the title subject.
209
- const contentSlot = regionSlots.find((s) => s === "center" || s === "body");
210
- const contentPageName = contentSlot
211
- ? ((resolved[contentSlot] as Record<string, unknown> | undefined)?.name as
212
- | string
213
- | undefined)
214
- : undefined;
215
-
216
- return {
217
- puckData: { root: { props: {} }, content: [] } as PuckData,
218
- regions,
219
- // Region-shell blocks (e.g. the GPT chat in the center region) read
220
- // their resolved entities from pageData.loaderData — same surface the
221
- // flat single-page branch exposes. Without it the chat block has no
222
- // agent and renders "Custom GPT not found".
223
- loaderData: resolved,
224
- website: {
225
- slug: website.slug as string,
226
- name: website.name as string,
227
- organisation_id: website.organisation_id as string | null,
228
- // header_settings carries the DB-driven header config (e.g.
229
- // backdropBlur) the public shell applies — PublicTemplate reads
230
- // pageData.website.header_settings.backdropBlur. Dropping it here
231
- // silently kills the header blur even though the DB switch is set
232
- // (the 2026-06-17 regression). Pass it through verbatim.
233
- header_settings:
234
- (website.header_settings as Record<string, unknown> | null) ?? null,
235
- },
236
- orgId:
237
- ((resolved.center as Record<string, unknown> | undefined)
238
- ?.organisation_id as string | null) ??
239
- ((resolved.agent as Record<string, unknown> | undefined)
240
- ?.organisation_id as string | null) ??
241
- null,
242
- brand: {
243
- primaryColor: overrideColor ?? primaryColor,
244
- backgroundColor: (brand.backgroundColor as string | null) ?? null,
245
- lightSurface: (brand.lightSurface as string | null) ?? null,
246
- darkSurface: (brand.darkSurface as string | null) ?? null,
247
- headingFont: overrideHeading ?? headingFont,
248
- bodyFont: overrideBody ?? bodyFont,
249
- logo,
250
- companyName: (brand.companyName as string) ?? null,
251
- companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
252
- contactEmail: (brand.contactEmail as string) ?? null,
253
- favicon: (brand.favicon as string) ?? null,
254
- },
255
- theme,
256
- meta: {
257
- title: composePageTitle(
258
- {
259
- templateTitle: (meta.title as string) || undefined,
260
- websiteName: (website.name as string) || undefined,
261
- organisationName: (brand.companyName as string) || undefined,
262
- pageName: contentPageName,
263
- entityTitle: entityTitle || undefined,
264
- },
265
- { orgPrefix },
266
- ),
267
- },
268
- };
35
+ return regionSlotsPageData(resolved, meta, regionSlots, themeOverride, orgPrefix);
269
36
  }
270
37
 
271
38
  // Branch 2: public shell (header/body/footer regions from header/footer sources)
272
- const headerSource = resolved.header as Record<string, unknown> | undefined;
273
- const footerSource = resolved.footer as Record<string, unknown> | undefined;
274
-
275
- if (headerSource || footerSource) {
276
- const page = (resolved.page as Record<string, unknown>) ?? {};
277
- const website =
278
- (resolved.website_by_host as Record<string, unknown>) ??
279
- (resolved.website as Record<string, unknown>) ??
280
- {};
281
- const layoutDefaults =
282
- (website.layout_defaults as Record<string, string>) ??
283
- (website.layout_settings as Record<string, string>) ??
284
- {};
285
- const layoutSettings = website.layout_settings as
286
- | { theme?: unknown }
287
- | null
288
- | undefined;
289
- const websiteTheme =
290
- layoutSettings && typeof layoutSettings.theme === "string"
291
- ? layoutSettings.theme
292
- : undefined;
293
-
294
- const regions: RegionEntry[] = [];
295
-
296
- if (headerSource) {
297
- const headerPuck = extractPuckData(headerSource) ?? EMPTY_PUCK_DATA;
298
- applyLayoutDefaults(headerPuck, layoutDefaults);
299
- regions.push({
300
- slot: "header",
301
- puckData: headerPuck,
302
- pageId: (headerSource.id as string) ?? "",
303
- });
304
- }
305
-
306
- const bodyPuck = extractPuckData(page) ?? EMPTY_PUCK_DATA;
307
- applyLayoutDefaults(bodyPuck, layoutDefaults);
308
- regions.push({
309
- slot: "body",
310
- puckData: bodyPuck,
311
- pageId: (page.id as string) ?? "",
312
- metaTitle: (page.meta_title as string) ?? null,
313
- metaDescription: (page.meta_description as string) ?? null,
314
- });
315
-
316
- if (footerSource) {
317
- const footerPuck = extractPuckData(footerSource) ?? EMPTY_PUCK_DATA;
318
- applyLayoutDefaults(footerPuck, layoutDefaults);
319
- regions.push({
320
- slot: "footer",
321
- puckData: footerPuck,
322
- pageId: (footerSource.id as string) ?? "",
323
- });
324
- }
325
-
326
- const rootTheme = pickRootProp<string>(regions, "theme");
327
- const { theme } = resolveThemeMode({
328
- override: themeOverride,
329
- candidates: [rootTheme, brandTheme, websiteTheme],
330
- });
331
-
332
- const overrideColor = pickRootProp<string>(regions, "primaryColor");
333
- const overrideBg = pickRootProp<string>(regions, "backgroundColor");
334
- const overrideHeading = pickRootProp<string>(regions, "headingFont");
335
- const overrideBody = pickRootProp<string>(regions, "bodyFont");
336
-
337
- const pageName = (page.name as string) || null;
338
- const entityTitle = resolveEntityTitle(resolved);
339
- const bodyMetaTitle = regions.find((r) => r.slot === "body")?.metaTitle;
340
- const pageRootTitle = pickRootProp<string>(regions, "title");
341
- const pageDescription =
342
- regions.find((r) => r.slot === "body")?.metaDescription ??
343
- pickRootProp<string>(regions, "description") ??
344
- null;
345
-
346
- return {
347
- puckData: { root: { props: {} }, content: [] } as PuckData,
348
- regions,
349
- // Region-shell blocks (e.g. the header Navigation deriving the body's
350
- // document sections) read the resolved entity graph from
351
- // pageData.loaderData — the same surface Branch 1 exposes. Without it a
352
- // header/footer block serverLoader gets ctx.loaderData=undefined and
353
- // cannot see the body page, so a server-derived nav produced no items
354
- // and the menu was client-only (vanished on stale tabs / narrow views).
355
- loaderData: resolved,
356
- website: {
357
- slug: website.slug as string,
358
- name: website.name as string,
359
- organisation_id: website.organisation_id as string | null,
360
- // header_settings carries the DB-driven header config (e.g.
361
- // backdropBlur) the public shell applies — PublicTemplate reads
362
- // pageData.website.header_settings.backdropBlur. Dropping it here
363
- // silently kills the header blur even though the DB switch is set
364
- // (the 2026-06-17 regression). Pass it through verbatim.
365
- header_settings:
366
- (website.header_settings as Record<string, unknown> | null) ?? null,
367
- },
368
- orgId:
369
- (page.organisation_id as string) ??
370
- null,
371
- brand: {
372
- primaryColor: overrideColor ?? primaryColor,
373
- // Thread the org brand's dark surface (brand_background_color /
374
- // brand_dark_color) when the page sets no explicit override — so
375
- // dark-mode pages use the brand's real dark colour, not a
376
- // primary-derived tint. Uses the org brand's real dark colour.
377
- backgroundColor:
378
- overrideBg ?? (brand.backgroundColor as string | null) ?? null,
379
- lightSurface: (brand.lightSurface as string | null) ?? null,
380
- darkSurface: (brand.darkSurface as string | null) ?? null,
381
- accentColor: (brand.accentColor as string | null) ?? null,
382
- secondaryColor: (brand.secondaryColor as string | null) ?? null,
383
- mutedColor: (brand.mutedColor as string | null) ?? null,
384
- headingFont: overrideHeading ?? headingFont,
385
- bodyFont: overrideBody ?? bodyFont,
386
- logo,
387
- companyName: (brand.companyName as string) ?? null,
388
- companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
389
- contactEmail: (brand.contactEmail as string) ?? null,
390
- favicon: (brand.favicon as string) ?? null,
391
- },
392
- theme,
393
- meta: {
394
- title: composePageTitle(
395
- {
396
- pageMetaTitle: bodyMetaTitle,
397
- templateTitle: (meta.title as string) || undefined,
398
- websiteName: (website.name as string) || undefined,
399
- organisationName: (brand.companyName as string) || undefined,
400
- pageName: pageName ?? pageRootTitle,
401
- entityTitle: entityTitle || undefined,
402
- },
403
- { orgPrefix },
404
- ),
405
- ...(pageDescription ? { description: pageDescription } : {}),
406
- },
407
- };
39
+ if (
40
+ (resolved.header as Record<string, unknown> | undefined) ||
41
+ (resolved.footer as Record<string, unknown> | undefined)
42
+ ) {
43
+ return publicShellPageData(resolved, meta, themeOverride, orgPrefix);
408
44
  }
409
45
 
410
- const page = (resolved.page as Record<string, unknown>) ?? null;
411
- const puckData = page
412
- ? extractPuckData(page) ?? EMPTY_PUCK_DATA
413
- : ({ root: { props: {} }, content: [] } as PuckData);
414
-
415
- const website = (resolved.website as Record<string, unknown>) ?? {};
416
- const layoutSettings = (website.layout_settings ??
417
- resolved.websiteDefaults) as { theme?: unknown } | null | undefined;
418
- const websiteTheme =
419
- layoutSettings && typeof layoutSettings.theme === "string"
420
- ? layoutSettings.theme
421
- : undefined;
422
-
423
- const pageRoot = (
424
- puckData as { root?: { props?: Record<string, unknown> } } | null
425
- )?.root?.props;
426
- const rootTheme =
427
- pageRoot && typeof pageRoot.theme === "string" ? pageRoot.theme : undefined;
428
- const agent = (resolved.agent as Record<string, unknown>) ?? null;
429
- // A GPT page's mode is the agent's dark_theme flag (the DB switch). It's the
430
- // top candidate so the SSR :root matches the GPT shell's own scope (both
431
- // derive from the same ai_agents.dark_theme) — flip the flag, the whole GPT
432
- // + everything it frames flips. themeOverride (?theme=) still wins for an
433
- // embedded render.
434
- const agentTheme =
435
- typeof agent?.dark_theme === "boolean"
436
- ? agent.dark_theme
437
- ? "dark"
438
- : "light"
439
- : undefined;
440
- // themeOverride (?theme=) wins so a form/quiz framed in a GPT panel matches
441
- // the host shell's light/dark scheme — this single-page branch serves
442
- // form/quiz/proposal/gpt pages.
443
- const { theme } = resolveThemeMode({
444
- override: themeOverride,
445
- candidates: [agentTheme, rootTheme, brandTheme, websiteTheme],
446
- });
447
- const orgId =
448
- (page?.organisation_id as string) ??
449
- (agent?.organisation_id as string) ??
450
- null;
451
-
452
- const overrideColor =
453
- pageRoot &&
454
- typeof pageRoot.primaryColor === "string" &&
455
- pageRoot.primaryColor
456
- ? pageRoot.primaryColor
457
- : null;
458
- const overrideHeading =
459
- pageRoot && typeof pageRoot.headingFont === "string" && pageRoot.headingFont
460
- ? pageRoot.headingFont
461
- : null;
462
- const overrideBody =
463
- pageRoot && typeof pageRoot.bodyFont === "string" && pageRoot.bodyFont
464
- ? pageRoot.bodyFont
465
- : null;
466
-
467
- const screenMode = pageRoot?.screenMode as string | undefined;
468
-
469
- const pageName = (page?.name as string) || null;
470
- const entityTitle = resolveEntityTitle(resolved);
471
- const entityDescription =
472
- ((resolved.form as Record<string, unknown>)?.description as string) ||
473
- ((resolved.quiz as Record<string, unknown>)?.description as string) ||
474
- ((resolved.proposal as Record<string, unknown>)?.description as string) ||
475
- ((resolved.agent as Record<string, unknown>)?.description as string);
476
- const pageDescription =
477
- (page?.meta_description as string) ||
478
- entityDescription ||
479
- (pageRoot?.description as string) ||
480
- null;
481
- const loaderDataPageMetaTitle = (
482
- resolved.loaderData as { page?: { meta_title?: unknown } } | undefined
483
- )?.page?.meta_title;
484
- const pageMetaTitle =
485
- (typeof loaderDataPageMetaTitle === "string" &&
486
- loaderDataPageMetaTitle.trim()
487
- ? loaderDataPageMetaTitle.trim()
488
- : typeof page?.meta_title === "string" && page.meta_title.trim()
489
- ? page.meta_title.trim()
490
- : null) || null;
491
-
492
- // Title is DERIVED via the single composer: meta_title is an explicit
493
- // per-page OVERRIDE (priority 1), a curated resource template wins for
494
- // resource surfaces (priority 2), otherwise <website>: <subject>. The
495
- // /sign-in special-case is gone — pageMetaTitle now wins uniformly, so a
496
- // sign-in page with a meta_title is honoured on every path, not just /sign-in.
497
- const mergedMeta: Record<string, unknown> = {
498
- ...(pageDescription ? { description: pageDescription } : {}),
499
- ...meta,
500
- };
501
- mergedMeta.title = composePageTitle(
502
- {
503
- pageMetaTitle,
504
- templateTitle: (meta.title as string) || undefined,
505
- websiteName: (website.name as string) || undefined,
506
- organisationName: (brand.companyName as string) || undefined,
507
- pageName: pageName ?? ((pageRoot?.title as string) || undefined),
508
- entityTitle: entityTitle || undefined,
509
- },
510
- { orgPrefix },
511
- );
512
-
513
- return {
514
- puckData,
515
- website: {
516
- slug: website.slug as string,
517
- name: website.name as string,
518
- organisation_id: website.organisation_id as string | null,
519
- // Pass header_settings through (DB-driven header blur etc.) — see the
520
- // other two website assignments above. Dropping it kills the blur.
521
- header_settings:
522
- (website.header_settings as Record<string, unknown> | null) ?? null,
523
- },
524
- brand: {
525
- primaryColor: overrideColor ?? primaryColor,
526
- backgroundColor: (brand.backgroundColor as string | null) ?? null,
527
- lightSurface: (brand.lightSurface as string | null) ?? null,
528
- darkSurface: (brand.darkSurface as string | null) ?? null,
529
- headingFont: overrideHeading ?? headingFont,
530
- bodyFont: overrideBody ?? bodyFont,
531
- logo,
532
- companyName: (brand.companyName as string) ?? null,
533
- companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
534
- contactEmail: (brand.contactEmail as string) ?? null,
535
- favicon: (brand.favicon as string) ?? null,
536
- },
537
- theme,
538
- orgId,
539
- loaderData: resolved,
540
- ...(screenMode ? { fullScreen: screenMode === "full-screen" } : {}),
541
- ...(pageRoot?.standalone != null
542
- ? { standalone: pageRoot.standalone === false ? false : undefined }
543
- : {}),
544
- ...(providers ? { providerKeys: providers } : {}),
545
- meta: mergedMeta,
546
- };
46
+ // Branch 3: single-page fallback.
47
+ return singlePageData(resolved, meta, providers, themeOverride, orgPrefix);
547
48
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Branch 3 of the resolved-graph → pageData conversion: the single-page
7
+ * fallback (form/quiz/proposal/gpt pages with no region slots and no
8
+ * header/footer sources).
9
+ *
10
+ * Split out of resolve-page-data.ts (547 lines) — byte-identical body; only
11
+ * the closure variables became explicit parameters.
12
+ */
13
+
14
+ import { resolveThemeMode } from "../resolve-theme-mode.ts";
15
+ import { composePageTitle } from "../compose-page-title.ts";
16
+ import { resolveEntityTitle } from "./meta-title.ts";
17
+ import {
18
+ EMPTY_PUCK_DATA,
19
+ extractPuckData,
20
+ readBrandTokens,
21
+ type PuckData,
22
+ } from "./page-data-shared.ts";
23
+
24
+ export function singlePageData(
25
+ resolved: Record<string, unknown>,
26
+ meta: Record<string, unknown>,
27
+ providers: string[] | undefined,
28
+ themeOverride: "light" | "dark" | undefined,
29
+ orgPrefix: boolean | undefined,
30
+ ): Record<string, unknown> {
31
+ const { brand, primaryColor, headingFont, bodyFont, logo, brandTheme } =
32
+ readBrandTokens(resolved);
33
+
34
+ const page = (resolved.page as Record<string, unknown>) ?? null;
35
+ const puckData = page
36
+ ? extractPuckData(page) ?? EMPTY_PUCK_DATA
37
+ : ({ root: { props: {} }, content: [] } as PuckData);
38
+
39
+ const website = (resolved.website as Record<string, unknown>) ?? {};
40
+ const layoutSettings = (website.layout_settings ??
41
+ resolved.websiteDefaults) as { theme?: unknown } | null | undefined;
42
+ const websiteTheme =
43
+ layoutSettings && typeof layoutSettings.theme === "string"
44
+ ? layoutSettings.theme
45
+ : undefined;
46
+
47
+ const pageRoot = (
48
+ puckData as { root?: { props?: Record<string, unknown> } } | null
49
+ )?.root?.props;
50
+ const rootTheme =
51
+ pageRoot && typeof pageRoot.theme === "string" ? pageRoot.theme : undefined;
52
+ const agent = (resolved.agent as Record<string, unknown>) ?? null;
53
+ // A GPT page's mode is the agent's dark_theme flag (the DB switch). It's the
54
+ // top candidate so the SSR :root matches the GPT shell's own scope (both
55
+ // derive from the same ai_agents.dark_theme) — flip the flag, the whole GPT
56
+ // + everything it frames flips. themeOverride (?theme=) still wins for an
57
+ // embedded render.
58
+ const agentTheme =
59
+ typeof agent?.dark_theme === "boolean"
60
+ ? agent.dark_theme
61
+ ? "dark"
62
+ : "light"
63
+ : undefined;
64
+ // themeOverride (?theme=) wins so a form/quiz framed in a GPT panel matches
65
+ // the host shell's light/dark scheme — this single-page branch serves
66
+ // form/quiz/proposal/gpt pages.
67
+ const { theme } = resolveThemeMode({
68
+ override: themeOverride,
69
+ candidates: [agentTheme, rootTheme, brandTheme, websiteTheme],
70
+ });
71
+ const orgId =
72
+ (page?.organisation_id as string) ??
73
+ (agent?.organisation_id as string) ??
74
+ null;
75
+
76
+ const overrideColor =
77
+ pageRoot &&
78
+ typeof pageRoot.primaryColor === "string" &&
79
+ pageRoot.primaryColor
80
+ ? pageRoot.primaryColor
81
+ : null;
82
+ const overrideHeading =
83
+ pageRoot && typeof pageRoot.headingFont === "string" && pageRoot.headingFont
84
+ ? pageRoot.headingFont
85
+ : null;
86
+ const overrideBody =
87
+ pageRoot && typeof pageRoot.bodyFont === "string" && pageRoot.bodyFont
88
+ ? pageRoot.bodyFont
89
+ : null;
90
+
91
+ const screenMode = pageRoot?.screenMode as string | undefined;
92
+
93
+ const pageName = (page?.name as string) || null;
94
+ const entityTitle = resolveEntityTitle(resolved);
95
+ const entityDescription =
96
+ ((resolved.form as Record<string, unknown>)?.description as string) ||
97
+ ((resolved.quiz as Record<string, unknown>)?.description as string) ||
98
+ ((resolved.proposal as Record<string, unknown>)?.description as string) ||
99
+ ((resolved.agent as Record<string, unknown>)?.description as string);
100
+ const pageDescription =
101
+ (page?.meta_description as string) ||
102
+ entityDescription ||
103
+ (pageRoot?.description as string) ||
104
+ null;
105
+ const loaderDataPageMetaTitle = (
106
+ resolved.loaderData as { page?: { meta_title?: unknown } } | undefined
107
+ )?.page?.meta_title;
108
+ const pageMetaTitle =
109
+ (typeof loaderDataPageMetaTitle === "string" &&
110
+ loaderDataPageMetaTitle.trim()
111
+ ? loaderDataPageMetaTitle.trim()
112
+ : typeof page?.meta_title === "string" && page.meta_title.trim()
113
+ ? page.meta_title.trim()
114
+ : null) || null;
115
+
116
+ // Title is DERIVED via the single composer: meta_title is an explicit
117
+ // per-page OVERRIDE (priority 1), a curated resource template wins for
118
+ // resource surfaces (priority 2), otherwise <website>: <subject>. The
119
+ // /sign-in special-case is gone — pageMetaTitle now wins uniformly, so a
120
+ // sign-in page with a meta_title is honoured on every path, not just /sign-in.
121
+ const mergedMeta: Record<string, unknown> = {
122
+ ...(pageDescription ? { description: pageDescription } : {}),
123
+ ...meta,
124
+ };
125
+ mergedMeta.title = composePageTitle(
126
+ {
127
+ pageMetaTitle,
128
+ templateTitle: (meta.title as string) || undefined,
129
+ websiteName: (website.name as string) || undefined,
130
+ organisationName: (brand.companyName as string) || undefined,
131
+ pageName: pageName ?? ((pageRoot?.title as string) || undefined),
132
+ entityTitle: entityTitle || undefined,
133
+ },
134
+ { orgPrefix },
135
+ );
136
+
137
+ return {
138
+ puckData,
139
+ website: {
140
+ slug: website.slug as string,
141
+ name: website.name as string,
142
+ organisation_id: website.organisation_id as string | null,
143
+ // Pass header_settings through (DB-driven header blur etc.) — see the
144
+ // other two website assignments above. Dropping it kills the blur.
145
+ header_settings:
146
+ (website.header_settings as Record<string, unknown> | null) ?? null,
147
+ },
148
+ brand: {
149
+ primaryColor: overrideColor ?? primaryColor,
150
+ backgroundColor: (brand.backgroundColor as string | null) ?? null,
151
+ lightSurface: (brand.lightSurface as string | null) ?? null,
152
+ darkSurface: (brand.darkSurface as string | null) ?? null,
153
+ headingFont: overrideHeading ?? headingFont,
154
+ bodyFont: overrideBody ?? bodyFont,
155
+ logo,
156
+ companyName: (brand.companyName as string) ?? null,
157
+ companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
158
+ contactEmail: (brand.contactEmail as string) ?? null,
159
+ favicon: (brand.favicon as string) ?? null,
160
+ },
161
+ theme,
162
+ orgId,
163
+ loaderData: resolved,
164
+ ...(screenMode ? { fullScreen: screenMode === "full-screen" } : {}),
165
+ ...(pageRoot?.standalone != null
166
+ ? { standalone: pageRoot.standalone === false ? false : undefined }
167
+ : {}),
168
+ ...(providers ? { providerKeys: providers } : {}),
169
+ meta: mergedMeta,
170
+ };
171
+ }