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' }] },
+ // 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 }] },
+ ],
+ },
+ },
+ }),
+ });
+}
+
+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('none');
+ expect(withPreflight[1].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/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 (