@akinon/pz-theme 2.0.130-rc.0 → 2.0.131-beta.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.
package/CHANGELOG.md CHANGED
@@ -1,40 +1,23 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.130-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d458]
35
- - Updated dependencies [dca12811]
36
- - @akinon/next@2.0.130-rc.0
37
- - @akinon/theme-contract@2.0.130-rc.0
3
+ ## 2.0.131-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.131-beta.0
9
+ - @akinon/theme-contract@2.0.131-beta.0
10
+
11
+ ## 2.0.130
12
+
13
+ ### Patch Changes
14
+
15
+ - ab5e529: ZERO-5152: Render the breakpoints a designer adds next to desktop, tablet and mobile
16
+
17
+ The theme editor saves the breakpoints a designer defines (`theme_breakpoints` on the `theme-config` widget), each one a name and a pixel range, and stores the values authored for it under its own key in every per-breakpoint object. The renderer read none of that, so a value set for such a breakpoint showed in the canvas at best and never on the live site. The theme config reader now parses the list, the placeholder passes it to the stylesheet generator, and the generator writes the values a custom breakpoint sets inside its own media query, after the built-in ones so it wins inside its range. Anything a custom breakpoint does not set falls back to the built-in breakpoint its range starts in, in the stylesheet, in the canvas and in the value readers alike, and a visibility list saved before the breakpoint existed answers for it the way it answers for that built-in breakpoint. On the live site the wrapper reports the custom breakpoint whose range holds the viewport, so property values resolve the same way. A theme without custom breakpoints emits exactly the stylesheet it emitted before.
18
+
19
+ - @akinon/next@2.0.130
20
+ - @akinon/theme-contract@2.0.130
38
21
 
39
22
  ## 2.0.129
40
23
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.130-rc.0",
4
+ "version": "2.0.131-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.130-rc.0",
11
+ "@akinon/next": "2.0.131-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.130-rc.0",
16
+ "@akinon/theme-contract": "2.0.131-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.130-rc.0",
24
+ "@akinon/next": "2.0.131-beta.0",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -25,7 +25,8 @@ describe('readThemeConfig', () => {
25
25
  ).toEqual({
26
26
  settings: { mainMenuSticky: true },
27
27
  dataSources: [{ id: 'ds-1' }],
28
- modals: []
28
+ modals: [],
29
+ breakpoints: []
29
30
  });
30
31
  });
31
32
 
@@ -40,7 +41,8 @@ describe('readThemeConfig', () => {
40
41
  ).toEqual({
41
42
  settings: { mainMenuSticky: true },
42
43
  dataSources: [{ id: 'ds-1' }],
43
- modals: []
44
+ modals: [],
45
+ breakpoints: []
44
46
  });
45
47
  });
46
48
 
@@ -55,7 +57,8 @@ describe('readThemeConfig', () => {
55
57
  ).toEqual({
56
58
  settings: { mainMenuSticky: false },
57
59
  dataSources: [{ id: 'ds-1' }],
58
- modals: []
60
+ modals: [],
61
+ breakpoints: []
59
62
  });
60
63
  });
61
64
 
@@ -99,7 +102,8 @@ describe('readThemeConfig', () => {
99
102
  autoTrigger: undefined,
100
103
  visibilityRules: undefined
101
104
  }
102
- ]
105
+ ],
106
+ breakpoints: []
103
107
  });
104
108
  });
105
109
 
@@ -113,7 +117,65 @@ describe('readThemeConfig', () => {
113
117
  theme_dataSources: JSON.stringify([{ id: 'ds-1' }])
114
118
  }
115
119
  })
116
- ).toEqual({ settings: null, dataSources: [{ id: 'ds-1' }], modals: [] });
120
+ ).toEqual({
121
+ settings: null,
122
+ dataSources: [{ id: 'ds-1' }],
123
+ modals: [],
124
+ breakpoints: []
125
+ });
126
+
127
+ error.mockRestore();
128
+ });
129
+
130
+ it('reads the custom breakpoints the editor saves next to the settings', () => {
131
+ // The built-in names only ever set a canvas width in the editor, and an
132
+ // entry the reader cannot make sense of is dropped on its own.
133
+ expect(
134
+ readThemeConfig({
135
+ attributes: {
136
+ theme_breakpoints: {
137
+ value: JSON.stringify([
138
+ { name: 'desktop', width: 1024 },
139
+ { name: 'mobile', width: 390 },
140
+ {
141
+ name: 'custom-wide',
142
+ label: 'Wide desktop',
143
+ width: 1440,
144
+ minWidth: 1440
145
+ },
146
+ {
147
+ name: 'custom-small',
148
+ label: 'Small phone',
149
+ width: 374,
150
+ maxWidth: 374
151
+ },
152
+ { name: 'custom-broken', label: 'Broken', minWidth: 900, maxWidth: 600 }
153
+ ])
154
+ }
155
+ }
156
+ }).breakpoints
157
+ ).toEqual([
158
+ { name: 'custom-wide', label: 'Wide desktop', width: 1440, minWidth: 1440 },
159
+ { name: 'custom-small', label: 'Small phone', width: 374, maxWidth: 374 }
160
+ ]);
161
+ });
162
+
163
+ it('keeps the rest of the config when the breakpoints are malformed', () => {
164
+ const error = jest.spyOn(console, 'error').mockImplementation(() => undefined);
165
+
166
+ expect(
167
+ readThemeConfig({
168
+ attributes: {
169
+ theme_breakpoints: '[{not json',
170
+ theme_settings: JSON.stringify({ mainMenuSticky: true })
171
+ }
172
+ })
173
+ ).toEqual({
174
+ settings: { mainMenuSticky: true },
175
+ dataSources: [],
176
+ modals: [],
177
+ breakpoints: []
178
+ });
117
179
 
118
180
  error.mockRestore();
119
181
  });
@@ -122,12 +184,14 @@ describe('readThemeConfig', () => {
122
184
  expect(readThemeConfig(null)).toEqual({
123
185
  settings: null,
124
186
  dataSources: [],
125
- modals: []
187
+ modals: [],
188
+ breakpoints: []
126
189
  });
127
190
  expect(readThemeConfig({ attributes: {} })).toEqual({
128
191
  settings: null,
129
192
  dataSources: [],
130
- modals: []
193
+ modals: [],
194
+ breakpoints: []
131
195
  });
132
196
  });
133
197
  });
@@ -143,7 +207,8 @@ describe('getThemeConfig', () => {
143
207
  await expect(getThemeConfig()).resolves.toEqual({
144
208
  settings: null,
145
209
  dataSources: [],
146
- modals: []
210
+ modals: [],
211
+ breakpoints: []
147
212
  });
148
213
  });
149
214
  });
@@ -30,7 +30,11 @@ export async function loadErrorComposition(): Promise<ErrorComposition | null> {
30
30
  slug,
31
31
  placeholderId: loaded.placeholderId,
32
32
  sections: loaded.sections,
33
- themeCSS: generateThemeCSS(loaded.sections),
33
+ themeCSS: generateThemeCSS(
34
+ loaded.sections,
35
+ undefined,
36
+ loaded.customBreakpoints
37
+ ),
34
38
  themeSettings: loaded.themeSettings,
35
39
  pageContext: buildErrorPageContext()
36
40
  };
@@ -11,6 +11,8 @@ export interface PreviewSnapshot {
11
11
  savedAt?: string;
12
12
  themeSettings?: Record<string, unknown> | null;
13
13
  dataSources?: unknown[];
14
+ /** `theme.breakpoints` as the working copy was saved with it. */
15
+ breakpoints?: unknown[];
14
16
  sections: unknown[];
15
17
  }
16
18
 
@@ -2,6 +2,10 @@ import * as React from 'react';
2
2
  import { getWidgetDataWithRetry } from './retrying-widget-data';
3
3
 
4
4
  import { readThemeModals, ThemeModal } from '../overlays/modal-registry';
5
+ import {
6
+ type CustomBreakpoint,
7
+ readCustomBreakpoints
8
+ } from '../utils/breakpoints';
5
9
 
6
10
  /**
7
11
  * The `theme-config` widget, read and parsed once per request.
@@ -23,6 +27,12 @@ export interface ThemeConfig {
23
27
  * every page already fetches once.
24
28
  */
25
29
  modals: ThemeModal[];
30
+ /**
31
+ * The breakpoints the designer added next to desktop, tablet and mobile.
32
+ * The editor has always saved `theme_breakpoints` on this widget; the live
33
+ * stylesheet needs them to emit the rules authored for them.
34
+ */
35
+ breakpoints: CustomBreakpoint[];
26
36
  }
27
37
 
28
38
  type ThemeConfigWidget = {
@@ -42,11 +52,30 @@ const unwrapAttribute = (raw: unknown): unknown => {
42
52
  };
43
53
 
44
54
  export const readThemeConfig = (data: ThemeConfigWidget): ThemeConfig => {
45
- const config: ThemeConfig = { settings: null, dataSources: [], modals: [] };
55
+ const config: ThemeConfig = {
56
+ settings: null,
57
+ dataSources: [],
58
+ modals: [],
59
+ breakpoints: []
60
+ };
46
61
  const attributes = data?.attributes;
47
62
 
48
63
  if (!attributes) return config;
49
64
 
65
+ if (attributes.theme_breakpoints) {
66
+ try {
67
+ const resolved = unwrapAttribute(attributes.theme_breakpoints);
68
+
69
+ // `readCustomBreakpoints` drops a bad entry on its own, so a malformed
70
+ // one never costs the others.
71
+ config.breakpoints = readCustomBreakpoints(
72
+ typeof resolved === 'string' ? JSON.parse(resolved) : resolved
73
+ );
74
+ } catch (error) {
75
+ console.error('Failed to parse theme_breakpoints:', error);
76
+ }
77
+ }
78
+
50
79
  if (attributes.theme_modals) {
51
80
  // `readThemeModals` already survives a malformed blob one entry at a time,
52
81
  // so it needs no try/catch of its own here.
@@ -95,7 +124,7 @@ const load = async (): Promise<ThemeConfig> => {
95
124
  return readThemeConfig(data as ThemeConfigWidget);
96
125
  } catch {
97
126
  // A brand with no published theme still renders: no settings, no sources.
98
- return { settings: null, dataSources: [], modals: [] };
127
+ return { settings: null, dataSources: [], modals: [], breakpoints: [] };
99
128
  }
100
129
  };
101
130
 
@@ -13,6 +13,7 @@ import {
13
13
  PreviewSnapshot
14
14
  } from './chrome/preview-snapshot-source';
15
15
  import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
16
+ import { readCustomBreakpoints } from './utils/breakpoints';
16
17
  // One acceptance rule for this loader and the designer canvas's client fetch
17
18
  // (utils/live-rows.ts), so the two cannot take different answers.
18
19
  import { carriesProducts } from './utils/live-rows';
@@ -226,7 +227,12 @@ async function loadFromPreviewSnapshot(
226
227
  placeholderId: slug,
227
228
  dataSources,
228
229
  themeSettings:
229
- (snapshot.themeSettings as Record<string, unknown> | null) ?? null
230
+ (snapshot.themeSettings as Record<string, unknown> | null) ?? null,
231
+ // The working copy's own list; a blob written before it carried one
232
+ // renders with the live theme's.
233
+ customBreakpoints: Array.isArray(snapshot.breakpoints)
234
+ ? readCustomBreakpoints(snapshot.breakpoints)
235
+ : (await getThemeConfig()).breakpoints
230
236
  };
231
237
  }
232
238
 
@@ -235,6 +241,7 @@ export interface LoadedPlaceholder {
235
241
  placeholderId: string;
236
242
  dataSources: ThemeConfig['dataSources'];
237
243
  themeSettings: ThemeConfig['settings'];
244
+ customBreakpoints: ThemeConfig['breakpoints'];
238
245
  }
239
246
 
240
247
  /**
@@ -262,7 +269,11 @@ export async function loadPlaceholderSections(
262
269
  // theme-config is optional for placeholder rendering, and the reader is
263
270
  // memoized per request — a page with several placeholders, or a ChromeSlot
264
271
  // asking the same question to decide whether it is pinned, reads it once.
265
- const { dataSources, settings: themeSettings } = await getThemeConfig();
272
+ const {
273
+ dataSources,
274
+ settings: themeSettings,
275
+ breakpoints: customBreakpoints
276
+ } = await getThemeConfig();
266
277
 
267
278
  let data: {
268
279
  slug?: string;
@@ -493,7 +504,8 @@ export async function loadPlaceholderSections(
493
504
  sections,
494
505
  placeholderId: data?.slug?.toString() || slug,
495
506
  dataSources,
496
- themeSettings
507
+ themeSettings,
508
+ customBreakpoints
497
509
  };
498
510
  } catch (error) {
499
511
  console.error(`Error fetching theme placeholder data for ${slug}:`, error);
@@ -3,7 +3,7 @@
3
3
  import { useEffect, useState } from 'react';
4
4
 
5
5
  import { parseEditorMessage } from '../editor-bridge';
6
- import { breakpointForWidth } from '../utils/breakpoints';
6
+ import { breakpointForWidth, builtInBreakpointOf } from '../utils/breakpoints';
7
7
 
8
8
  /**
9
9
  * Which breakpoint the surrounding page is at.
@@ -39,7 +39,9 @@ export const useOverlayBreakpoint = (): string => {
39
39
  if (message?.type !== 'CHANGE_BREAKPOINT') return;
40
40
 
41
41
  const next = (message.data as { breakpoint?: string })?.breakpoint;
42
- if (next) setBreakpoint(next);
42
+ // Modal chrome knows the three built-in breakpoints only; a custom one
43
+ // shows the chrome of the band it stands on, as its range does live.
44
+ if (next) setBreakpoint(builtInBreakpointOf(next));
43
45
  };
44
46
 
45
47
  window.addEventListener('message', handleMessage);
@@ -19,7 +19,12 @@ import { ThemeSettingsProvider } from './theme-settings-context';
19
19
  import { MenuSeedProvider } from './menu-seed-context';
20
20
  import type { MenuSeeds } from './blocks/menu-query-args';
21
21
  import { generateThemeCSS, readThemeTypographyFont } from './utils';
22
- import { breakpointForWidth } from './utils/breakpoints';
22
+ import {
23
+ type CustomBreakpoint,
24
+ breakpointForViewport,
25
+ readCustomBreakpoints,
26
+ registerCustomBreakpoints
27
+ } from './utils/breakpoints';
23
28
  import { PartStatePreview } from './components/part-state-preview';
24
29
  import { useVisibilityContext } from './hooks/use-visibility-context';
25
30
  import { createPageContextDiscovery } from './utils/page-context-discovery';
@@ -222,6 +227,12 @@ interface ThemePlaceholderWrapperProps {
222
227
  isDesignMode: boolean;
223
228
  dataSources?: any[];
224
229
  initialThemeSettings?: Record<string, unknown> | null;
230
+ /**
231
+ * The theme's custom breakpoints (`theme_breakpoints`), read with the theme
232
+ * settings. Absent for a caller that has none to give; the editor canvas
233
+ * gets them with the theme it sends.
234
+ */
235
+ initialCustomBreakpoints?: CustomBreakpoint[] | null;
225
236
  initialPageContext?: Record<string, unknown> | null;
226
237
  /**
227
238
  * Menus the server fetched for this placeholder's nav-menu blocks
@@ -366,6 +377,7 @@ export default function ThemePlaceholderWrapper({
366
377
  isDesignMode,
367
378
  dataSources: initialDataSources = [],
368
379
  initialThemeSettings = null,
380
+ initialCustomBreakpoints = null,
369
381
  initialPageContext = null,
370
382
  initialMenuSeeds = null
371
383
  }: ThemePlaceholderWrapperProps) {
@@ -378,6 +390,17 @@ export default function ThemePlaceholderWrapper({
378
390
  );
379
391
  const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
380
392
  const [currentBreakpoint, setCurrentBreakpoint] = useState<string>('desktop');
393
+ // Registered for the readers that only see a value and a breakpoint name
394
+ // (see utils/breakpoints). Only when this caller has a list to give: a
395
+ // wrapper without one must not clear what another placeholder registered.
396
+ const [customBreakpoints, setCustomBreakpoints] = useState<
397
+ CustomBreakpoint[]
398
+ >(() => {
399
+ if (initialCustomBreakpoints) {
400
+ registerCustomBreakpoints(initialCustomBreakpoints);
401
+ }
402
+ return initialCustomBreakpoints ?? [];
403
+ });
381
404
  // Designer flag survives remounts via the window global; the editor sends
382
405
  // SET_THEME_EDITOR_COOKIE only once per document (IFRAME_READY guard).
383
406
  const [isDesigner, setIsDesigner] = useState(
@@ -728,8 +751,9 @@ export default function ThemePlaceholderWrapper({
728
751
  const width = window.innerWidth;
729
752
  // The cutoffs live in utils/breakpoints, alongside the media queries
730
753
  // the generated stylesheet is built from, so the viewport this reports
731
- // and the query that is matching it can never drift apart.
732
- const newBreakpoint = breakpointForWidth(width);
754
+ // and the query that is matching it can never drift apart. A custom
755
+ // breakpoint whose range holds the width is the one being read.
756
+ const newBreakpoint = breakpointForViewport(width, customBreakpoints);
733
757
 
734
758
  setCurrentBreakpoint((prev) => {
735
759
  if (prev !== newBreakpoint) {
@@ -754,7 +778,7 @@ export default function ThemePlaceholderWrapper({
754
778
  clearTimeout(timeoutId);
755
779
  };
756
780
  }
757
- }, []);
781
+ }, [customBreakpoints]);
758
782
 
759
783
  // Re-hangs the editor's data-source records over the sections, and again
760
784
  // whenever live rows arrive (`liveRowsRevision`). Waits for the editor's
@@ -865,6 +889,18 @@ export default function ThemePlaceholderWrapper({
865
889
  case 'UPDATE_THEME': {
866
890
  const theme = messageData?.theme;
867
891
 
892
+ // Before any state is set, so the render this message causes already
893
+ // resolves custom breakpoints against the list it carries.
894
+ if (Array.isArray(theme?.breakpoints)) {
895
+ const incoming = readCustomBreakpoints(theme.breakpoints);
896
+ registerCustomBreakpoints(incoming);
897
+ setCustomBreakpoints((previous) =>
898
+ JSON.stringify(previous) === JSON.stringify(incoming)
899
+ ? previous
900
+ : incoming
901
+ );
902
+ }
903
+
868
904
  // Inject theme colors as CSS custom properties
869
905
  if (theme?.settings) {
870
906
  injectThemeColors(theme.settings);
@@ -1032,8 +1068,12 @@ export default function ThemePlaceholderWrapper({
1032
1068
  // mobile-width frame — a layout jump on every toggle.
1033
1069
  const dynamicCSS = useMemo(
1034
1070
  () =>
1035
- generateThemeCSS(sections, isDesigner ? currentBreakpoint : undefined),
1036
- [sections, isDesigner, currentBreakpoint]
1071
+ generateThemeCSS(
1072
+ sections,
1073
+ isDesigner ? currentBreakpoint : undefined,
1074
+ customBreakpoints
1075
+ ),
1076
+ [sections, isDesigner, currentBreakpoint, customBreakpoints]
1037
1077
  );
1038
1078
  const themeBackgroundCSS = useMemo(
1039
1079
  () =>
@@ -1059,7 +1099,9 @@ export default function ThemePlaceholderWrapper({
1059
1099
  applyVisibilityRulesToSections(sections, visibilityContext),
1060
1100
  currentBreakpoint
1061
1101
  ),
1062
- [sections, visibilityContext, currentBreakpoint]
1102
+ // `customBreakpoints` too: the chain a custom breakpoint reads through is
1103
+ // the registered list, which changes without the breakpoint changing.
1104
+ [sections, visibilityContext, currentBreakpoint, customBreakpoints]
1063
1105
  );
1064
1106
 
1065
1107
  return (
@@ -22,8 +22,14 @@ export default async function ThemePlaceholder({
22
22
  return null;
23
23
  }
24
24
 
25
- const { sections, placeholderId, dataSources, themeSettings } = loaded;
26
- const themeCSS = generateThemeCSS(sections);
25
+ const {
26
+ sections,
27
+ placeholderId,
28
+ dataSources,
29
+ themeSettings,
30
+ customBreakpoints
31
+ } = loaded;
32
+ const themeCSS = generateThemeCSS(sections, undefined, customBreakpoints);
27
33
  // The live and the preview-snapshot loads both arrive here, so both get the
28
34
  // menus their nav-menu blocks would otherwise fetch after hydration.
29
35
  const menuSeeds = await loadMenuSeeds(sections);
@@ -40,6 +46,7 @@ export default async function ThemePlaceholder({
40
46
  isDesignMode={false}
41
47
  dataSources={dataSources}
42
48
  initialThemeSettings={themeSettings}
49
+ initialCustomBreakpoints={customBreakpoints}
43
50
  initialPageContext={pageContext}
44
51
  initialMenuSeeds={menuSeeds}
45
52
  />