@akinon/pz-theme 2.0.75-rc.0 → 2.0.76-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.
@@ -0,0 +1,33 @@
1
+ import { getWidgetData } from '@akinon/next/data/server';
2
+
3
+ const RETRY_DELAYS_MS = [100, 250];
4
+
5
+ /**
6
+ * The data layer swallows transport errors and resolves with nothing — and a
7
+ * widget that truly does not exist answers with an empty 404 body, which the
8
+ * layer's JSON parse turns into the same nothing. The two cannot be told
9
+ * apart here, so a falsy answer is retried on a short backoff: a flaky
10
+ * socket recovers within a beat and a published composition stops flickering
11
+ * back to the brand markup, while a genuinely missing widget only costs two
12
+ * quick extra reads before the caller keeps the project's own chrome.
13
+ *
14
+ * Only for the low-volume chrome reads (placeholder widgets, theme-config).
15
+ * Per-section data fetches must not use this: a regular-widget miss is the
16
+ * NORMAL path for a collection section, and retrying it would tax every
17
+ * composed page.
18
+ */
19
+ export const getWidgetDataWithRetry = async <T>(
20
+ slug: string
21
+ ): Promise<T | null> => {
22
+ for (let attempt = 0; ; attempt++) {
23
+ const lastAttempt = attempt >= RETRY_DELAYS_MS.length;
24
+ try {
25
+ const data = await getWidgetData<T>({ slug });
26
+ if (data) return data as T;
27
+ } catch (error) {
28
+ if (lastAttempt) throw error;
29
+ }
30
+ if (lastAttempt) return null;
31
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS_MS[attempt]));
32
+ }
33
+ };
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { getWidgetData } from '@akinon/next/data/server';
2
+ import { getWidgetDataWithRetry } from './retrying-widget-data';
3
3
 
4
4
  import { readThemeModals, ThemeModal } from '../overlays/modal-registry';
5
5
 
@@ -86,12 +86,11 @@ export const readThemeConfig = (data: ThemeConfigWidget): ThemeConfig => {
86
86
 
87
87
  const load = async (): Promise<ThemeConfig> => {
88
88
  try {
89
- // The generic is the widget's attributes bag, not the widget wrapper
90
- const data = await getWidgetData<{
91
- theme_settings?: unknown;
92
- theme_dataSources?: unknown;
93
- theme_modals?: unknown;
94
- }>({ slug: 'theme-config' });
89
+ // Retried on a short backoff: a transient miss here silently drops every
90
+ // data source and theme setting on the page.
91
+ const data = await getWidgetDataWithRetry<ThemeConfigWidget>(
92
+ 'theme-config'
93
+ );
95
94
 
96
95
  return readThemeConfig(data as ThemeConfigWidget);
97
96
  } catch {
@@ -7,6 +7,7 @@ import { ServerVariables } from '@akinon/next/utils/server-variables';
7
7
  import { Section } from './theme-section';
8
8
  import { devWarn } from './utils/dev-warn';
9
9
  import { validateThemeSectionsInDev } from './schemas/theme-schema';
10
+ import { getPlaceholderWidget } from './chrome/composition-source';
10
11
  import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
11
12
 
12
13
  const { getWidgetData } = serverData;
@@ -57,13 +58,6 @@ const getWidgetSchemaData = async <T,>(args: {
57
58
  return directFetch<T>(`/widgets/${args.widgetSlug}/schema/`);
58
59
  };
59
60
 
60
- type ThemePlaceholderData = {
61
- theme_editor_placeholder: Array<{
62
- section_slug: string;
63
- order: number;
64
- }>;
65
- };
66
-
67
61
  export interface LoadedPlaceholder {
68
62
  sections: Section[];
69
63
  placeholderId: string;
@@ -94,9 +88,9 @@ export async function loadPlaceholderSections(
94
88
 
95
89
  let data: { slug?: string; attributes?: { theme_editor_placeholder?: unknown; [key: string]: unknown } } | null = null;
96
90
  try {
97
- data = await getWidgetData<ThemePlaceholderData>({
98
- slug
99
- }) as typeof data;
91
+ // Shared with ChromeSlot's composed/uncomposed decision — see
92
+ // getPlaceholderWidget for why the two must read one fetch.
93
+ data = (await getPlaceholderWidget(slug)) as typeof data;
100
94
  } catch (placeholderError) {
101
95
  console.warn(
102
96
  `Theme placeholder widget "${slug}" was not found. Rendering empty placeholder.`,
@@ -0,0 +1,29 @@
1
+ import { colorToRgba } from '../index';
2
+
3
+ /**
4
+ * The theme CSS generator routes every background-color through
5
+ * `colorToRgba(value, backgroundAlpha)` with backgroundAlpha defaulting to 1.
6
+ * A colour the author already gave an alpha must keep it — rebuilding
7
+ * rgba(0,0,0,0.07) used to print rgba(0, 0, 0, 1) and turn a 7% tint into
8
+ * solid black.
9
+ */
10
+ describe('authored rgba alpha survives the background pass', () => {
11
+ it('keeps a 7% tint at the default background opacity', () => {
12
+ expect(colorToRgba('rgba(0, 0, 0, 0.07)', 1)).toBe('rgba(0, 0, 0, 0.07)');
13
+ expect(colorToRgba('rgb(0 0 0 / 7%)', 1)).toBe('rgba(0, 0, 0, 0.07)');
14
+ });
15
+
16
+ it('multiplies an authored alpha with a background-opacity below 100', () => {
17
+ expect(colorToRgba('rgba(0, 0, 0, 0.5)', 0.5)).toBe('rgba(0, 0, 0, 0.25)');
18
+ });
19
+
20
+ it('keeps the alpha channel of an 8-digit hex', () => {
21
+ expect(colorToRgba('#00000012', 1)).toBe('rgba(0, 0, 0, 0.0706)');
22
+ expect(colorToRgba('#0003', 1)).toBe('rgba(0, 0, 0, 0.2)');
23
+ });
24
+
25
+ it('still applies the requested alpha to opaque colours', () => {
26
+ expect(colorToRgba('#000000', 0.4)).toBe('rgba(0, 0, 0, 0.4)');
27
+ expect(colorToRgba('rgb(10, 20, 30)', 0.5)).toBe('rgba(10, 20, 30, 0.5)');
28
+ });
29
+ });
@@ -115,6 +115,28 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
115
115
  }
116
116
  const short = normalized.match(/^#([0-9a-f]{3})$/i);
117
117
  const long = normalized.match(/^#([0-9a-f]{6})$/i);
118
+ // #RRGGBBAA / #RGBA: the authored alpha channel is kept and multiplied
119
+ // with the requested one, like the rgba() branches below.
120
+ const longAlpha = normalized.match(/^#([0-9a-f]{8})$/i);
121
+ const shortAlpha = normalized.match(/^#([0-9a-f]{4})$/i);
122
+
123
+ if (longAlpha) {
124
+ const h = longAlpha[1];
125
+ const r = parseInt(h.slice(0, 2), 16);
126
+ const g = parseInt(h.slice(2, 4), 16);
127
+ const b = parseInt(h.slice(4, 6), 16);
128
+ const authored = parseInt(h.slice(6, 8), 16) / 255;
129
+ return `rgba(${r}, ${g}, ${b}, ${Number((authored * alpha).toFixed(4))})`;
130
+ }
131
+
132
+ if (shortAlpha) {
133
+ const h = shortAlpha[1];
134
+ const r = parseInt(h[0] + h[0], 16);
135
+ const g = parseInt(h[1] + h[1], 16);
136
+ const b = parseInt(h[2] + h[2], 16);
137
+ const authored = parseInt(h[3] + h[3], 16) / 255;
138
+ return `rgba(${r}, ${g}, ${b}, ${Number((authored * alpha).toFixed(4))})`;
139
+ }
118
140
 
119
141
  if (short) {
120
142
  const h = short[1];
@@ -132,7 +154,10 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
132
154
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
133
155
  }
134
156
 
135
- // rgb(12, 34, 56) or rgba(12, 34, 56, 0.5)
157
+ // rgb(12, 34, 56) or rgba(12, 34, 56, 0.5). An alpha the author already
158
+ // put into the colour is kept and multiplied with the requested one —
159
+ // rebuilding rgba(0,0,0,0.07) at the default background opacity used to
160
+ // print rgba(0, 0, 0, 1) and turn a 7% tint solid black.
136
161
  const commaRgb = normalized.match(
137
162
  /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*([0-9.]+))?\s*\)$/i
138
163
  );
@@ -141,7 +166,10 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
141
166
  const g = Number(commaRgb[2]);
142
167
  const b = Number(commaRgb[3]);
143
168
  if ([r, g, b].some((v) => Number.isNaN(v))) return null;
144
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
169
+ const authored =
170
+ commaRgb[4] !== undefined ? Number(commaRgb[4]) : 1;
171
+ const effective = Number.isNaN(authored) ? alpha : authored * alpha;
172
+ return `rgba(${r}, ${g}, ${b}, ${effective})`;
145
173
  }
146
174
 
147
175
  // rgb(12 34 56) or rgb(12 34 56 / 0.5)
@@ -153,7 +181,15 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
153
181
  const g = Number(spaceRgb[2]);
154
182
  const b = Number(spaceRgb[3]);
155
183
  if ([r, g, b].some((v) => Number.isNaN(v))) return null;
156
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
184
+ const rawAuthored = spaceRgb[4];
185
+ const authored =
186
+ rawAuthored === undefined
187
+ ? 1
188
+ : rawAuthored.endsWith('%')
189
+ ? Number(rawAuthored.slice(0, -1)) / 100
190
+ : Number(rawAuthored);
191
+ const effective = Number.isNaN(authored) ? alpha : authored * alpha;
192
+ return `rgba(${r}, ${g}, ${b}, ${effective})`;
157
193
  }
158
194
 
159
195
  return null;
@@ -59,14 +59,18 @@ describe('colorToRgba', () => {
59
59
  expect(colorToRgba('rgb(12 34 56)', 0.5)).toBe('rgba(12, 34, 56, 0.5)');
60
60
  });
61
61
 
62
- it('returns null for formats it cannot parse (named, var, 8-digit hex, hsl)', () => {
62
+ it('returns null for formats it cannot parse (named, var, hsl)', () => {
63
63
  // These return null -> the caller silently drops the alpha. Locking the
64
64
  // current behavior so a future change here is a conscious decision.
65
65
  expect(colorToRgba('red', 0.5)).toBeNull();
66
66
  expect(colorToRgba('var(--theme-primary)', 0.5)).toBeNull();
67
- expect(colorToRgba('#11223344', 0.5)).toBeNull();
68
67
  expect(colorToRgba('hsl(0, 100%, 50%)', 0.5)).toBeNull();
69
68
  });
69
+
70
+ it('multiplies the alpha channel of an 8-digit hex with the requested one', () => {
71
+ // 0x44 = 68/255 ≈ 0.2667; halved ≈ 0.1333.
72
+ expect(colorToRgba('#11223344', 0.5)).toBe('rgba(17, 34, 51, 0.1333)');
73
+ });
70
74
  });
71
75
 
72
76
  describe('applyAlphaToColor', () => {