fix(stylesheet): allow gaps between level decorations (#3434)

* fix(stylesheet): allow gaps between level decorations

* test(pdf): explain level gap raster measurements
This commit is contained in:
Amruth Pillai
2026-09-05 08:40:09 -07:00
committed by GitHub
parent b6842fb769
commit f629ea1ea3
4 changed files with 118 additions and 6 deletions
+15
View File
@@ -213,6 +213,21 @@ list-item-content {
}
```
### Space level indicators
Target `level` to adjust the space between a skill's circles, icons, or other level decorations:
```css
@version 1;
section[type="skills"] level {
column-gap: 4pt;
}
```
This sets a 4pt horizontal gap between decorations. `gap: 4pt` also works; `gap: 0` removes the gap.
`row-gap` does not change horizontal spacing within the single level row.
### Style fields inside an item
Named fields let you make a focused change without styling every item value. Use the selector only where that field
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { renderToBuffer } from "@react-pdf/renderer";
import { act } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../../document";
import { resolveResumeRuntime } from "../../semantic/resolve";
import { rasterizePdf } from "../../semantic/test/rasterize-pdf";
// rasterizePdf renders at 1.5 pixels per PDF point. The default 10pt body
// font produces 8pt level circles via resolveLevelDisplaySizes.
const pdfRasterScale = 1.5;
const circleDiameterPt = 8;
async function circlePositions(declaration = "", mode: "semantic" | "legacy" = "semantic") {
const data = structuredClone(defaultResumeData);
data.metadata.typography.body.fontFamily = "Helvetica";
data.metadata.typography.heading.fontFamily = "Helvetica";
data.metadata.page.hideIcons = true;
data.metadata.design.colors.primary = "rgba(255, 0, 0, 1)";
data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }];
data.metadata.stylesheet = {
mode,
source: { languageVersion: 1, text: `@version 1; section[type="skills"] level { ${declaration} }` },
};
data.sections.skills.items = [
{
id: "skill",
hidden: false,
icon: "",
iconColor: "",
name: "Skill",
proficiency: "",
level: 5,
keywords: [],
},
];
const runtime = resolveResumeRuntime({ data, template: "onyx", mode });
const bytes = await act(() => renderToBuffer(<ResumeDocument data={data} template="onyx" />));
const [page] = await rasterizePdf(new Uint8Array(bytes));
if (!page) throw new Error("Missing PDF page");
// Scan the actual circle raster for a row with five separate red segments.
// The curved tops remain separate even when the circles have zero gap.
for (let y = 0; y < page.height; y++) {
const segments: { start: number; end: number }[] = [];
let segment: { start: number; end: number } | undefined;
for (let x = 0; x < page.width; x++) {
const offset = (y * page.width + x) * 4;
// Select the red decorations while excluding black text and white/antialiased background.
const red = (page.data[offset] ?? 0) > 200 && (page.data[offset + 1] ?? 255) < 100;
if (red) {
if (segment) segment.end = x;
else segment = { start: x, end: x };
} else if (segment) {
segments.push(segment);
segment = undefined;
}
}
if (segment) segments.push(segment);
if (segments.length === 5)
return {
centers: segments.map(({ start, end }) => (start + end) / 2),
y,
diagnostics: runtime.diagnostics,
};
}
throw new Error("Missing five level circles in PDF raster");
}
describe("semantic level gaps (#3040)", () => {
it("preserves default circle geometry in legacy and semantic modes", async () => {
expect(await circlePositions()).toEqual(await circlePositions("", "legacy"));
});
it.each([
["gap: 0;", 0],
["gap: 4pt;", 4],
["gap: 3pt 5pt;", 5],
["column-gap: 6pt;", 6],
["row-gap: 9pt;", 4 / 3],
["gap: 4pt; column-gap: 0;", 0],
] as const)("renders %s on the level row", async (declaration, expectedGap) => {
const baseline = await circlePositions();
const actual = await circlePositions(declaration);
expect(actual.y).toBe(baseline.y);
expect(actual.centers[0]).toBe(baseline.centers[0]);
for (let index = 1; index < actual.centers.length; index++) {
const previous = actual.centers[index - 1];
const current = actual.centers[index];
if (previous === undefined || current === undefined) throw new Error("Missing circle center");
expect(Math.abs(current - previous - (circleDiameterPt + expectedGap) * pdfRasterScale)).toBeLessThanOrEqual(0.5);
}
expect(actual.diagnostics).toEqual([]);
});
});
@@ -211,13 +211,15 @@ const expectedPropertyGroups = [
"flex-shrink",
"flex-basis",
"justify-content",
"gap",
"row-gap",
"column-gap",
],
appliesTo: linkContainerNodes,
inheritable: false,
},
{
names: ["gap", "row-gap", "column-gap"],
appliesTo: [...linkContainerNodes, "level"],
inheritable: false,
},
{
names: ["aspect-ratio", "bottom"],
appliesTo: linkContainerNodes,
@@ -213,12 +213,14 @@ const properties = {
"flex-shrink",
"flex-basis",
"justify-content",
"gap",
"row-gap",
"column-gap",
],
{ category: "flexbox", inheritable: false, appliesTo: linkContainerNodes },
),
...entries(["gap", "row-gap", "column-gap"], {
category: "flexbox",
inheritable: false,
appliesTo: [...linkContainerNodes, "level"],
}),
...entries(["aspect-ratio", "bottom"], {
category: "layout",
inheritable: false,