@akinon/pz-theme 2.0.79-rc.1 → 2.0.80-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,72 +1,25 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.79-rc.1
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea4143]
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 [e9598c71]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a63]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b]
34
- - @akinon/next@2.0.79-rc.1
35
- - @akinon/theme-contract@2.0.79-rc.1
36
-
37
- ## 2.0.79-rc.0
38
-
39
- ### Patch Changes
40
-
41
- - Updated dependencies [86187b1c]
42
- - Updated dependencies [0cf9ea239]
43
- - Updated dependencies [324f97d55]
44
- - Updated dependencies [51ea06888]
45
- - Updated dependencies
46
- - Updated dependencies [2fea4143]
47
- - Updated dependencies [b55acb768]
48
- - Updated dependencies [760258c1c]
49
- - Updated dependencies [143be2b9d]
50
- - Updated dependencies [7889b08fe]
51
- - Updated dependencies [9f8cd3bc5]
52
- - Updated dependencies [bfafa3f49]
53
- - Updated dependencies [57d7eb305]
54
- - Updated dependencies [d99a6a7d5]
55
- - Updated dependencies [9db81a714]
56
- - Updated dependencies [591e345e1]
57
- - Updated dependencies [4de5303c5]
58
- - Updated dependencies [95b139dc1]
59
- - Updated dependencies [1d00f2d06]
60
- - Updated dependencies [4ac7b2a1e]
61
- - Updated dependencies [e9598c71]
62
- - Updated dependencies [4998a9631]
63
- - Updated dependencies [804d2bd6c]
64
- - Updated dependencies [3909d3224]
65
- - Updated dependencies [6a3d8a631]
66
- - Updated dependencies [e18836b20]
67
- - Updated dependencies [b1111d7b]
68
- - @akinon/next@2.0.79-rc.0
69
- - @akinon/theme-contract@2.0.79-rc.0
3
+ ## 2.0.80-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.80-beta.0
9
+ - @akinon/theme-contract@2.0.80-beta.0
10
+
11
+ ## 2.0.79
12
+
13
+ ### Patch Changes
14
+
15
+ - 945e153d: ZERO-4669: Keep the designer action toolbar reachable at the top of the canvas.
16
+
17
+ The toolbar is portaled to `<body>` (ZERO-4651), so nothing can clip it any more — but hanging above its host still draws it at a negative y when the host sits at the viewport top: the first section at scroll 0, and any section in a chrome slot pinned with `position: sticky; top: 0`, which reports the same box at every scroll position and so was unreachable permanently. The toolbar now flips inside its host when there is not enough room above it on screen, while the portal frame stays pinned to the host's box.
18
+
19
+ Also adds the `@akinon/pz-theme` content globs to the app's Tailwind config: without them the toolbar's own utilities (`-top-0.5`, `top-1`, `-translate-y-full`, `bg-[#4482ff]`) are never generated, so the designer chrome rendered unstyled. The app template carries the same two lines, so scaffolded brand apps are not shipping unstyled designer chrome either.
20
+
21
+ - @akinon/next@2.0.79
22
+ - @akinon/theme-contract@2.0.79
70
23
 
71
24
  ## 2.0.78
72
25
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
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.79-rc.1",
4
+ "version": "2.0.80-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.79-rc.1",
11
+ "@akinon/next": "2.0.80-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.79-rc.1",
16
+ "@akinon/theme-contract": "2.0.80-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.79-rc.1",
23
+ "@akinon/next": "2.0.80-beta.0",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,338 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+ import ActionToolbar from '../action-toolbar';
4
+
5
+ /**
6
+ * The toolbar is portaled to <body> and pinned onto its host's viewport box, so
7
+ * everything here is read off `document`, never off the render container, and
8
+ * the host has to carry `[data-block-id]` / `[data-section-id]` — that is the
9
+ * selector the toolbar resolves its host with.
10
+ *
11
+ * These are the placement cases; that the portal escapes every clipping
12
+ * declaration a composition can set is covered in
13
+ * `src/__tests__/designer-chrome-clipping.test.tsx`.
14
+ */
15
+
16
+ /** Mirrors the component's own constants: 40px tall + the 2px `-top-0.5`. */
17
+ const SPACE_NEEDED_ABOVE = 42;
18
+ const CHROME_Z_BASE = 2147483000;
19
+
20
+ const rectAt = (top: number, left = 40) => ({
21
+ top,
22
+ left,
23
+ right: left + 300,
24
+ bottom: top + 200,
25
+ width: 300,
26
+ height: 200,
27
+ x: left,
28
+ y: top
29
+ });
30
+
31
+ /**
32
+ * A complete rect for the host and a zero rect for everything else — the shape
33
+ * `getBoundingClientRect` really has. A partial mock leaves `rect.left`
34
+ * undefined, which used to turn the every-render measure into an infinite
35
+ * render loop; `nonFiniteHostRect` below pins the guard that stops it.
36
+ */
37
+ const ZERO_RECT = {
38
+ top: 0,
39
+ left: 0,
40
+ right: 0,
41
+ bottom: 0,
42
+ width: 0,
43
+ height: 0,
44
+ x: 0,
45
+ y: 0
46
+ };
47
+
48
+ const mockHostRect = (rect: ReturnType<typeof rectAt>) =>
49
+ jest
50
+ .spyOn(Element.prototype, 'getBoundingClientRect')
51
+ .mockImplementation(function (this: Element) {
52
+ const value = this.hasAttribute('data-block-id') ? rect : ZERO_RECT;
53
+ return { ...value, toJSON: () => value } as DOMRect;
54
+ });
55
+
56
+ const renderToolbar = (
57
+ props: Partial<React.ComponentProps<typeof ActionToolbar>> = {}
58
+ ) =>
59
+ render(
60
+ <div data-block-id="b1">
61
+ <ActionToolbar label="My Section" {...props} />
62
+ </div>
63
+ );
64
+
65
+ const toolbarEl = () =>
66
+ document.querySelector('[data-action-toolbar]') as HTMLElement;
67
+
68
+ const frameEl = () => toolbarEl().parentElement as HTMLElement;
69
+
70
+ /** Every re-measure but a re-render is coalesced into a frame. */
71
+ const flushFrame = () =>
72
+ act(() => {
73
+ jest.advanceTimersByTime(20);
74
+ });
75
+
76
+ describe('ActionToolbar placement', () => {
77
+ beforeEach(() => jest.useFakeTimers());
78
+
79
+ afterEach(() => {
80
+ jest.useRealTimers();
81
+ jest.restoreAllMocks();
82
+ });
83
+
84
+ it('hangs above the host when there is room above it on screen', () => {
85
+ mockHostRect(rectAt(200));
86
+ renderToolbar();
87
+
88
+ expect(toolbarEl().className).toContain('-top-0.5');
89
+ expect(toolbarEl().className).toContain('-translate-y-full');
90
+ expect(toolbarEl().className).not.toContain('top-1');
91
+ });
92
+
93
+ /**
94
+ * The case the portal alone does not solve. Hanging above a host at the
95
+ * viewport top draws the toolbar at y = -42, off-screen, and nothing scrolls
96
+ * above 0 — rename / duplicate / delete would be unreachable. A chrome slot
97
+ * pinned with `position: sticky; top: 0` reports this rect at *every* scroll
98
+ * position, so there it is unreachable permanently.
99
+ */
100
+ it('flips inside the host when the host sits at the viewport top', () => {
101
+ mockHostRect(rectAt(0));
102
+ renderToolbar();
103
+
104
+ expect(toolbarEl().className).toContain('top-1');
105
+ expect(toolbarEl().className).not.toContain('-translate-y-full');
106
+ expect(toolbarEl().className).not.toContain('-top-0.5');
107
+ });
108
+
109
+ /**
110
+ * The flip must be expressed in the toolbar's own classes and nowhere else.
111
+ * Clamping or offsetting the frame instead would break the contract that the
112
+ * frame is pinned to the host's box
113
+ * (`designer-chrome-clipping.test.tsx` → 'pins a nested block toolbar…').
114
+ */
115
+ it('leaves the frame pinned to the host box when the toolbar flips', () => {
116
+ mockHostRect(rectAt(0, 220));
117
+ renderToolbar();
118
+
119
+ expect(toolbarEl().className).toContain('top-1');
120
+ expect(frameEl().style.position).toBe('fixed');
121
+ expect(frameEl().style.top).toBe('0px');
122
+ expect(frameEl().style.left).toBe('220px');
123
+ });
124
+
125
+ it('flips one pixel short of the room it needs and not one pixel over', () => {
126
+ mockHostRect(rectAt(SPACE_NEEDED_ABOVE - 1));
127
+ const short = render(
128
+ <div data-block-id="b1">
129
+ <ActionToolbar label="My Section" />
130
+ </div>
131
+ );
132
+ expect(toolbarEl().className).toContain('top-1');
133
+ short.unmount();
134
+
135
+ mockHostRect(rectAt(SPACE_NEEDED_ABOVE));
136
+ renderToolbar();
137
+ expect(toolbarEl().className).toContain('-translate-y-full');
138
+ });
139
+
140
+ /**
141
+ * Documented limitation, asserted so that fixing it is a deliberate change:
142
+ * once the host's top has scrolled *past* the viewport edge, neither branch
143
+ * puts the toolbar on screen — flipped it lands at `origin.top + 4`. The
144
+ * in-tree predecessor had the same hole. The fix is to slide the toolbar
145
+ * down inside the host by a computed amount, which needs the host's height
146
+ * carried in the measured origin.
147
+ */
148
+ it('still flips, but cannot rescue a host scrolled above the viewport', () => {
149
+ mockHostRect(rectAt(-300));
150
+ renderToolbar();
151
+
152
+ expect(toolbarEl().className).toContain('top-1');
153
+ // The frame is where the host is, i.e. off-screen; the flip does not clamp.
154
+ expect(frameEl().style.top).toBe('-300px');
155
+ });
156
+
157
+ it('repositions and re-evaluates placement on scroll', () => {
158
+ const spy = mockHostRect(rectAt(200));
159
+ renderToolbar();
160
+ expect(frameEl().style.top).toBe('200px');
161
+ expect(toolbarEl().className).toContain('-translate-y-full');
162
+
163
+ spy.mockRestore();
164
+ mockHostRect(rectAt(0));
165
+ fireEvent.scroll(window);
166
+ flushFrame();
167
+
168
+ expect(frameEl().style.top).toBe('0px');
169
+ expect(toolbarEl().className).toContain('top-1');
170
+ expect(toolbarEl().className).not.toContain('-translate-y-full');
171
+ });
172
+
173
+ it('repositions and re-evaluates placement on resize', () => {
174
+ const spy = mockHostRect(rectAt(0));
175
+ renderToolbar();
176
+ expect(frameEl().style.top).toBe('0px');
177
+ expect(toolbarEl().className).toContain('top-1');
178
+
179
+ spy.mockRestore();
180
+ mockHostRect(rectAt(200));
181
+ fireEvent.resize(window);
182
+ flushFrame();
183
+
184
+ expect(frameEl().style.top).toBe('200px');
185
+ expect(toolbarEl().className).toContain('-translate-y-full');
186
+ });
187
+
188
+ /**
189
+ * Move Up / Move Down are buttons on this toolbar: they move the host to a
190
+ * different y without resizing it, scrolling it or touching its attributes,
191
+ * so no listener fires. The canvas re-renders, so a render has to be a
192
+ * measuring point too.
193
+ */
194
+ it('re-evaluates placement on a re-render that moved the host', () => {
195
+ const spy = mockHostRect(rectAt(200));
196
+ const { rerender } = renderToolbar();
197
+ expect(toolbarEl().className).toContain('-translate-y-full');
198
+
199
+ spy.mockRestore();
200
+ mockHostRect(rectAt(0));
201
+ rerender(
202
+ <div data-block-id="b1">
203
+ <ActionToolbar label="My Section" isLocked />
204
+ </div>
205
+ );
206
+
207
+ expect(toolbarEl().className).toContain('top-1');
208
+ });
209
+
210
+ /**
211
+ * The measure runs on every render and its only brake is an unchanged
212
+ * origin, so a coordinate that is not a number (`NaN !== NaN`) would set a
213
+ * fresh origin every commit and render forever.
214
+ */
215
+ it('ignores an unreadable rect instead of re-rendering forever', () => {
216
+ mockHostRect(rectAt(200));
217
+ renderToolbar();
218
+ expect(frameEl().style.top).toBe('200px');
219
+
220
+ jest.restoreAllMocks();
221
+ jest
222
+ .spyOn(Element.prototype, 'getBoundingClientRect')
223
+ .mockReturnValue({ top: 10 } as DOMRect);
224
+
225
+ expect(() => {
226
+ fireEvent.scroll(window);
227
+ flushFrame();
228
+ }).not.toThrow();
229
+
230
+ // Left exactly where the last good measurement put it.
231
+ expect(frameEl().style.top).toBe('200px');
232
+ expect(frameEl().style.left).toBe('40px');
233
+ });
234
+
235
+ it('marks the toolbar with data-action-toolbar for outside-click guards', () => {
236
+ mockHostRect(rectAt(200));
237
+ renderToolbar();
238
+
239
+ // `accordion-block` reads this off the click target to tell chrome clicks
240
+ // from content clicks; portaled events still bubble through the React tree.
241
+ expect(toolbarEl()).toBeInTheDocument();
242
+ });
243
+ });
244
+
245
+ describe('ActionToolbar stacking', () => {
246
+ beforeEach(() => jest.useFakeTimers());
247
+
248
+ afterEach(() => {
249
+ jest.useRealTimers();
250
+ jest.restoreAllMocks();
251
+ });
252
+
253
+ /**
254
+ * At <body> the toolbar no longer inherits the host's stacking context, so
255
+ * the frame carries an absolute z-index and `zIndex` is only the relative
256
+ * order of block chrome (default 30) over section chrome (20).
257
+ */
258
+ it('stacks the frame above page chrome and leaves the toolbar unstyled', () => {
259
+ mockHostRect(rectAt(200));
260
+ renderToolbar();
261
+
262
+ expect(frameEl().style.zIndex).toBe(String(CHROME_Z_BASE + 30));
263
+ expect(toolbarEl().style.zIndex).toBe('');
264
+ });
265
+
266
+ it('orders section chrome below block chrome', () => {
267
+ mockHostRect(rectAt(200));
268
+ renderToolbar({ zIndex: 20 });
269
+
270
+ expect(frameEl().style.zIndex).toBe(String(CHROME_Z_BASE + 20));
271
+ expect(Number(frameEl().style.zIndex)).toBeLessThan(CHROME_Z_BASE + 30);
272
+ });
273
+
274
+ /**
275
+ * `zIndex` is added to a base that already sits just under the 32-bit
276
+ * maximum browsers clamp z-index to, so only a small range is usable: a
277
+ * larger value silently ties with everything else past the clamp instead of
278
+ * ordering above it.
279
+ */
280
+ it('keeps the default inside the range a browser can represent', () => {
281
+ mockHostRect(rectAt(200));
282
+ renderToolbar();
283
+
284
+ expect(Number(frameEl().style.zIndex)).toBeLessThanOrEqual(2147483647);
285
+ });
286
+ });
287
+
288
+ describe('ActionToolbar rename', () => {
289
+ beforeEach(() => jest.useFakeTimers());
290
+
291
+ afterEach(() => {
292
+ jest.useRealTimers();
293
+ jest.restoreAllMocks();
294
+ });
295
+
296
+ it('starts rename on label click and commits the trimmed value on Enter', () => {
297
+ mockHostRect(rectAt(200));
298
+ const onRename = jest.fn();
299
+ renderToolbar({ onRename });
300
+
301
+ fireEvent.click(screen.getByText('My Section'));
302
+ const input = screen.getByDisplayValue('My Section');
303
+ fireEvent.change(input, { target: { value: ' Renamed ' } });
304
+ fireEvent.keyDown(input, { key: 'Enter' });
305
+
306
+ expect(onRename).toHaveBeenCalledWith('Renamed');
307
+ });
308
+
309
+ it('cancels rename on Escape without calling onRename', () => {
310
+ mockHostRect(rectAt(200));
311
+ const onRename = jest.fn();
312
+ renderToolbar({ onRename });
313
+
314
+ fireEvent.click(screen.getByText('My Section'));
315
+ const input = screen.getByDisplayValue('My Section');
316
+ fireEvent.change(input, { target: { value: 'Changed' } });
317
+ fireEvent.keyDown(input, { key: 'Escape' });
318
+
319
+ expect(onRename).not.toHaveBeenCalled();
320
+ expect(screen.getByText('My Section')).toBeInTheDocument();
321
+ });
322
+
323
+ /**
324
+ * The renaming input's `border-2` makes the control strip 44px, so a
325
+ * measured height would move the flip threshold mid-interaction and could
326
+ * pull the toolbar out from under the cursor. The height is a constant
327
+ * precisely so it cannot.
328
+ */
329
+ it('does not move the toolbar when a rename starts', () => {
330
+ mockHostRect(rectAt(200));
331
+ renderToolbar({ onRename: jest.fn() });
332
+ const before = toolbarEl().className;
333
+
334
+ fireEvent.click(screen.getByText('My Section'));
335
+
336
+ expect(toolbarEl().className).toBe(before);
337
+ });
338
+ });
@@ -30,6 +30,27 @@ const HOST_SELECTOR = '[data-block-id],[data-section-id]';
30
30
  */
31
31
  const CHROME_Z_BASE = 2147483000;
32
32
 
33
+ /**
34
+ * The toolbar's own height, as a constant rather than a measurement: it is
35
+ * fully determined by this file's markup — the control strip is `h-10` and the
36
+ * label column is a `text-sm` 20px line box inside `p-2.5` — and nothing about
37
+ * the host or the composition can change it. Measuring would need a ref on an
38
+ * element that does not exist until `origin` is known, i.e. a second pass and
39
+ * a frame of wrong placement, and it would make the placement depend on
40
+ * `isRenaming` (the input's `border-2` makes the row 44px), so starting a
41
+ * rename could move the toolbar out from under the cursor.
42
+ */
43
+ const TOOLBAR_HEIGHT = 40;
44
+
45
+ /** The `-top-0.5` nudge above the host's edge, in pixels. */
46
+ const TOOLBAR_TOP_OFFSET = 2;
47
+
48
+ /**
49
+ * Room the toolbar needs above the host to be fully on screen. Hanging above,
50
+ * it occupies `[origin.top - 42, origin.top - 2]`.
51
+ */
52
+ const SPACE_NEEDED_ABOVE = TOOLBAR_HEIGHT + TOOLBAR_TOP_OFFSET;
53
+
33
54
  // `useLayoutEffect` would warn during SSR; the toolbar only ever mounts in the
34
55
  // designer, but the module is part of a server-rendered package.
35
56
  const useIsomorphicLayoutEffect =
@@ -140,6 +161,13 @@ export default function ActionToolbar({
140
161
  left: rect.left + host.clientLeft
141
162
  };
142
163
 
164
+ // A coordinate that is not a number would defeat the bail-out below
165
+ // (`NaN !== NaN`), and this effect runs on every render — so the measure
166
+ // would set a fresh origin every commit and re-render forever. Leaving
167
+ // the toolbar where it is is also the right answer: an unreadable rect
168
+ // is a failed measurement, not a moved host.
169
+ if (!Number.isFinite(next.top) || !Number.isFinite(next.left)) return;
170
+
143
171
  setOrigin((previous) =>
144
172
  previous && previous.top === next.top && previous.left === next.left
145
173
  ? previous
@@ -232,10 +260,38 @@ export default function ActionToolbar({
232
260
  handleCancelRename();
233
261
  }
234
262
  };
263
+
264
+ /**
265
+ * Portaled to <body>, no ancestor can clip the toolbar any more, so the one
266
+ * reason left to draw it *inside* its host is that there is no room above it
267
+ * on screen: a host whose top is at the viewport edge would have its toolbar
268
+ * drawn at a negative y, painted off-screen with no scroll position that
269
+ * reveals it — and rename / duplicate / delete exist nowhere else. Two hosts
270
+ * sit there in the canvas: the top-most section at scroll 0, and anything in
271
+ * a chrome slot pinned with `position: sticky; top: 0`, which reports
272
+ * `rect.top === 0` at every scroll position and so is unreachable
273
+ * permanently.
274
+ *
275
+ * This covers `0 <= origin.top < SPACE_NEEDED_ABOVE` only. A host whose top
276
+ * has scrolled *past* the viewport edge keeps an off-screen toolbar, exactly
277
+ * as it did when the toolbar rendered in-tree; fixing that means sliding the
278
+ * toolbar down inside the host by a computed amount, which needs the host's
279
+ * height carried in `origin`.
280
+ *
281
+ * Derived from `origin` during render rather than kept in its own state:
282
+ * `origin` is the only input, so a second state set from an effect would buy
283
+ * an extra render and one painted frame in the old place. It cannot feed
284
+ * back either — the toolbar is `fixed` inside a zero-size frame at <body>
285
+ * and contributes no layout, so flipping it can never move the host.
286
+ */
287
+ const placeInside = origin !== null && origin.top < SPACE_NEEDED_ABOVE;
288
+
235
289
  const toolbar = (
236
290
  <div
237
291
  data-action-toolbar=""
238
- className="absolute -top-0.5 left-0 -translate-y-full flex items-center pointer-events-auto gap-1 min-w-max"
292
+ className={`absolute left-0 flex items-center pointer-events-auto gap-1 min-w-max ${
293
+ placeInside ? 'top-1' : '-top-0.5 -translate-y-full'
294
+ }`}
239
295
  >
240
296
  <div className="flex items-center gap-1">
241
297
  {isRenaming ? (