Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/agave/news/199.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `styles/content.css` for Agave's block content tokens, loaded in both the Public UI and the CMSUI editor. @sneridagh
11 changes: 11 additions & 0 deletions packages/agave/styles/content.css
Original file line number Diff line number Diff line change
@@ -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%;
}
4 changes: 4 additions & 0 deletions packages/blocks/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 1 addition & 3 deletions packages/blocks/Image/ImageBlockEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,7 +34,7 @@ const ImageBlockEdit = (props: BlockEditProps) => {
);

return (
<div className={clsx(styles.imageBlock, 'image-block')}>
<div className="image-block">
{data.url ? (
<Image
item={getImageBlockItem(data)}
Expand Down
6 changes: 2 additions & 4 deletions packages/blocks/Image/ImageBlockView.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import type { BlockViewProps } from '@plone/types';
import Image from '@plone/layout/components/Image/Image';
import { Link } from '@plone/components';
import clsx from 'clsx';
import {
getImageBlockHref,
getImageBlockItem,
getImageBlockSrc,
} from './utils';
import styles from './ImageBlock.module.css';

const ImageBlockView = (props: BlockViewProps) => {
const { data } = props;
Expand All @@ -27,10 +25,10 @@ const ImageBlockView = (props: BlockViewProps) => {
);

return (
<figure className={clsx(styles.imageBlock, 'image-block')}>
<figure className="image-block">
{href ? (
<Link
className={styles.imageLink}
className="block-image__link"
href={href}
target={openInNewTab ? '_blank' : undefined}
rel={openInNewTab ? 'noopener noreferrer' : undefined}
Expand Down
201 changes: 201 additions & 0 deletions packages/blocks/acceptance/tests/image-block-content-css.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
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';

// The image block's styles come from `styles/content.css`, which both user
// interfaces load in the `plone-content` cascade layer. These tests check that
// a theme's content tokens reach the block in both of them, and that the block
// lays out the same whatever reset the public theme brings.

const PAGE_ID = 'image-content-css';
const IMAGE_ID = 'image-content-css-image';
const TEXT =
'Half Dome is a granite dome at the eastern end of Yosemite Valley. ' +
'One side is a sheer face while the other three sides are smooth and round.';

const imageNode = (props: Record<string, unknown>) => ({
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 `<figure>`, 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);
});
117 changes: 117 additions & 0 deletions packages/blocks/acceptance/visual/image-block.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({
type: 'p',
...props,
children: [{ text }],
});

const image = (props: Record<string, unknown>) => ({
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',
);
});
1 change: 1 addition & 0 deletions packages/blocks/news/199.feature
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading