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 (