From bb141d1b842569392d5fc9c7f1bcbc335c8c3509 Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Fri, 2 Oct 2026 21:42:48 +0200 Subject: [PATCH 1/3] Move the image block and inner container styles to styles/content.css Step A3 of #199, the pilot for the content CSS architecture. The image block drops its CSS Module and the block inner container its duplicated Public UI and CMSUI rules: both now live in styles/content.css, loaded in both user interfaces inside the plone-content cascade layer. The editor gets the .content-area content root, and Agave ships its first content token. Acceptance tests prove that a theme token reaches the image block in both user interfaces and that its layout doesn't depend on the public theme's reset. --- packages/agave/news/199.feature | 1 + packages/agave/styles/content.css | 11 + packages/blocks/AGENTS.md | 4 + packages/blocks/Image/ImageBlockEdit.tsx | 4 +- packages/blocks/Image/ImageBlockView.tsx | 6 +- .../tests/image-block-content-css.test.ts | 197 ++++++++++++++++++ .../acceptance/visual/image-block.test.ts | 117 +++++++++++ packages/blocks/news/199.feature | 1 + .../content.css} | 57 ++--- packages/blocks/vitest.config.ts | 2 +- .../tests/image-block-style-fields.test.ts | 34 ++- .../components/BlockEditor/BlocksEditor.tsx | 4 + packages/cmsui/news/199.feature | 1 + .../editor/plugins/plone-block-adapter.tsx | 7 + .../plate/news/+content-css-pilot.internal | 1 + packages/plate/styles/cmsui.css | 10 +- packages/plate/styles/content.css | 18 ++ packages/plate/styles/publicui.css | 10 +- 18 files changed, 422 insertions(+), 63 deletions(-) create mode 100644 packages/agave/news/199.feature create mode 100644 packages/agave/styles/content.css create mode 100644 packages/blocks/acceptance/tests/image-block-content-css.test.ts create mode 100644 packages/blocks/acceptance/visual/image-block.test.ts create mode 100644 packages/blocks/news/199.feature rename packages/blocks/{Image/ImageBlock.module.css => styles/content.css} (60%) create mode 100644 packages/cmsui/news/199.feature create mode 100644 packages/plate/news/+content-css-pilot.internal create mode 100644 packages/plate/styles/content.css diff --git a/packages/agave/news/199.feature b/packages/agave/news/199.feature new file mode 100644 index 000000000..ca4691aea --- /dev/null +++ b/packages/agave/news/199.feature @@ -0,0 +1 @@ +Added `styles/content.css` for Agave's block content tokens, loaded in both the Public UI and the CMSUI editor. @sneridagh diff --git a/packages/agave/styles/content.css b/packages/agave/styles/content.css new file mode 100644 index 000000000..db532a66a --- /dev/null +++ b/packages/agave/styles/content.css @@ -0,0 +1,11 @@ +/* + * Agave's block content tokens and overrides. They're loaded in both the + * Public UI and the CMSUI editor, inside the `plone-content` cascade layer, so + * blocks look the same in both. Declare tokens on `.content-area`, and don't + * wrap these rules in a `@layer`. + */ + +.content-area { + /* Floated images keep room for the wrapping content (framework default). */ + --block-float-max-size: 66%; +} diff --git a/packages/blocks/AGENTS.md b/packages/blocks/AGENTS.md index 44aac6487..43cf45e3b 100644 --- a/packages/blocks/AGENTS.md +++ b/packages/blocks/AGENTS.md @@ -48,3 +48,7 @@ Each block lives in its own folder at the package root (e.g., `Video/`, `Image/` pnpm --filter @plone/blocks test --run pnpm --filter @plone/blocks check:ts ``` + +Block content styles live in `styles/content.css`, which the app loads in both the Public UI and the CMSUI inside the `plone-content` cascade layer. Follow the authoring rules in `docs/conceptual-guides/add-on-styles-loader.md`: no CSS Modules, no `@layer`, and selectors inside `:where()`. + +Acceptance tests live under `acceptance/tests/` (run with `pnpm acceptance-test`) and visual regression tests under `acceptance/visual/` (run with `pnpm visual-test`). Visual baselines are only generated in CI, through the "Update VRT Screenshots" workflow; never commit locally generated screenshots. diff --git a/packages/blocks/Image/ImageBlockEdit.tsx b/packages/blocks/Image/ImageBlockEdit.tsx index 544dc6b02..600cf3bd6 100644 --- a/packages/blocks/Image/ImageBlockEdit.tsx +++ b/packages/blocks/Image/ImageBlockEdit.tsx @@ -3,9 +3,7 @@ import type { BlockEditProps } from '@plone/types'; import Image from '@plone/layout/components/Image/Image'; import { flattenToAppURL } from '@plone/helpers'; import config from '@plone/registry'; -import clsx from 'clsx'; import { getImageBlockItem, getImageBlockSrc } from './utils'; -import styles from './ImageBlock.module.css'; const ImageBlockEdit = (props: BlockEditProps) => { const { block, data, setBlock, selected } = props; @@ -36,7 +34,7 @@ const ImageBlockEdit = (props: BlockEditProps) => { ); return ( -
+
{data.url ? ( { const { data } = props; @@ -27,10 +25,10 @@ const ImageBlockView = (props: BlockViewProps) => { ); return ( -
+
{href ? ( ) => ({ + type: PLONE_BLOCK_TYPE, + '@type': 'image', + url: `/${IMAGE_ID}`, + blockWidth: 'default', + ...props, + children: [{ text: '' }], +}); + +async function createImagePage(page: Page) { + await createContent(page, { + contentType: 'Image', + contentId: IMAGE_ID, + contentTitle: 'Half Dome', + image: { + sourceFilename: 'halfdome2022.jpg', + filename: 'halfdome2022.jpg', + 'content-type': 'image/jpeg', + }, + }); + await createContent(page, { + contentType: 'Document', + contentId: PAGE_ID, + contentTitle: 'Image content CSS', + transition: 'publish', + bodyModifier: (body) => ({ + ...body, + blocks: { + __somersault__: { + '@type': '__somersault__', + value: [ + { type: 'title', children: [{ text: 'Image content CSS' }] }, + imageNode({ align: 'left', size: 'm' }), + { type: 'p', children: [{ text: TEXT }] }, + imageNode({ + align: 'center', + size: 'l', + href: [{ '@id': 'https://plone.org' }], + }), + { type: 'p', children: [{ text: TEXT }] }, + ], + }, + }, + }), + }); +} + +async function waitForImages(page: Page) { + const images = page.locator(`img[src*="/${IMAGE_ID}/@@images/image"]`); + await expect(images).toHaveCount(2); + await expect + .poll(() => + images.evaluateAll((imgs) => + imgs.every((img) => (img as HTMLImageElement).complete), + ), + ) + .toBe(true); +} + +const floatMaxSize = (page: Page) => + page + .locator('.image-block') + .first() + .evaluate((el) => + getComputedStyle(el).getPropertyValue('--block-float-max-size').trim(), + ); + +test('theme content tokens reach the image block in both user interfaces', async ({ + page, +}) => { + await createImagePage(page); + + // The framework only reads `--block-float-max-size` with a fallback and + // never declares it, so a value can only come from Agave's content styles. + await page.goto(`/${PAGE_ID}`); + await waitForImages(page); + expect(await floatMaxSize(page)).toBe('66%'); + + await login(page); + await page.goto(`/@@edit/${PAGE_ID}`); + await waitForPlateEditorReady(page); + await waitForImages(page); + expect(await floatMaxSize(page)).toBe('66%'); +}); + +// Geometry of each image block relative to its inner container, plus the +// computed styles the block sets itself. +const measure = (page: Page) => + page.locator('.image-block').evaluateAll((figures) => + figures.map((figure) => { + const container = figure.closest('.block-inner-container')!; + const box = container.getBoundingClientRect(); + const rect = figure.getBoundingClientRect(); + const img = figure.querySelector('img')!.getBoundingClientRect(); + const style = getComputedStyle(figure); + return { + left: Math.round(rect.left - box.left), + top: Math.round(rect.top - box.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + imageWidth: Math.round(img.width), + imageHeight: Math.round(img.height), + float: style.float, + margin: style.margin, + imageDisplay: getComputedStyle(figure.querySelector('img')!).display, + }; + }), + ); + +// Removes every rule in the top-level `base` layer, where the public theme's +// reset lives (Tailwind's preflight, for Agave). Returns how many it removed. +const removeBaseLayer = (page: Page) => + page.evaluate(() => { + let removed = 0; + const strip = (rules: CSSRuleList) => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSLayerBlockRule && rule.name === 'base') { + while (rule.cssRules.length) { + rule.deleteRule(0); + removed++; + } + } else if (rule instanceof CSSImportRule && rule.styleSheet) { + strip(rule.styleSheet.cssRules); + } + } + }; + for (const sheet of Array.from(document.styleSheets)) { + try { + strip(sheet.cssRules); + } catch { + // Cross-origin stylesheets (web fonts) can't be read. + } + } + return removed; + }); + +// The margin of a plain `
`, which no block styles target: it shows +// which reset is in effect. +const plainFigureMargin = (page: Page) => + page.evaluate(() => { + const figure = document.createElement('figure'); + document.body.append(figure); + const { margin } = getComputedStyle(figure); + figure.remove(); + return margin; + }); + +// A different, non-Tailwind reset in the `base` layer, with values that +// differ from Tailwind's preflight on the elements the image block uses. +const ALTERNATIVE_RESET = ` +@layer base { + *, *::before, *::after { box-sizing: content-box; } + figure { margin: 2em 3em; } + img { display: inline; max-width: none; vertical-align: baseline; } + a { display: inline; } +}`; + +test('the image block lays out the same under any public theme reset', async ({ + page, +}) => { + await createImagePage(page); + await page.goto(`/${PAGE_ID}`); + await waitForImages(page); + + const withPreflight = await measure(page); + expect(withPreflight).toHaveLength(2); + expect(withPreflight[0].float).toBe('left'); + expect(await plainFigureMargin(page)).toBe('0px'); + + // No reset: the browser's default styles apply. + expect(await removeBaseLayer(page)).toBeGreaterThan(0); + expect(await plainFigureMargin(page)).toBe('16px 40px'); + expect(await measure(page)).toEqual(withPreflight); + + // A different reset. + await page.addStyleTag({ content: ALTERNATIVE_RESET }); + expect(await plainFigureMargin(page)).toBe('32px 48px'); + expect(await measure(page)).toEqual(withPreflight); +}); diff --git a/packages/blocks/acceptance/visual/image-block.test.ts b/packages/blocks/acceptance/visual/image-block.test.ts new file mode 100644 index 000000000..d26c6429e --- /dev/null +++ b/packages/blocks/acceptance/visual/image-block.test.ts @@ -0,0 +1,117 @@ +import type { Page } from '@playwright/test'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; +import { expect, test } from '../../../tooling/playwright/test'; +import { login } from '../../../tooling/playwright/login'; +import { createContent } from '../../../tooling/playwright/content'; +import { waitForPlateEditorReady } from '../../../tooling/playwright/plate'; +import { settle } from '../../../tooling/playwright/visual'; + +// The image block in its three alignments: floated left and right with text +// and a list wrapping around it, and centered with a link. Covers the image +// block's own layout and its effect on the blocks that follow a floated image. + +const PAGE_ID = 'image-block-page'; +const IMAGE_ID = 'image-block-image'; +const TEXT = + 'Half Dome is a granite dome at the eastern end of Yosemite Valley. ' + + 'It is a well-known rock formation in the park, named for its distinct ' + + 'shape. One side is a sheer face while the other three sides are smooth ' + + 'and round, making it appear like a dome cut in half.'; + +const p = (text: string, props: Record = {}) => ({ + type: 'p', + ...props, + children: [{ text }], +}); + +const image = (props: Record) => ({ + type: PLONE_BLOCK_TYPE, + '@type': 'image', + url: `/${IMAGE_ID}`, + ...props, + children: [{ text: '' }], +}); + +async function createImagePage(page: Page) { + await createContent(page, { + contentType: 'Image', + contentId: IMAGE_ID, + contentTitle: 'Half Dome', + image: { + sourceFilename: 'halfdome2022.jpg', + filename: 'halfdome2022.jpg', + 'content-type': 'image/jpeg', + }, + }); + await createContent(page, { + contentType: 'Document', + contentId: PAGE_ID, + contentTitle: 'Image block', + transition: 'publish', + bodyModifier: (body) => ({ + ...body, + blocks: { + __somersault__: { + '@type': '__somersault__', + value: [ + { type: 'title', children: [{ text: 'Image block' }] }, + image({ align: 'left', size: 'm', blockWidth: 'default' }), + p(TEXT), + p('Wrapping list item', { indent: 1, listStyleType: 'disc' }), + p('Another wrapping item', { indent: 1, listStyleType: 'disc' }), + p(TEXT), + image({ align: 'right', size: 's', blockWidth: 'default' }), + p(TEXT), + p(TEXT), + image({ + align: 'center', + size: 'l', + blockWidth: 'default', + href: [{ '@id': 'https://plone.org' }], + }), + p(TEXT), + ], + }, + }, + }), + }); +} + +test('Image block alignments in the public view', async ({ page }) => { + await createImagePage(page); + await page.goto(`/${PAGE_ID}`); + await expect( + page.locator(`img[src*="/${IMAGE_ID}/@@images/image"]`), + ).toHaveCount(3); + await settle(page); + + await expect(page).toHaveScreenshot('image-block-view.png', { + fullPage: true, + }); +}); + +test('Image block alignments in the editor, floated image selected', async ({ + page, +}) => { + await createImagePage(page); + await login(page); + await page.goto(`/@@edit/${PAGE_ID}`); + await waitForPlateEditorReady(page); + + const form = page.locator('#sidebar form'); + await expect(async () => { + if ((await form.count()) === 0) { + await page + .locator(`img[src*="/${IMAGE_ID}/@@images/image"]`) + .first() + .click(); + } + await expect(form).toHaveCount(1); + }).toPass(); + await page.mouse.move(0, 0); + await settle(page); + + await expect(page.locator('[data-slate-editor]')).toHaveScreenshot( + 'image-block-edit.png', + ); +}); diff --git a/packages/blocks/news/199.feature b/packages/blocks/news/199.feature new file mode 100644 index 000000000..4d4893330 --- /dev/null +++ b/packages/blocks/news/199.feature @@ -0,0 +1 @@ +Moved the image block styles from a CSS Module to `styles/content.css`, so they load in both the Public UI and the CMSUI inside the `plone-content` cascade layer and themes can override them. The link around a linked image now has the `block-image__link` class. @sneridagh diff --git a/packages/blocks/Image/ImageBlock.module.css b/packages/blocks/styles/content.css similarity index 60% rename from packages/blocks/Image/ImageBlock.module.css rename to packages/blocks/styles/content.css index 38c216773..d377e6a65 100644 --- a/packages/blocks/Image/ImageBlock.module.css +++ b/packages/blocks/styles/content.css @@ -1,12 +1,13 @@ /* - * The image block owns its own layout. Tailwind's preflight reset - * (`* { margin: 0 }`) and the editor's selection outline live in a stronger - * cascade layer (`cmsui`), so these rules are intentionally left unlayered — - * unlayered normal declarations win over any layer — otherwise the reset would - * zero out the alignment margins and the outline could not be suppressed. + * Block content styles of @plone/blocks. They're loaded in both the Public UI + * and the CMSUI, inside the `plone-content` cascade layer. Follow the authoring + * rules in the "Content styles" section of the add-on styles loader docs: no + * `@layer`, selectors inside `:where()`, and no reliance on a particular reset. */ -.imageBlock { +/* Image block */ + +:where(.image-block) { width: min(100%, var(--block-size, 100%)); /* * Alignment is float-based so that the content of following blocks wraps @@ -16,19 +17,19 @@ */ margin: var(--block-margin, 0 auto); float: var(--block-float, none); +} - img { - display: block; - width: 100%; - height: auto; - } +:where(.image-block img) { + display: block; + width: 100%; + height: auto; } /* * When the image links somewhere, the anchor wraps the image inside the figure. * Make it a block so the image keeps filling the figure width. */ -.imageLink { +:where(.block-image__link) { display: block; } @@ -38,8 +39,10 @@ * select the block in the editor. Raising the floated image above the next * block keeps it clickable. Only needed for the floated (left/right) states. */ -:global([data-style-align='left']) .imageBlock, -:global([data-style-align='right']) .imageBlock { +:where( + [data-style-align='left'] .image-block, + [data-style-align='right'] .image-block +) { position: relative; z-index: 2; /* @@ -51,17 +54,15 @@ } /* - * Once floated, the collapsed wrapper's selection outline renders as a stray - * full-width line and the inter-block spacing below it is pointless. Drop both - * while floated. + * Once floated, the inter-block spacing below the collapsed wrapper is + * pointless, so it's dropped. The editor's selection outline, which would + * render as a stray full-width line, is dropped in the block adapter. */ -:global(.block-image[data-style-align='left']), -:global(.block-image[data-style-align='right']) { - outline: none; - - :global(.block-inner-container) { - padding-bottom: 0; - } +:where( + .block-image[data-style-align='left'], + .block-image[data-style-align='right'] +) { + --block-bottom-spacing: 0; } /* @@ -73,9 +74,9 @@ * tracking the shifted line. Switching these lists to inside markers moves the * marker into the line box, so it respects the gap exactly like wrapping text. */ -:global(.block-image[data-style-align='left']) ~ :global(.block) :global(ol), -:global(.block-image[data-style-align='left']) ~ :global(.block) :global(ul), -:global(.block-image[data-style-align='right']) ~ :global(.block) :global(ol), -:global(.block-image[data-style-align='right']) ~ :global(.block) :global(ul) { +:where( + .block-image[data-style-align='left'] ~ .block :is(ol, ul), + .block-image[data-style-align='right'] ~ .block :is(ol, ul) +) { list-style-position: inside; } diff --git a/packages/blocks/vitest.config.ts b/packages/blocks/vitest.config.ts index 8f7778679..823459147 100644 --- a/packages/blocks/vitest.config.ts +++ b/packages/blocks/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ // you might want to disable it, if you don't have tests that rely on CSS // since parsing CSS is slow css: true, - exclude: ['**/node_modules/**', '**/lib/**'], + exclude: ['**/node_modules/**', '**/lib/**', '**/acceptance/**'], passWithNoTests: true, }, }); diff --git a/packages/cmsui/acceptance/tests/image-block-style-fields.test.ts b/packages/cmsui/acceptance/tests/image-block-style-fields.test.ts index 7e364249a..05e72b50f 100644 --- a/packages/cmsui/acceptance/tests/image-block-style-fields.test.ts +++ b/packages/cmsui/acceptance/tests/image-block-style-fields.test.ts @@ -98,18 +98,30 @@ async function readImageBlock( } // Rendered width of the image relative to its column (the inner container). +// Retries while the block's nodes are detached: right after a page load, React +// can still replace the server-rendered nodes, which then measure 0/0. async function widthRatio(page: Page) { - return page - .locator('.image-block') - .first() - .evaluate((el) => { - const inner = el.closest('.block-inner-container') as HTMLElement | null; - const container = inner ?? (el.parentElement as HTMLElement); - return ( - el.getBoundingClientRect().width / - container.getBoundingClientRect().width - ); - }); + let ratio = NaN; + await expect + .poll(async () => { + ratio = await page + .locator('.image-block') + .first() + .evaluate((el) => { + if (!el.isConnected) return NaN; + const inner = el.closest( + '.block-inner-container', + ) as HTMLElement | null; + const container = inner ?? (el.parentElement as HTMLElement); + return ( + el.getBoundingClientRect().width / + container.getBoundingClientRect().width + ); + }); + return Number.isFinite(ratio); + }) + .toBe(true); + return ratio; } function radio(page: Page, name: string) { diff --git a/packages/cmsui/components/BlockEditor/BlocksEditor.tsx b/packages/cmsui/components/BlockEditor/BlocksEditor.tsx index 14357393c..3e1bb12ae 100644 --- a/packages/cmsui/components/BlockEditor/BlocksEditor.tsx +++ b/packages/cmsui/components/BlockEditor/BlocksEditor.tsx @@ -69,6 +69,10 @@ const BlocksEditor = () => { return ( Date: Fri, 2 Oct 2026 23:48:37 +0200 Subject: [PATCH 2/3] Make the image block reset test independent of fonts A centered image right after a float is pushed below it by an amount that depends on how tall the text next to the float is, which varies with the fonts installed. The test page now puts the floated image last. --- .../acceptance/tests/image-block-content-css.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/blocks/acceptance/tests/image-block-content-css.test.ts b/packages/blocks/acceptance/tests/image-block-content-css.test.ts index a5a02c8ad..6339bc3ff 100644 --- a/packages/blocks/acceptance/tests/image-block-content-css.test.ts +++ b/packages/blocks/acceptance/tests/image-block-content-css.test.ts @@ -48,14 +48,17 @@ async function createImagePage(page: Page) { '@type': '__somersault__', value: [ { type: 'title', children: [{ text: 'Image content CSS' }] }, - imageNode({ align: 'left', size: 'm' }), - { type: 'p', children: [{ text: TEXT }] }, + // The floated image goes last: a centered image right after a + // float is pushed below it, by an amount that depends on how + // tall the text next to the float is, which varies with fonts. imageNode({ align: 'center', size: 'l', href: [{ '@id': 'https://plone.org' }], }), { type: 'p', children: [{ text: TEXT }] }, + imageNode({ align: 'left', size: 'm' }), + { type: 'p', children: [{ text: TEXT }] }, ], }, }, @@ -182,7 +185,8 @@ test('the image block lays out the same under any public theme reset', async ({ const withPreflight = await measure(page); expect(withPreflight).toHaveLength(2); - expect(withPreflight[0].float).toBe('left'); + expect(withPreflight[0].float).toBe('none'); + expect(withPreflight[1].float).toBe('left'); expect(await plainFigureMargin(page)).toBe('0px'); // No reset: the browser's default styles apply. From f8d3133fc8afcb5183ec17e27a05058dc95b20ad Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Sat, 3 Oct 2026 00:00:37 +0200 Subject: [PATCH 3/3] Measure the image block width once again in the style fields test The retry worked around React replacing the server-rendered DOM on hydration. plone/aurora#207 fixed the cause (the server didn't load the translations), so the nodes now stay connected. --- .../tests/image-block-style-fields.test.ts | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/cmsui/acceptance/tests/image-block-style-fields.test.ts b/packages/cmsui/acceptance/tests/image-block-style-fields.test.ts index 05e72b50f..7e364249a 100644 --- a/packages/cmsui/acceptance/tests/image-block-style-fields.test.ts +++ b/packages/cmsui/acceptance/tests/image-block-style-fields.test.ts @@ -98,30 +98,18 @@ async function readImageBlock( } // Rendered width of the image relative to its column (the inner container). -// Retries while the block's nodes are detached: right after a page load, React -// can still replace the server-rendered nodes, which then measure 0/0. async function widthRatio(page: Page) { - let ratio = NaN; - await expect - .poll(async () => { - ratio = await page - .locator('.image-block') - .first() - .evaluate((el) => { - if (!el.isConnected) return NaN; - const inner = el.closest( - '.block-inner-container', - ) as HTMLElement | null; - const container = inner ?? (el.parentElement as HTMLElement); - return ( - el.getBoundingClientRect().width / - container.getBoundingClientRect().width - ); - }); - return Number.isFinite(ratio); - }) - .toBe(true); - return ratio; + return page + .locator('.image-block') + .first() + .evaluate((el) => { + const inner = el.closest('.block-inner-container') as HTMLElement | null; + const container = inner ?? (el.parentElement as HTMLElement); + return ( + el.getBoundingClientRect().width / + container.getBoundingClientRect().width + ); + }); } function radio(page: Page, name: string) {