@akinon/pz-theme 2.0.85-rc.0 → 2.0.85

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,38 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.85-rc.0
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 [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 [b1111d7b]
34
- - @akinon/next@2.0.85-rc.0
35
- - @akinon/theme-contract@2.0.85-rc.0
3
+ ## 2.0.85
4
+
5
+ ### Patch Changes
6
+
7
+ - a5787dcf: ZERO-4848: Refactor ImageBlock to improve box sizing and CSS generation logic
8
+ - @akinon/next@2.0.85
9
+ - @akinon/theme-contract@2.0.85
36
10
 
37
11
  ## 2.0.84
38
12
 
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.85-rc.0",
4
+ "version": "2.0.85",
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.85-rc.0",
11
+ "@akinon/next": "2.0.85",
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.85-rc.0",
16
+ "@akinon/theme-contract": "2.0.85",
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.85-rc.0",
23
+ "@akinon/next": "2.0.85",
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",
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { render, screen } from '@testing-library/react';
3
3
 
4
4
  import ImageBlock from '../image-block';
5
+ import { generateThemeCSS } from '../../utils';
5
6
  import type { BlockRendererProps } from '../block-renderer-registry';
6
7
 
7
8
  // ImageBlock renders a native <img> / <div> directly (no child ThemeBlock /
@@ -137,3 +138,122 @@ describe('ImageBlock - loading strategy', () => {
137
138
  expect(getImg(container)?.getAttribute('loading')).toBe('eager');
138
139
  });
139
140
  });
141
+
142
+ /**
143
+ * The box is declared once.
144
+ *
145
+ * `width`, `height` and `aspect-ratio` are ordinary root style keys, so the
146
+ * generated stylesheet writes them onto this block's wrapper — the element the
147
+ * parent lays out. The renderer inlined the same numbers on the picture as
148
+ * well, which is one authored value applied to two elements that do not have to
149
+ * agree: a wrapper carrying `flex: 1 1 0` is resized by the flex algorithm, an
150
+ * inline `height: 160px` on an <img> is not.
151
+ *
152
+ * On a collection card that is exactly what happened. The card was a 162px
153
+ * square whose caption took 33px, leaving the image block a 127px box — and the
154
+ * picture, carrying the mobile breakpoint's authored `160px`, was 33px taller
155
+ * than the box it sat in. The card's `overflow: hidden` cut the difference off,
156
+ * so on a phone every image was cropped.
157
+ */
158
+ describe('ImageBlock leaves its box to the sheet', () => {
159
+ const sized = (overrides: Record<string, unknown> = {}) => ({
160
+ id: 'box',
161
+ value: 'https://example.com/a.jpg',
162
+ styles: {
163
+ width: { desktop: '100%' },
164
+ height: { desktop: '100%', mobile: '160px' },
165
+ 'object-fit': { desktop: 'contain', mobile: 'cover' },
166
+ 'border-radius': { desktop: '8px' }
167
+ },
168
+ ...overrides
169
+ });
170
+
171
+ it('fills the box rather than repeating the authored height', () => {
172
+ const { container } = renderBlock(sized(), 'mobile');
173
+ const img = getImg(container) as HTMLImageElement;
174
+
175
+ // The authored 160px is the WRAPPER's; repeating it here is what overflowed
176
+ // a box the layout had already made shorter.
177
+ expect(img.style.height).toBe('100%');
178
+ expect(img.style.width).toBe('100%');
179
+ });
180
+
181
+ it('does not drop the authored value — it moves to the wrapper rule', () => {
182
+ // The other half of "declared once": the number still reaches the page.
183
+ const css = generateThemeCSS([
184
+ {
185
+ id: 'sec-1',
186
+ styles: {},
187
+ blocks: [sized()]
188
+ }
189
+ ] as unknown as Parameters<typeof generateThemeCSS>[0]);
190
+
191
+ expect(css).toContain('[data-block-id="box"]');
192
+ expect(css).toContain('height: 160px');
193
+ });
194
+
195
+ it('still paints what a rule on the wrapper cannot say', () => {
196
+ // `object-fit` is meaningless on a <div>, and the radius has to be on the
197
+ // painted element because the wrapper does not clip.
198
+ const { container } = renderBlock(sized(), 'mobile');
199
+ const img = getImg(container) as HTMLImageElement;
200
+
201
+ expect(img.style.objectFit).toBe('cover');
202
+ expect(img.style.borderRadius).toBe('8px');
203
+ });
204
+
205
+ it('leaves an unsized picture on its intrinsic height', () => {
206
+ // The narrow half of the fix: with nothing authored, the wrapper rule sizes
207
+ // nothing, so filling it would let a stretched flex row crop the picture
208
+ // under the default `object-fit: cover`. Unchanged behaviour on purpose.
209
+ const { container } = renderBlock({
210
+ id: 'unsized',
211
+ value: 'https://example.com/a.jpg',
212
+ styles: { 'object-fit': { desktop: 'cover' } }
213
+ });
214
+
215
+ expect((getImg(container) as HTMLImageElement).style.height).toBe('auto');
216
+ });
217
+
218
+ it('fills a box the sheet sized with an aspect ratio alone', () => {
219
+ // An aspect ratio sizes the wrapper without naming a height, and it is on
220
+ // the wrapper too — so the picture has to fill, or it would sit at its
221
+ // intrinsic height inside a box shaped for it.
222
+ const { container } = renderBlock({
223
+ id: 'ratio',
224
+ value: 'https://example.com/a.jpg',
225
+ styles: { 'aspect-ratio': { desktop: '1/1' } }
226
+ });
227
+ const img = getImg(container) as HTMLImageElement;
228
+
229
+ expect(img.style.height).toBe('100%');
230
+ expect(img.style.aspectRatio).toBe('');
231
+ });
232
+
233
+ it('inlines a size the row resolved for itself', () => {
234
+ // One rule serves every clone, so a per-row value has nowhere else to live.
235
+ const { container } = renderBlock(
236
+ sized({ rowStyleKeys: ['height'] }),
237
+ 'mobile'
238
+ );
239
+
240
+ expect((getImg(container) as HTMLImageElement).style.height).toBe('160px');
241
+ });
242
+
243
+ it('gives an empty block a floor only when nothing sized it', () => {
244
+ const unsized = renderBlock({ id: 'empty', value: '' });
245
+ const placeholder = screen.getByText('No image uploaded');
246
+
247
+ expect(placeholder.style.minHeight).toBe('150px');
248
+ unsized.unmount();
249
+
250
+ // Sized by the wrapper, so filling it is already the right height; the
251
+ // repeated minimum is what pushed the placeholder out of a shorter box.
252
+ renderBlock(
253
+ { id: 'empty-sized', value: '', styles: { height: { mobile: '160px' } } },
254
+ 'mobile'
255
+ );
256
+
257
+ expect(screen.getByText('No image uploaded').style.minHeight).toBe('');
258
+ });
259
+ });
@@ -123,11 +123,50 @@ const ImageBlock = ({
123
123
  undefined
124
124
  ) as string | undefined;
125
125
 
126
+ /**
127
+ * THE PICTURE FILLS ITS BOX; IT DOES NOT RE-DECLARE IT.
128
+ *
129
+ * `width`, `height` and `aspect-ratio` are ordinary root style keys, so the
130
+ * generated stylesheet already writes them onto this block's WRAPPER — the
131
+ * element the parent lays out (`image` is not one of
132
+ * INNER_ROOT_BLOCK_TYPES, so the rule lands there). Inlining the same
133
+ * numbers here applied one authored value to two elements, and the two do
134
+ * not have to agree: a wrapper carrying `flex: 1 1 0` is resized by the flex
135
+ * algorithm, an inline `height` on this element is not. A card whose caption
136
+ * left 127px of a 162px square therefore drew a 160px picture inside a 127px
137
+ * box, and the card's `overflow: hidden` ate the rest.
138
+ *
139
+ * The exception is a value the row resolved for itself: one rule serves every
140
+ * clone, so a per-row size has nowhere to live but here (see
141
+ * `collectRowBoundStyleKeys`).
142
+ */
143
+ const isRowBound = (key: string): boolean =>
144
+ (block.rowStyleKeys ?? []).includes(key);
145
+ /**
146
+ * Whether the wrapper rule gives the box a height at all. Deliberately narrow:
147
+ * where nothing was authored the picture keeps its intrinsic `auto` height, so
148
+ * an unsized image in a stretched flex row renders exactly as it does today
149
+ * rather than filling the stretch and being cropped by the default
150
+ * `object-fit: cover` — a regression the fix must not trade for the one it
151
+ * repairs.
152
+ */
153
+ const boxIsSized =
154
+ (typeof height === 'string' && height !== 'auto') || Boolean(aspectRatio);
155
+ const boxWidth = isRowBound('width') ? width ?? '100%' : '100%';
156
+ const boxHeight = isRowBound('height')
157
+ ? height ?? 'auto'
158
+ : boxIsSized
159
+ ? '100%'
160
+ : 'auto';
161
+ const boxAspectRatio = isRowBound('aspect-ratio')
162
+ ? aspectRatio || undefined
163
+ : undefined;
164
+
126
165
  const content = !src ? (
127
166
  <div
128
167
  style={{
129
- width: width ?? '100%',
130
- height: height ?? '100%',
168
+ width: boxWidth,
169
+ height: boxHeight,
131
170
  borderRadius: borderRadius ?? '0px',
132
171
  overflow: 'hidden',
133
172
  backgroundColor: '#e5e7eb',
@@ -135,9 +174,12 @@ const ImageBlock = ({
135
174
  alignItems: 'center',
136
175
  justifyContent: 'center',
137
176
  textAlign: 'center',
138
- minHeight:
139
- typeof height === 'string' && height !== 'auto' ? height : '150px',
140
- aspectRatio: aspectRatio || undefined,
177
+ // A floor only when nothing sized the box. An authored height is on the
178
+ // wrapper, so filling it is already the right size — repeating the
179
+ // number as a minimum is what pushed the placeholder out of a box the
180
+ // layout had made shorter.
181
+ minHeight: boxIsSized ? undefined : '150px',
182
+ aspectRatio: boxAspectRatio,
141
183
  color: '#6b7280'
142
184
  }}
143
185
  >
@@ -152,11 +194,14 @@ const ImageBlock = ({
152
194
  fetchPriority={priority}
153
195
  style={{
154
196
  display: 'block',
155
- width: width ?? '100%',
156
- height: height ?? 'auto',
197
+ width: boxWidth,
198
+ height: boxHeight,
199
+ // These two stay: `object-fit` is meaningless on the wrapper <div>, and
200
+ // the radius has to be on the painted element because the wrapper does
201
+ // not clip.
157
202
  objectFit: objectFit ?? 'cover',
158
203
  borderRadius: borderRadius ?? '0px',
159
- aspectRatio: aspectRatio || undefined
204
+ aspectRatio: boxAspectRatio
160
205
  }}
161
206
  />
162
207
  );