mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
2703 lines
112 KiB
Markdown
2703 lines
112 KiB
Markdown
# Semantic CSS Stylesheet Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or
|
||
> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the structured Custom Styles form with Semantic CSS, a copy-pastable Semantic CSS language that can style
|
||
every registered PDF template part while preserving builder-owned content and the last valid rendered stylesheet.
|
||
|
||
**Architecture:** A universal compiler under `@reactive-resume/resume/stylesheet` parses real CSS syntax into a
|
||
versioned, environment-neutral program and resolves it against an immutable semantic resume tree. `@reactive-resume/pdf`
|
||
builds that tree, supplies template defaults, applies resolved styles and structural props to existing React PDF
|
||
primitives without adding wrappers, and owns PDF preflight and public projections. A dedicated API state machine stores
|
||
editable and applied source with revision/render-data compare-and-swap, while a lazy CodeMirror editor communicates
|
||
through a browser worker and a serialized mutation queue.
|
||
|
||
**Tech Stack:** TypeScript 7, Zod 4, React 19, React PDF 4.5, Vitest, oRPC, Drizzle/PostgreSQL, Zustand, Vite workers,
|
||
CSSTree, `@bramus/specificity`, CodeMirror 6, Prettier standalone, RFC 8785 `canonicalize`, Lingui, Base UI/shadcn.
|
||
|
||
## Global Constraints
|
||
|
||
- Follow `docs/superpowers/specs/2026-07-28-semantic-css-stylesheet-design.md`.
|
||
- Use Node.js 24, pnpm, existing workspace export maps, and `turbo boundaries`.
|
||
- Put the universal compiler in `packages/resume` per the repository placement rules; do not create a parallel domain
|
||
package and do not put compiler behavior in `packages/utils`.
|
||
- Add dependencies with `@latest` through pnpm, then commit the resolved lockfile.
|
||
- Semantic CSS affects PDF preview, public PDF rendering, and PDF export only. DOCX and Markdown remain unchanged.
|
||
- Design, Typography, Layout, Page, and Picture controls remain the base layer. Semantic CSS overrides exposed visual output.
|
||
- Typography remains the only font-family selector. Reject `font-family`, `@font-face`, `@import`, `url()`, generated
|
||
content, executable expressions, browser interaction states, animations, CSS Grid, and remote assets.
|
||
- Store exact editable source and last-valid applied source. Never persist a compiled AST.
|
||
- Treat the stylesheet atomically: any compiler error preserves the previous applied source. Do not apply valid fragments
|
||
from an otherwise invalid candidate.
|
||
- Generated/default source begins with `@version 1;`. A missing directive is a v1 warning for hand-written input;
|
||
duplicate, malformed, unsupported, or metadata-mismatched directives are errors.
|
||
- Expose builder values through read-only `--resume-*` system variables. User declarations using the reserved prefix are
|
||
errors.
|
||
- Never double-apply legacy `styleRules` and Semantic CSS. Persisted `mode` selects exactly one custom-style path.
|
||
- Legacy conversion stays inactive until explicit activation and must pass behavioral parity and bounded PDF preflight.
|
||
- Semantic instrumentation must not add React PDF layout wrappers. Bind node keys and resolved presentation to existing
|
||
`Page`, `View`, `Text`, `Image`, `Link`, HTML-renderer, and icon nodes.
|
||
- Match selectors against immutable source order; apply `display: none` and stable `order` once after cascade.
|
||
- Keep editable source, comments, diagnostics, and applied source owner-only. Public responses receive only a validated,
|
||
source-free resolved projection.
|
||
- Use TDD for every behavioral change: write one failing test, run it and confirm the intended failure, implement the
|
||
minimum behavior, rerun focused and neighboring tests, then commit.
|
||
- When a new TypeScript module does not yet exist, add only its typed export shell with functions that throw
|
||
`"Not implemented"` after writing the test but before the RED run. Confirm the test reaches the behavioral assertion
|
||
and fails on that sentinel; a module-resolution/type error is not an acceptable RED result. Replace the sentinel in
|
||
the same task and never commit a throwing scaffold.
|
||
- Use early one-line returns and named TypeScript types for non-trivial props and generic interfaces.
|
||
- UI work uses existing `@reactive-resume/ui` components and semantic Tailwind tokens. Desktop focus mode resizes the
|
||
existing right panel; mobile focus mode uses the existing Base UI `Sheet` with `SheetTitle`.
|
||
- Do not run broad mutating `pnpm check` until the final integration gate. Use focused Biome/Markdown checks while tasks
|
||
are in progress.
|
||
|
||
---
|
||
|
||
## Research Decisions Incorporated
|
||
|
||
| Decision | Application |
|
||
| --- | --- |
|
||
| CSS parser | Use `css-tree` for tolerant CSS parsing, source locations, AST walking, generation of machine-created CSS, and baseline lexer support. Keep exact source outside the AST because ordinary comments/spacing are omitted. |
|
||
| Specificity | Use `@bramus/specificity/core#calculateForAST` on already parsed CSSTree selector nodes. Lock behavior with `:is()`, `:not()`, `:where()`, and `:nth-*` golden tests. |
|
||
| Canonical hashing | Use `canonicalize` for RFC 8785 serialization and `globalThis.crypto.subtle.digest("SHA-256", …)` for the domain-separated public render hash in Node 24 and browsers. |
|
||
| Editor | Use CodeMirror 6 directly, without a React wrapper. Lazy-load the editor when Custom Styles opens. |
|
||
| Formatter | Lazy-load `prettier/standalone` and `prettier/plugins/postcss` only when the user selects **Format**. Use `formatWithCursor`; never format on save or paste. |
|
||
| Expanded editor | Resize the existing desktop right panel toward its 45% maximum and restore the prior layout on exit. Use `Sheet` only on mobile, where the preview already lives in a separate tab. |
|
||
| Shared tokens | Inject read-only colors, typography metrics, page metrics, sidebar width, picture metrics, and final page dimensions through `--resume-*`. Do not expose font family or picture URL. |
|
||
| No-wrapper API | Resolve structural behavior before React rendering; use a leaf `useResolvedNode(nodeKey)` hook only to apply styles/primitive props to existing nodes. |
|
||
| Acceptance fixtures | Turn issues #3146, #3134, #3137, #2223, and #3199 into named compiler/PDF/editor regression fixtures. |
|
||
| Future graphics | Record gradients and general box shadows as a later SVG-backed extension. They are not part of the v1 property adapter. |
|
||
|
||
## Milestones and Dependency Order
|
||
|
||
1. **Compiler foundation:** schema, parser, diagnostics, registries, selector matching, cascade, serialization, hashing.
|
||
2. **Dormant backend safety:** revision columns, render-data version, preservation, flags, no authoring UI.
|
||
3. **Semantic PDF runtime:** descriptor tree, all template manifests, adapter, structural props, issue fixtures.
|
||
4. **Migration and API:** behavioral converter, parity gate, state-machine mutation, preflight, public projection.
|
||
5. **Web authoring:** store, worker, CodeMirror shell, diagnostics, completion, formatting, focus mode.
|
||
6. **Integration and rollout:** preview/export/public paths, docs, all-template visual/E2E gates, backend-first flags.
|
||
|
||
Each milestone is testable and reviewable before the next begins. Do not expose the authoring flag until milestones 1–4
|
||
are green.
|
||
|
||
## File Responsibility Map
|
||
|
||
### `packages/schema`
|
||
|
||
- Create `packages/schema/src/resume/stylesheet.ts`: persisted source/applied/mode schemas and shared wire types.
|
||
- Create `packages/schema/src/resume/stylesheet.test.ts`: tolerant legacy compatibility and strict new-value tests.
|
||
- Modify `packages/schema/src/resume/data.ts`: optional `metadata.stylesheet`; preserve `metadata.styleRules`.
|
||
- Modify `packages/schema/package.json`: export `./resume/stylesheet`.
|
||
|
||
### `packages/resume`
|
||
|
||
- Create `packages/resume/src/stylesheet/types.ts`: diagnostics, semantic node, compiled program, resolved style, public
|
||
projection, and base-setting types.
|
||
- Create `packages/resume/src/stylesheet/version.ts`: `@version` parsing contract and immutable compiler dispatch.
|
||
- Create `packages/resume/src/stylesheet/parse.ts`: CSSTree adapter and source-range normalization.
|
||
- Create `packages/resume/src/stylesheet/registry/semantic.ts`: node kinds, parentage, attributes, roles, fields, and
|
||
registry fingerprint input.
|
||
- Create `packages/resume/src/stylesheet/registry/properties.ts`: supported property grammar, inheritance,
|
||
applicability, shorthands, structural props, and hard technical bounds.
|
||
- Create `packages/resume/src/stylesheet/registry/system-variables.ts`: read-only `--resume-*` catalog and injection.
|
||
- Create `packages/resume/src/stylesheet/selector.ts`: selector validation, specificity, and immutable-tree matching.
|
||
- Create `packages/resume/src/stylesheet/values.ts`: variables, colors, dimensions, shorthands, and forbidden functions.
|
||
- Create `packages/resume/src/stylesheet/limits.ts`: immutable v1 resource limits and failure codes.
|
||
- Create `packages/resume/src/stylesheet/cache.ts`: bounded process-local compile cache.
|
||
- Create `packages/resume/src/stylesheet/compile.ts`: versioned parse/validate/compile entrypoint.
|
||
- Create `packages/resume/src/stylesheet/cascade.ts`: media, inheritance, source order, `!important`, structural pass.
|
||
- Create `packages/resume/src/stylesheet/analyze.ts`: no-match, ineffective-property, and extreme-value warnings.
|
||
- Create `packages/resume/src/stylesheet/serialize.ts`: deterministic escaping and generated Semantic CSS output.
|
||
- Create `packages/resume/src/stylesheet/render-hash.ts`: RFC 8785 canonicalization and SHA-256.
|
||
- Create `packages/resume/src/stylesheet/render-data.ts`: private render projection for versioning/preflight plus redacted
|
||
public render projection for browser-verifiable hashing.
|
||
- Create `packages/resume/src/stylesheet/index.ts`: intentional public surface.
|
||
- Create colocated tests and `packages/resume/src/stylesheet/__fixtures__/v1/*.css`.
|
||
- Modify `packages/resume/package.json`: exports plus direct dependencies.
|
||
|
||
### `packages/db`, `packages/env`, root
|
||
|
||
- Modify `packages/db/src/schema/resume.ts`: `stylesheetRevision` and `renderDataVersion`.
|
||
- Generate a Drizzle migration and snapshot under `migrations/` using the repository generator; do not hand-name or edit
|
||
generated snapshot metadata.
|
||
- Modify `packages/env/src/server.ts`: dormant authoring/default flags.
|
||
- Modify `turbo.json`: add both flag names to `globalEnv`.
|
||
|
||
### `packages/pdf`
|
||
|
||
- Create `packages/pdf/src/semantic/node-keys.ts`: stable node key factories.
|
||
- Create `packages/pdf/src/semantic/tree.ts`: shared descriptor tree construction.
|
||
- Create `packages/pdf/src/semantic/template-manifest.ts`: typed template manifest contract and aggregate registry.
|
||
- Create `packages/pdf/src/semantic/base-styles.ts`: builder/template defaults by node key.
|
||
- Create `packages/pdf/src/semantic/adapter.ts`: resolved domain values to React PDF `Style` and primitive props.
|
||
- Create `packages/pdf/src/semantic/context.tsx`: `SemanticRenderProvider` and `useResolvedNode`.
|
||
- Create `packages/pdf/src/semantic/resolve.ts`: compile/tree/cascade/adapter orchestration and mode selection.
|
||
- Create `packages/pdf/src/semantic/preflight.tsx`: bounded browser/server candidate render.
|
||
- Create `packages/pdf/src/semantic/legacy-converter.ts`: effective legacy conversion and parity comparison.
|
||
- Create `packages/pdf/src/semantic/public-projection.ts`: source-free resolved projection.
|
||
- Create `semantic.ts` beside each of the 15 template page files for regions and template parts.
|
||
- Modify shared primitives, sections, rich text, contacts, level display, all template pages, document, browser/server
|
||
entrypoints, exports, and focused tests.
|
||
|
||
### `packages/api`, `apps/server`
|
||
|
||
- Create `packages/api/src/features/resume/stylesheet.ts`: oRPC router procedures.
|
||
- Create `packages/api/src/features/resume/stylesheet-service.ts`: transition state machine and CAS.
|
||
- Create `packages/api/src/features/resume/stylesheet-preflight.ts`: compile/preflight orchestration outside locks.
|
||
- Create `packages/api/src/features/resume/stylesheet-preservation.ts`: full-update/patch protection and render-data
|
||
versioning.
|
||
- Create `packages/api/src/features/resume/public-style-projection.ts`: public projection query and cache.
|
||
- Create `packages/api/src/features/resume/public-pdf.ts`: exported public/password-aware fallback rendering service.
|
||
- Modify resume router, DTO, service, CRUD, versions, sharing, access policy, events, flags, and tests.
|
||
- Create `apps/server/src/workers/stylesheet-preflight.ts` and `apps/server/src/services/stylesheet-preflight.ts`:
|
||
terminable resource-bounded worker and API-context runner.
|
||
- Create `apps/server/src/http/public-resume-pdf.ts`: authorized/rate-limited public fallback without changing signed
|
||
owner-download behavior in `resume-pdf.ts`.
|
||
|
||
### `apps/web`
|
||
|
||
- Create `apps/web/src/features/resume/stylesheet/protocol.ts`: structured-clone-safe worker messages.
|
||
- Create `apps/web/src/features/resume/stylesheet/stylesheet.worker.ts`: compiler/analyzer worker.
|
||
- Create `apps/web/src/features/resume/stylesheet/worker-client.ts`: worker lifecycle and generation checks.
|
||
- Create `apps/web/src/features/resume/stylesheet/store.ts`: per-resume source/applied/revision/history/mutation queue.
|
||
- Create `apps/web/src/features/resume/stylesheet/editor.tsx`: direct CodeMirror host.
|
||
- Create `apps/web/src/features/resume/stylesheet/editor-extensions.ts`: lint, completion, hover, search, and color widgets.
|
||
- Create `apps/web/src/features/resume/stylesheet/formatter.ts`: lazy Prettier command.
|
||
- Create `apps/web/src/features/resume/stylesheet/toolbar.tsx`: copy, format, reset, activate, undo/redo, focus mode.
|
||
- Create `apps/web/src/features/resume/stylesheet/status.tsx`: applied/warning/error state.
|
||
- Create `apps/web/src/features/resume/stylesheet/legacy-banner.tsx`: inactive converted draft and activation.
|
||
- Create `apps/web/src/features/resume/stylesheet/focus-mode.ts`: desktop panel resize/restore and mobile sheet state.
|
||
- Add colocated happy-dom tests.
|
||
- Replace form internals in the existing Custom Styles route section with a lazy feature shell.
|
||
- Modify builder initialization, preview/export/public rendering, and version restore reinitialization.
|
||
|
||
### Documentation and E2E
|
||
|
||
- Replace `docs/guides/using-custom-styles.mdx` with Semantic CSS authoring/migration guidance.
|
||
- Create `docs/guides/semantic-css-reference.mdx` from registry-backed generated content.
|
||
- Create Playwright specs under `tests/e2e/specs/semantic-css/`.
|
||
|
||
## Normative v1 Property Adapter Matrix
|
||
|
||
The v1 registry and PDF adapter must cover these renderer properties under kebab-case names. Registry tests enumerate
|
||
each property and its allowed semantic/primitive kinds; PDF adapter tests fail when either side lacks an entry.
|
||
|
||
| Category | Properties |
|
||
| --- | --- |
|
||
| Flexbox | `align-content`, `align-items`, `align-self`, `flex`, `flex-direction`, `flex-wrap`, `flex-flow`, `flex-grow`, `flex-shrink`, `flex-basis`, `justify-content`, `gap`, `row-gap`, `column-gap`; Semantic CSS `order` is resolved structurally before rendering. |
|
||
| Layout/position | `aspect-ratio`, `bottom`, `display`, `left`, `position`, `right`, `top`, `overflow`, `z-index`. |
|
||
| Dimensions | `width`, `height`, `min-width`, `min-height`, `max-width`, `max-height`. |
|
||
| Color | `color`, `background-color`, `opacity`. |
|
||
| Text | `direction`, `font-size`, `font-style`, `font-weight`, `letter-spacing`, `line-height`, `max-lines`, `text-align`, `text-decoration`, `text-decoration-color`, `text-decoration-style`, `text-indent`, `text-overflow`, `text-transform`, `vertical-align`. `font-family` is deliberately rejected. |
|
||
| Image | `object-fit`, `object-position`; picture-only `-resume-shadow-color` and `-resume-shadow-width` map the renderer properties already used by the template base styles. |
|
||
| Margin/padding | `margin`, `margin-top/right/bottom/left`, `margin-horizontal`, `margin-vertical`, `padding`, `padding-top/right/bottom/left`, `padding-horizontal`, `padding-vertical`. |
|
||
| Borders | `border`, `border-color/style/width`, every per-side color/style/width, and every corner radius. |
|
||
| Transform | `transform` functions `rotate`, `scale`, `scale-x/y`, `translate`, `translate-x/y`, `skew`, `skew-x/y`, `matrix`, plus `transform-origin`. |
|
||
| Pagination/structure | `display: none`, `order`, `break-before`, `break-inside`, `orphans`, `widows`, page `size`, `-resume-fixed`, `-resume-min-presence-ahead`. |
|
||
|
||
Applicability rules:
|
||
|
||
- `page`: page style plus page `size`; no text-only properties unless inherited by a text descendant.
|
||
- container nodes backed by `View`: Flexbox, layout, dimensions, colors, spacing, borders, transforms.
|
||
- text/field/name/headline/strong/mark/list content: text, color, spacing supported by the backing `Text`.
|
||
- `link`: text properties plus link container properties supported by React PDF.
|
||
- `picture`: image, dimensions, spacing, borders, transforms, and the two supported shadow extensions.
|
||
- `icon`/level decorations: adapter-supported color/opacity/size; `font-size` maps to explicit icon size as documented.
|
||
- declarations valid in the language but ineffective on the selected kind produce warnings; unknown or unsupported
|
||
properties are errors.
|
||
|
||
Explicit v1 exclusions include `font-family`, `@font-face`, `@import`, `url()`, `src`, generated `content`, Grid,
|
||
filters, blend modes, gradients, general `box-shadow`, animations, transitions, and interactive pseudo-classes.
|
||
|
||
---
|
||
|
||
### Task 1: Add Persisted Stylesheet Contracts
|
||
|
||
**Files:**
|
||
- Create: `packages/schema/src/resume/stylesheet.ts`
|
||
- Create: `packages/schema/src/resume/stylesheet.test.ts`
|
||
- Modify: `packages/schema/src/resume/data.ts`
|
||
- Modify: `packages/schema/package.json`
|
||
|
||
**Interfaces:**
|
||
- Produces: `StylesheetSource`, `SemanticStylesheet`, `StylesheetMode`,
|
||
`stylesheetSourceSchema`, `semanticStylesheetSchema`, `EMPTY_SEMANTIC_CSS_SOURCE`.
|
||
- Consumed by: compiler, API, PDF mode selection, web store, import/export.
|
||
|
||
- [ ] **Step 1: Write failing schema tests**
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { resumeDataSchema } from "./data";
|
||
import { defaultResumeData } from "./default";
|
||
import { semanticStylesheetSchema } from "./stylesheet";
|
||
|
||
describe("semanticStylesheetSchema", () => {
|
||
it("preserves separate editable and applied sources", () => {
|
||
const result = semanticStylesheetSchema.parse({
|
||
mode: "semantic",
|
||
source: { languageVersion: 1, text: "@version 1;\nsection {" },
|
||
applied: { languageVersion: 1, text: "@version 1;\nsection { color: red; }\n" },
|
||
});
|
||
|
||
expect(result.source.text).toContain("section {");
|
||
expect(result.applied.text).toContain("color: red");
|
||
});
|
||
|
||
it("keeps resumes without a stylesheet valid for legacy rendering", () => {
|
||
expect(resumeDataSchema.parse(defaultResumeData).metadata.stylesheet).toBeUndefined();
|
||
});
|
||
|
||
it("rejects non-positive language versions", () => {
|
||
expect(
|
||
semanticStylesheetSchema.safeParse({
|
||
mode: "semantic",
|
||
source: { languageVersion: 0, text: "" },
|
||
applied: { languageVersion: 1, text: "" },
|
||
}).success,
|
||
).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the focused tests and verify RED**
|
||
|
||
Run: `pnpm --filter @reactive-resume/schema test -- src/resume/stylesheet.test.ts`
|
||
|
||
Expected: FAIL on the `Not implemented` schema shell or the first stylesheet assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement the strict new schema and tolerant optional metadata field**
|
||
|
||
```ts
|
||
// packages/schema/src/resume/stylesheet.ts
|
||
import { z } from "zod";
|
||
|
||
export const EMPTY_SEMANTIC_CSS_SOURCE = "@version 1;\n";
|
||
|
||
export const stylesheetSourceSchema = z.strictObject({
|
||
languageVersion: z.number().int().positive(),
|
||
text: z.string(),
|
||
});
|
||
|
||
export const semanticStylesheetSchema = z.strictObject({
|
||
mode: z.enum(["legacy", "semantic"]),
|
||
source: stylesheetSourceSchema,
|
||
applied: stylesheetSourceSchema,
|
||
});
|
||
|
||
export type StylesheetSource = z.infer<typeof stylesheetSourceSchema>;
|
||
export type SemanticStylesheet = z.infer<typeof semanticStylesheetSchema>;
|
||
export type StylesheetMode = SemanticStylesheet["mode"];
|
||
```
|
||
|
||
Add `stylesheet: semanticStylesheetSchema.optional()` to `metadataSchema`. Do not add it to `defaultResumeData`; absence
|
||
is the backwards-compatible legacy discriminator. Add the explicit package export.
|
||
|
||
- [ ] **Step 4: Verify schema behavior and neighboring resume tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/schema test -- src/resume/stylesheet.test.ts src/resume/data.test.ts src/resume/default.test.ts
|
||
pnpm --filter @reactive-resume/schema typecheck
|
||
```
|
||
|
||
Expected: PASS with all existing `styleRules` tests unchanged.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/schema
|
||
git commit -m "feat(schema): add semantic stylesheet contracts"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Add Parser, Diagnostics, and Version Dispatch
|
||
|
||
**Files:**
|
||
- Create: `packages/resume/src/stylesheet/types.ts`
|
||
- Create: `packages/resume/src/stylesheet/diagnostics.ts`
|
||
- Create: `packages/resume/src/stylesheet/version.ts`
|
||
- Create: `packages/resume/src/stylesheet/parse.ts`
|
||
- Create: `packages/resume/src/stylesheet/compile.ts`
|
||
- Create: `packages/resume/src/stylesheet/version.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/parse.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/index.ts`
|
||
- Modify: `packages/resume/package.json`
|
||
- Modify: `pnpm-lock.yaml`
|
||
|
||
**Interfaces:**
|
||
- Produces: `compileStylesheet(source): CompileStylesheetResult`, `SemanticCssDiagnostic`, `SourceRange`,
|
||
`SUPPORTED_SEMANTIC_CSS_VERSIONS`.
|
||
- Consumes: `StylesheetSource` from Task 1.
|
||
|
||
- [ ] **Step 1: Install direct parser and specificity dependencies**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/resume add css-tree@latest @bramus/specificity@latest canonicalize@latest
|
||
pnpm --filter @reactive-resume/resume add -D fast-check@latest
|
||
```
|
||
|
||
Expected: `packages/resume/package.json` and `pnpm-lock.yaml` list direct resolved dependencies.
|
||
|
||
- [ ] **Step 2: Write failing parser/version tests**
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { compileStylesheet } from "./compile";
|
||
import { parseStylesheet } from "./parse";
|
||
|
||
describe("compileStylesheet", () => {
|
||
it("compiles canonical version-one source", () => {
|
||
const result = compileStylesheet({
|
||
languageVersion: 1,
|
||
text: "@version 1;\nsection { color: #123456; }\n",
|
||
});
|
||
|
||
expect(result.program?.languageVersion).toBe(1);
|
||
expect(result.diagnostics).toEqual([]);
|
||
});
|
||
|
||
it("warns when version-one source omits the directive", () => {
|
||
const result = compileStylesheet({ languageVersion: 1, text: "section { color: red; }" });
|
||
|
||
expect(result.diagnostics).toContainEqual(
|
||
expect.objectContaining({ code: "MISSING_VERSION_DIRECTIVE", severity: "warning" }),
|
||
);
|
||
});
|
||
|
||
it("rejects a directive that disagrees with persisted metadata", () => {
|
||
const result = compileStylesheet({ languageVersion: 1, text: "@version 2;" });
|
||
|
||
expect(result.program).toBeNull();
|
||
expect(result.diagnostics).toContainEqual(
|
||
expect.objectContaining({ code: "VERSION_MISMATCH", severity: "error" }),
|
||
);
|
||
});
|
||
|
||
it("returns exact ranges for malformed declarations and keeps a recoverable parse tree", () => {
|
||
const result = parseStylesheet("@version 1;\nsection { color red; }\nitem { opacity: .5; }");
|
||
|
||
expect(result.diagnostics[0]?.range.start.line).toBe(2);
|
||
expect(result.rules).toHaveLength(2);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Verify RED**
|
||
|
||
Run: `pnpm --filter @reactive-resume/resume test -- src/stylesheet/version.test.ts src/stylesheet/parse.test.ts`
|
||
|
||
Expected: FAIL on the parser/compiler sentinel or the first version/range assertion, not on module resolution.
|
||
|
||
- [ ] **Step 4: Implement the CSSTree adapter and immutable v1 dispatch**
|
||
|
||
```ts
|
||
export type DiagnosticSeverity = "error" | "warning";
|
||
|
||
export type SourcePosition = {
|
||
line: number;
|
||
column: number;
|
||
offset: number;
|
||
};
|
||
|
||
export type SourceRange = {
|
||
start: SourcePosition;
|
||
end: SourcePosition;
|
||
};
|
||
|
||
export type SemanticCssDiagnostic = {
|
||
code: string;
|
||
severity: DiagnosticSeverity;
|
||
message: string;
|
||
range: SourceRange;
|
||
};
|
||
|
||
export type CompileStylesheetResult = {
|
||
program: StyleProgram | null;
|
||
diagnostics: readonly SemanticCssDiagnostic[];
|
||
};
|
||
```
|
||
|
||
Configure CSSTree with `positions: true`, `parseCustomProperty: true`, `onParseError`, and token/comment callbacks.
|
||
Convert `Raw` nodes into errors without replacing raw source. Parse `@version` as a top-level at-rule: generated
|
||
source has exactly one directive; v1 hand-written source without one compiles with a warning; duplicates, non-positive
|
||
values, unsupported values, and metadata mismatches block the program.
|
||
|
||
- [ ] **Step 5: Verify parser tests, Unicode ranges, browser-safe imports, and typecheck**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/resume test -- src/stylesheet/version.test.ts src/stylesheet/parse.test.ts
|
||
pnpm --filter @reactive-resume/resume typecheck
|
||
```
|
||
|
||
Expected: PASS; diagnostics use one-based line/column and zero-based offsets consistently.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/resume/package.json packages/resume/src/stylesheet pnpm-lock.yaml
|
||
git commit -m "feat(resume): add Semantic CSS parser and versioning"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Add Semantic, Property, and System-Variable Registries
|
||
|
||
**Files:**
|
||
- Create: `packages/resume/src/stylesheet/registry/semantic.ts`
|
||
- Create: `packages/resume/src/stylesheet/registry/properties.ts`
|
||
- Create: `packages/resume/src/stylesheet/registry/system-variables.ts`
|
||
- Create: `packages/resume/src/stylesheet/registry/semantic.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/registry/properties.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/registry/system-variables.test.ts`
|
||
- Modify: `packages/resume/src/stylesheet/types.ts`
|
||
- Modify: `packages/resume/src/stylesheet/index.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `SemanticNode`, `SemanticNodeKind`, `SEMANTIC_REGISTRY_V1`, `PROPERTY_REGISTRY_V1`,
|
||
`SYSTEM_VARIABLE_REGISTRY_V1`, `createSystemVariables(base, page)`.
|
||
- Consumed by: selector compiler, cascade, PDF tree builder, editor completion/docs.
|
||
|
||
- [ ] **Step 1: Write failing registry tests**
|
||
|
||
```ts
|
||
it("injects builder values without exposing fonts or assets", () => {
|
||
const variables = createSystemVariables(baseSettings, { width: 595.28, height: 841.89 });
|
||
|
||
expect(variables["--resume-primary-color"]).toBe(baseSettings.design.colors.primary);
|
||
expect(variables["--resume-sidebar-width"]).toBe(`${baseSettings.layout.sidebarWidth}%`);
|
||
expect(variables["--resume-page-width"]).toBe("595.28pt");
|
||
expect(Object.keys(variables)).not.toContain("--resume-font-family");
|
||
expect(Object.keys(variables)).not.toContain("--resume-picture-url");
|
||
});
|
||
|
||
it("registers every stable semantic node and relationship", () => {
|
||
expect(canContainNode("resume", "page")).toBe(true);
|
||
expect(canContainNode("section", "item")).toBe(false);
|
||
expect(canContainNode("section-items", "item")).toBe(true);
|
||
});
|
||
|
||
it("rejects unsupported browser and asset properties", () => {
|
||
expect(PROPERTY_REGISTRY_V1["font-family"]).toBeUndefined();
|
||
expect(PROPERTY_REGISTRY_V1["background-image"]).toBeUndefined();
|
||
expect(PROPERTY_REGISTRY_V1.color?.appliesTo).toContain("field");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/resume test -- src/stylesheet/registry/semantic.test.ts src/stylesheet/registry/properties.test.ts src/stylesheet/registry/system-variables.test.ts
|
||
```
|
||
|
||
Expected: FAIL on the registry sentinel or the first catalog/parentage assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement typed registries**
|
||
|
||
Register these semantic kinds:
|
||
|
||
```ts
|
||
export type SemanticNodeKind =
|
||
| "resume"
|
||
| "page"
|
||
| "region"
|
||
| "header"
|
||
| "picture"
|
||
| "name"
|
||
| "headline"
|
||
| "contact-list"
|
||
| "contact-item"
|
||
| "section"
|
||
| "section-heading"
|
||
| "section-items"
|
||
| "item"
|
||
| "item-header"
|
||
| "field"
|
||
| "link"
|
||
| "icon"
|
||
| "level"
|
||
| "rich-text"
|
||
| "rich-heading"
|
||
| "blockquote"
|
||
| "paragraph"
|
||
| "list"
|
||
| "list-item"
|
||
| "list-item-content"
|
||
| "list-marker"
|
||
| "strong"
|
||
| "emphasis"
|
||
| "underline"
|
||
| "strike"
|
||
| "code"
|
||
| "text-span"
|
||
| "mark"
|
||
| "hard-break"
|
||
| "horizontal-rule"
|
||
| "template-part";
|
||
```
|
||
|
||
The property registry covers React PDF-supported Flexbox, layout, dimensions, colors, text properties except
|
||
`font-family`, spacing, borders, transforms, object fit/position, and the structural properties `display`, `order`,
|
||
`break-before`, `break-inside`, `orphans`, `widows`, `size`, `-resume-fixed`, and `-resume-min-presence-ahead`.
|
||
|
||
Inject exactly these v1 reserved variables:
|
||
|
||
```text
|
||
--resume-primary-color
|
||
--resume-text-color
|
||
--resume-background-color
|
||
--resume-body-font-size
|
||
--resume-body-line-height
|
||
--resume-heading-font-size
|
||
--resume-heading-line-height
|
||
--resume-page-gap-x
|
||
--resume-page-gap-y
|
||
--resume-page-margin-x
|
||
--resume-page-margin-y
|
||
--resume-page-width
|
||
--resume-page-height
|
||
--resume-sidebar-width
|
||
--resume-picture-size
|
||
--resume-picture-rotation
|
||
--resume-picture-aspect-ratio
|
||
--resume-picture-border-radius
|
||
--resume-picture-border-width
|
||
--resume-picture-border-color
|
||
--resume-picture-shadow-width
|
||
--resume-picture-shadow-color
|
||
```
|
||
|
||
- [ ] **Step 4: Verify registries and export-map boundaries**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/resume test -- src/stylesheet/registry
|
||
pnpm --filter @reactive-resume/resume typecheck
|
||
pnpm exec turbo boundaries
|
||
```
|
||
|
||
Expected: PASS; `packages/resume` remains `runtime:universal` and `role:domain`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/resume/src/stylesheet
|
||
git commit -m "feat(resume): define Semantic CSS registries and system tokens"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Implement Selector Validation, Specificity, and Matching
|
||
|
||
**Files:**
|
||
- Create: `packages/resume/src/stylesheet/selector.ts`
|
||
- Create: `packages/resume/src/stylesheet/selector.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/__fixtures__/v1/selectors.css`
|
||
- Modify: `packages/resume/src/stylesheet/compile.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `CompiledSelector`, `Specificity`, `compileSelector`, `matchesSelector`.
|
||
- Consumes: parsed CSSTree selectors and the semantic registry.
|
||
|
||
- [ ] **Step 1: Write failing selector tests**
|
||
|
||
```ts
|
||
it.each([
|
||
["section[type=\"experience\"] > section-heading", "heading-experience", true],
|
||
["region[placement=\"sidebar\"] section", "section-skills-sidebar", true],
|
||
["section:is([type=\"experience\"], [type=\"education\"])", "section-education", true],
|
||
["item:nth-child(2)", "item-second", true],
|
||
["section:hover", "section-experience", false],
|
||
])("matches %s against the immutable semantic tree", (selector, nodeKey, expected) => {
|
||
const result = compileSelector(selector);
|
||
if (!result.selector) return expect(expected).toBe(false);
|
||
expect(matchesSelector(result.selector, fixtureTree, nodeKey)).toBe(expected);
|
||
});
|
||
|
||
it("gives :where zero specificity and :is its most specific argument", () => {
|
||
expect(getSpecificity(":where(#one) section")).toEqual([0, 0, 1]);
|
||
expect(getSpecificity(":is(#one, section)")).toEqual([1, 0, 0]);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run: `pnpm --filter @reactive-resume/resume test -- src/stylesheet/selector.test.ts`
|
||
|
||
Expected: FAIL on the selector sentinel or a matching/specificity assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement allowed selectors and immutable-tree matching**
|
||
|
||
Support type/universal selectors, reflected `#id`, attribute operators, selector lists, descendant/child/adjacent/general
|
||
sibling combinators, the `:root` alias for the `resume` node, `:is`, `:where`, `:not`, `:first-child`, `:last-child`,
|
||
`:only-child`, `:nth-child`, and `:nth-of-type`. Reject pseudo-elements, interactive pseudo-classes, custom classes,
|
||
unknown elements/attributes/roles, and nested complexity beyond registry limits.
|
||
|
||
Use `calculateForAST` only after Semantic CSS validation. Store specificity as a plain `[ids, classes, types]` tuple so the
|
||
compiled program remains structured-clone-safe and independent of library classes.
|
||
|
||
- [ ] **Step 4: Verify selector behavior and dependency compatibility**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/resume test -- src/stylesheet/selector.test.ts
|
||
pnpm --filter @reactive-resume/resume typecheck
|
||
```
|
||
|
||
Expected: PASS, including CSS escapes, UUID attribute selectors, `role~=` token matching, and stable source-order sibling
|
||
positions.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/resume/src/stylesheet
|
||
git commit -m "feat(resume): add semantic selector matching"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Implement Values, Cascade, Media, and Structural Resolution
|
||
|
||
**Files:**
|
||
- Create: `packages/resume/src/stylesheet/values.ts`
|
||
- Create: `packages/resume/src/stylesheet/limits.ts`
|
||
- Create: `packages/resume/src/stylesheet/cache.ts`
|
||
- Create: `packages/resume/src/stylesheet/cascade.ts`
|
||
- Create: `packages/resume/src/stylesheet/analyze.ts`
|
||
- Create: `packages/resume/src/stylesheet/values.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/cascade.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/analyze.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/__fixtures__/v1/portable-theme.css`
|
||
- Modify: `packages/resume/src/stylesheet/compile.ts`
|
||
- Modify: `packages/resume/src/stylesheet/index.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `resolveStylesheet(program, tree, context): ResolveStylesheetResult`,
|
||
`analyzeStylesheet(program, tree)`.
|
||
- Consumes: Tasks 2–4.
|
||
|
||
- [ ] **Step 1: Write failing cascade tests**
|
||
|
||
```ts
|
||
it("resolves template base, normal rules, important rules, specificity, and source order", () => {
|
||
const result = resolveFixture(`
|
||
@version 1;
|
||
section-heading { color: red; }
|
||
section[type="experience"] > section-heading { color: blue !important; }
|
||
section#experience > section-heading { color: green; }
|
||
`);
|
||
|
||
expect(result.nodes["heading-experience"]?.style.color).toBe("blue");
|
||
});
|
||
|
||
it("keeps positional selectors based on source order before hiding and ordering", () => {
|
||
const result = resolveFixture(`
|
||
@version 1;
|
||
item:nth-child(2) { display: none; }
|
||
item:last-child { order: -1; }
|
||
`);
|
||
|
||
expect(result.renderTree.children.map((node) => node.key)).toEqual(["item-3", "item-1"]);
|
||
});
|
||
|
||
it("resolves read-only system variables in an otherwise valid program", () => {
|
||
const result = resolveFixture(`
|
||
@version 1;
|
||
:root { --accent: var(--resume-primary-color); }
|
||
name { color: var(--accent); }
|
||
`);
|
||
|
||
expect(result.nodes.name?.style.color).toBe(fixtureBase.design.colors.primary);
|
||
});
|
||
|
||
it("blocks the entire program when reserved system variables are reassigned", () => {
|
||
const result = compileStylesheet({
|
||
languageVersion: 1,
|
||
text: "@version 1; :root { --resume-primary-color: red; } name { color: blue; }",
|
||
});
|
||
|
||
expect(result.program).toBeNull();
|
||
expect(result.diagnostics).toContainEqual(
|
||
expect.objectContaining({ code: "SYSTEM_VARIABLE_READONLY", severity: "error" }),
|
||
);
|
||
});
|
||
|
||
it("allows small but technically renderable values and leaves aesthetics to warnings", () => {
|
||
const result = compileStylesheet({
|
||
languageVersion: 1,
|
||
text: "@version 1; field { font-size: 3pt; }",
|
||
});
|
||
|
||
expect(result.program).not.toBeNull();
|
||
expect(result.diagnostics).toContainEqual(
|
||
expect.objectContaining({ code: "EXTREME_VALUE", severity: "warning" }),
|
||
);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/resume test -- src/stylesheet/values.test.ts src/stylesheet/cascade.test.ts src/stylesheet/analyze.test.ts
|
||
```
|
||
|
||
Expected: FAIL on the cascade sentinel or a resolved-value/structure assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement value normalization and two-pass resolution**
|
||
|
||
Implement:
|
||
|
||
1. Parse/expand shorthands and custom properties.
|
||
2. Resolve `var()` with fallbacks and cycle diagnostics.
|
||
3. Validate again after expansion so `url()` cannot hide inside variables/shorthands.
|
||
4. Convert `pt`, `in`, `mm`, `cm`, `px`, `%`, `vw`, `vh`, `em`, and `rem` to normalized domain values.
|
||
5. Resolve non-media page `size`, then media width/height/orientation; reject `size` inside media.
|
||
6. Match/cascade on the immutable source tree.
|
||
7. Calculate inheritance and CSS-wide keywords.
|
||
8. Apply `display: none` and stable `order` once to produce `renderTree`.
|
||
9. Return warnings separately from compiler errors.
|
||
10. Return `program: null` for any error and never invoke cascade/preflight for that candidate.
|
||
|
||
```ts
|
||
export type ResolveStylesheetInput = {
|
||
program: StyleProgram;
|
||
tree: SemanticNode;
|
||
baseStyles: Readonly<Record<string, ResolvedNodeStyle>>;
|
||
baseSettings: BaseSettingsSnapshot;
|
||
pages: readonly AuthoredPageContext[];
|
||
};
|
||
|
||
export type ResolveStylesheetResult = {
|
||
nodes: Readonly<Record<string, ResolvedNodeStyle>>;
|
||
renderTree: SemanticNode;
|
||
diagnostics: readonly SemanticCssDiagnostic[];
|
||
};
|
||
```
|
||
|
||
Define these shared plain-value types in `types.ts` before implementing the resolver:
|
||
|
||
```ts
|
||
export type ResolvedNodeStyle = {
|
||
style: Readonly<Record<string, string | number>>;
|
||
structural: StructuralPresentation;
|
||
hidden: boolean;
|
||
order: number;
|
||
};
|
||
|
||
export type ResolvedPageSize = "A4" | "LETTER" | { width: number; height?: number };
|
||
|
||
export type StructuralPresentation = {
|
||
breakBefore?: "page";
|
||
breakInside?: "avoid";
|
||
fixed?: boolean;
|
||
minPresenceAhead?: number;
|
||
orphans?: number;
|
||
widows?: number;
|
||
pageSize?: ResolvedPageSize;
|
||
};
|
||
|
||
export type BaseSettingsSnapshot = Pick<ResumeData, "picture"> & {
|
||
template: Template;
|
||
design: Design;
|
||
typography: Typography;
|
||
page: Page;
|
||
layout: Pick<Layout, "sidebarWidth">;
|
||
};
|
||
```
|
||
|
||
Freeze these v1 technical limits in `limits.ts` and return `RESOURCE_LIMIT` diagnostics when exceeded:
|
||
|
||
```ts
|
||
export const SEMANTIC_CSS_LIMITS_V1 = {
|
||
maxSourceBytes: 128 * 1024,
|
||
maxRules: 1_024,
|
||
maxDeclarations: 8_192,
|
||
maxSelectorsPerRule: 64,
|
||
maxSelectorCodePoints: 2_048,
|
||
maxCombinatorsPerSelector: 16,
|
||
maxFunctionDepth: 16,
|
||
maxVariableExpansionDepth: 32,
|
||
maxMediaNesting: 4,
|
||
maxSemanticNodes: 20_000,
|
||
maxAbsoluteLengthPt: 100_000,
|
||
} as const;
|
||
```
|
||
|
||
Implement a process-local LRU of at most 128 compiled programs and 16 MiB estimated serialized size, keyed by language
|
||
version, source hash, compiler build ID, and registry fingerprint. Add `fast-check` properties for malformed Unicode,
|
||
escaped/case-varied `@import`/`@font-face`/`url()`/`src`, nested variables, and shorthand expansion; any error must return
|
||
no program and must not throw or hang.
|
||
|
||
- [ ] **Step 4: Verify focused and complete compiler suites**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/resume test -- src/stylesheet
|
||
pnpm --filter @reactive-resume/resume typecheck
|
||
```
|
||
|
||
Expected: PASS for cycles, fallback values, `!important`, inheritance, `revert`, units, media, structural directives,
|
||
resource limits, cache eviction, attack corpus, fuzz properties, and no-match warnings.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/resume/src/stylesheet
|
||
git commit -m "feat(resume): resolve Semantic CSS cascade and structure"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Add Deterministic Serialization and Public Render Hashing
|
||
|
||
**Files:**
|
||
- Create: `packages/resume/src/stylesheet/serialize.ts`
|
||
- Create: `packages/resume/src/stylesheet/render-hash.ts`
|
||
- Create: `packages/resume/src/stylesheet/render-data.ts`
|
||
- Create: `packages/resume/src/stylesheet/serialize.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/render-hash.test.ts`
|
||
- Create: `packages/resume/src/stylesheet/render-data.test.ts`
|
||
- Modify: `packages/resume/src/stylesheet/index.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `serializeGeneratedStylesheet`, `escapeCssComment`, `escapeCssString`, `projectRenderData`,
|
||
`projectPublicRenderData`, `computeRenderDataHash`.
|
||
- Consumed by: converter, public projection, browser validation.
|
||
|
||
- [ ] **Step 1: Write failing serializer/hash tests**
|
||
|
||
```ts
|
||
it("serializes generated Semantic CSS deterministically and safely", () => {
|
||
const output = serializeGeneratedStylesheet({
|
||
languageVersion: 1,
|
||
blocks: [
|
||
{
|
||
comment: "Bad */ label",
|
||
selector: 'section[id="projects"] > section-heading',
|
||
declarations: { color: "#123456", fontSize: "12pt" },
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(output).toBe(
|
||
'@version 1;\n\n/* Bad *\\/ label */\nsection[id="projects"] > section-heading {\n\tcolor: #123456;\n\tfont-size: 12pt;\n}\n',
|
||
);
|
||
});
|
||
|
||
it("hashes logically equivalent public render inputs identically", async () => {
|
||
const first = await computeRenderDataHash({ domainVersion: 1, data: { b: 2, a: 1 } });
|
||
const second = await computeRenderDataHash({ domainVersion: 1, data: { a: 1, b: 2 } });
|
||
|
||
expect(first).toBe(second);
|
||
});
|
||
|
||
it("separates resume render-data identity from stylesheet revision identity", () => {
|
||
const legacy = projectRenderData(legacyData);
|
||
const semanticPrivate = projectRenderData(semanticData);
|
||
const semanticPublic = projectPublicRenderData(semanticData);
|
||
|
||
expect(legacy.metadata.styleRules).toEqual(legacyData.metadata.styleRules);
|
||
expect(semanticPrivate.metadata.styleRules).toBeUndefined();
|
||
expect(semanticPrivate.metadata.stylesheet).toBeUndefined();
|
||
expect(semanticPublic.metadata.styleRules).toBeUndefined();
|
||
expect(semanticPublic.metadata.stylesheet).toBeUndefined();
|
||
expect(semanticPublic.metadata.notes).toBeUndefined();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/resume test -- src/stylesheet/serialize.test.ts src/stylesheet/render-hash.test.ts src/stylesheet/render-data.test.ts
|
||
```
|
||
|
||
Expected: FAIL on the serializer/hash sentinel or deterministic output assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement canonical output and domain-separated SHA-256**
|
||
|
||
Use `canonicalize` for RFC 8785 and hash UTF-8 bytes with Web Crypto. Prefix the canonical payload with
|
||
`reactive-resume:public-style-projection:v1\0`. Reject non-I-JSON values before canonicalization.
|
||
|
||
Implement both projections by constructing known schema fields rather than spreading the loose input. Include picture,
|
||
basics, summary, sections, custom sections, template/layout/page/design/typography, and:
|
||
|
||
- private legacy mode: active `styleRules`, no stylesheet data;
|
||
- private semantic mode: neither stylesheet data nor inactive legacy rules;
|
||
- public semantic mode: neither stylesheet source nor inactive legacy rules; resolved projection nodes carry styling;
|
||
- every mode: no notes, dashboard metadata, diagnostics, unknown loose-schema fields, or server revisions.
|
||
|
||
Use `projectRenderData` only for `renderDataVersion`, which tracks content/base settings and active legacy rules.
|
||
Semantic source/applied/mode changes increment only `stylesheetRevision`; preflight CAS checks both columns. Compute the
|
||
public hash over `projectPublicRenderData(data)` plus the source-free resolved nodes and projection fingerprints. The
|
||
browser has every hash input; `applied` and `stylesheetRevision` are never part of the public hash.
|
||
|
||
- [ ] **Step 4: Verify Node test vectors and browser-compatible typecheck**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/resume test -- src/stylesheet/serialize.test.ts src/stylesheet/render-hash.test.ts src/stylesheet/render-data.test.ts
|
||
pnpm --filter @reactive-resume/resume typecheck
|
||
```
|
||
|
||
Expected: PASS against RFC 8785 vectors, mode-sensitive render projections, unknown-field exclusion, and domain-version
|
||
mismatch cases.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/resume/src/stylesheet
|
||
git commit -m "feat(resume): serialize and hash Semantic CSS output"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Deploy Dormant Persistence Safety and Feature Flags
|
||
|
||
**Files:**
|
||
- Modify: `packages/db/src/schema/resume.ts`
|
||
- Generated: Drizzle migration and snapshot under `migrations/`
|
||
- Create: `packages/api/src/features/resume/stylesheet-preservation.ts`
|
||
- Create: `packages/api/src/features/resume/stylesheet-preservation.test.ts`
|
||
- Modify: `packages/api/src/features/resume/service.ts`
|
||
- Modify: `packages/api/src/features/resume/service.test.ts`
|
||
- Modify: `packages/api/src/features/resume/crud.ts`
|
||
- Modify: `packages/api/src/features/resume/versions.ts`
|
||
- Modify: `packages/api/src/dto/resume.ts`
|
||
- Modify: `packages/api/src/dto/resume.test.ts`
|
||
- Modify: `packages/env/src/server.ts`
|
||
- Modify: `packages/api/src/features/flags/router.ts`
|
||
- Modify: `turbo.json`
|
||
|
||
**Interfaces:**
|
||
- Produces DB columns `stylesheetRevision`, `renderDataVersion`; helpers
|
||
`preserveServerStylesheet`, `hasRenderDataChanged`.
|
||
- Consumed by: dedicated stylesheet mutation and editor state.
|
||
|
||
- [ ] **Step 1: Write failing preservation/version tests**
|
||
|
||
```ts
|
||
it("preserves server-owned stylesheet when an old client replaces resume data", () => {
|
||
const merged = preserveServerStylesheet(serverData, clientDataWithoutStylesheet);
|
||
|
||
expect(merged.metadata.stylesheet).toEqual(serverData.metadata.stylesheet);
|
||
});
|
||
|
||
it("increments render-data version for visual/content changes but not notes or stylesheet drafts", () => {
|
||
expect(hasRenderDataChanged(serverData, withChangedExperience)).toBe(true);
|
||
expect(hasRenderDataChanged(serverData, withChangedNotes)).toBe(false);
|
||
expect(hasRenderDataChanged(serverData, withChangedStylesheetOnly)).toBe(false);
|
||
});
|
||
|
||
it("keeps server concurrency columns out of ordinary resume DTOs", () => {
|
||
const dto = toResume(rowWithStylesheetColumns, false);
|
||
expect(dto).not.toHaveProperty("stylesheetRevision");
|
||
expect(dto).not.toHaveProperty("renderDataVersion");
|
||
});
|
||
|
||
it("seeds empty semantic source only for the default-enabled cohort", () => {
|
||
expect(createResumeData({ semanticCssDefault: true }).metadata.stylesheet).toEqual({
|
||
mode: "semantic",
|
||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||
});
|
||
expect(createResumeData({ semanticCssDefault: false }).metadata.stylesheet).toBeUndefined();
|
||
});
|
||
|
||
it("rejects active semantic imports while the dormant backend cannot validate them", async () => {
|
||
await expect(importResume(activeSemanticImport)).rejects.toMatchObject({
|
||
code: "SEMANTIC_STYLESHEET_UNAVAILABLE",
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/api test -- src/features/resume/stylesheet-preservation.test.ts src/features/resume/service.test.ts src/dto/resume.test.ts
|
||
```
|
||
|
||
Expected: FAIL on a preservation/version/DTO assertion after typed shells compile.
|
||
|
||
- [ ] **Step 3: Add columns and generate migration**
|
||
|
||
```ts
|
||
stylesheetRevision: pg.integer("stylesheet_revision").notNull().default(0),
|
||
renderDataVersion: pg.integer("render_data_version").notNull().default(0),
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
dotenvx run -f .env.local -- pnpm db:generate
|
||
git status --short migrations packages/db/src/schema/resume.ts
|
||
```
|
||
|
||
Expected: one generated migration directory containing `migration.sql` and `snapshot.json`; SQL adds both non-null
|
||
integer columns with zero defaults and performs no JSONB backfill.
|
||
|
||
- [ ] **Step 4: Refactor generic update/patch to preserve stylesheet atomically**
|
||
|
||
Use a short transaction with `SELECT … FOR UPDATE`, merge the server stylesheet into submitted data, compare the
|
||
output of `projectRenderData`, and increment `renderDataVersion` only when that projection changes. Reject JSON Patch
|
||
paths under `/metadata/stylesheet`; allow legacy `/metadata/styleRules` only while mode is legacy. Ordinary owner/public
|
||
resume DTOs explicitly omit both concurrency columns; only the dedicated stylesheet envelope exposes them.
|
||
|
||
- [ ] **Step 5: Add dormant flags**
|
||
|
||
```ts
|
||
FLAG_SEMANTIC_CSS_AUTHORING: z.stringbool().default(false),
|
||
FLAG_SEMANTIC_CSS_DEFAULT: z.stringbool().default(false),
|
||
```
|
||
|
||
Add both variables to `turbo.json.globalEnv`, expose `semanticCssAuthoring` and `semanticCssDefault`, and keep both false
|
||
by default.
|
||
|
||
Update resume creation/sample-data paths so `semanticCssDefault` seeds both empty v1 sources and semantic mode for new
|
||
resumes in the enabled cohort. Imported data keeps its imported/missing mode after validation rather than inheriting the
|
||
instance default. During this dormant task, reject imports containing `metadata.stylesheet` with
|
||
`SEMANTIC_STYLESHEET_UNAVAILABLE`; Task 13 replaces that temporary rejection with compiler/applied-source validation and
|
||
preflight before semantic imports are accepted.
|
||
|
||
- [ ] **Step 6: Verify migration, API behavior, flags, and boundaries**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/api test -- src/features/resume/stylesheet-preservation.test.ts src/features/resume/service.test.ts src/dto/resume.test.ts
|
||
pnpm --filter @reactive-resume/api typecheck
|
||
pnpm --filter @reactive-resume/db typecheck
|
||
pnpm exec turbo boundaries
|
||
```
|
||
|
||
Expected: PASS; old-client updates cannot remove stylesheet state and no client can activate Semantic CSS yet.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add packages/db migrations packages/api packages/env turbo.json
|
||
git commit -m "feat(api): preserve dormant semantic stylesheet state"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Build the Shared Semantic Descriptor Tree
|
||
|
||
**Files:**
|
||
- Create: `packages/pdf/src/semantic/node-keys.ts`
|
||
- Create: `packages/pdf/src/semantic/binding-inventory.ts`
|
||
- Create: `packages/pdf/src/semantic/tree.ts`
|
||
- Create: `packages/pdf/src/semantic/tree.test.ts`
|
||
- Create: `packages/pdf/src/semantic/binding-inventory.test.ts`
|
||
- Create: `packages/pdf/src/semantic/field-registry.test.ts`
|
||
- Modify: `packages/pdf/package.json`
|
||
- Modify: `packages/pdf/src/templates/shared/rich-text-html.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `buildSemanticTree`, stable node-key factories, standard field/role registry.
|
||
- Consumes: `ResumeData`, layout page, template, normalized rich text, semantic registry.
|
||
|
||
- [ ] **Step 1: Add the explicit workspace dependency**
|
||
|
||
Run: `pnpm --filter @reactive-resume/pdf add "@reactive-resume/resume@workspace:*"`
|
||
|
||
Expected: PDF package and lockfile directly declare the domain compiler package.
|
||
|
||
- [ ] **Step 2: Write failing tree tests for shared content**
|
||
|
||
```ts
|
||
it("builds stable section, item, field, and rich-text ancestry", () => {
|
||
const tree = buildSemanticTree({
|
||
data: sampleResumeData,
|
||
template: "onyx",
|
||
page: sampleResumeData.metadata.layout.pages[0]!,
|
||
pageNumber: 1,
|
||
showHeader: true,
|
||
});
|
||
|
||
expect(getNode(tree, "page-1/region-main/section-experience")?.attributes.type).toBe("experience");
|
||
expect(findNodes(tree, { kind: "field", name: "company" })[0]?.roles).toContain("primary-text");
|
||
expect(findNodes(tree, { kind: "list-marker" }).length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("represents nested experience roles as semantic item children", () => {
|
||
const role = findNodeById(buildFixtureTree(), "experience-role-id");
|
||
expect(role?.kind).toBe("item");
|
||
expect(role?.roles).toContain("experience-role");
|
||
});
|
||
|
||
it("maps every semantic node to an existing primitive or an explicit alias", () => {
|
||
const inventory = createBindingInventory(buildFixtureTree());
|
||
|
||
expect(inventory.unboundNodeKeys).toEqual([]);
|
||
expect(inventory.syntheticWrapperCount).toBe(0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Verify RED**
|
||
|
||
Run: `pnpm --filter @reactive-resume/pdf test -- src/semantic/tree.test.ts`
|
||
|
||
Expected: FAIL on the tree-builder sentinel or the first ancestry/binding assertion, not on module resolution.
|
||
|
||
- [ ] **Step 4: Implement shared tree construction with no React imports**
|
||
|
||
Build:
|
||
|
||
```text
|
||
resume
|
||
page[page-number]
|
||
region
|
||
header → picture/name/headline/contact-list/contact-item
|
||
section[id][type][placement][origin]
|
||
section-heading
|
||
section-items
|
||
item[id]
|
||
item-header
|
||
field[name][role]
|
||
link/icon/level
|
||
rich-text
|
||
rich-heading[level]
|
||
blockquote
|
||
paragraph
|
||
list
|
||
list-item
|
||
list-marker
|
||
list-item-content
|
||
link/strong/emphasis/underline/strike/code/text-span/mark/hard-break/horizontal-rule
|
||
```
|
||
|
||
Use one stable path key per semantic node. Normalize rich-text HTML into a semantic subtree using the same normalization
|
||
input as `react-pdf-html`; custom rich-text renderers bind those keys to existing paragraph/list/item/marker/content/link/
|
||
strong/mark output. Do not add renderer wrappers.
|
||
|
||
Before accepting a node or template part, add it to `binding-inventory.ts` with one of two bindings:
|
||
|
||
1. `primitive`: exactly one existing React PDF primitive receives the node key;
|
||
2. `alias`: the selector identity is reflected as a `part~=`/role token on an existing canonical node.
|
||
|
||
Aliases do not create parent/child nodes and cannot receive independent structural ordering. Remove a proposed target if
|
||
neither binding is truthful. In particular, Bronzor's interleaved row is an alias on the existing section primitive, not
|
||
a synthetic `template-part` child.
|
||
|
||
- [ ] **Step 5: Verify all built-in/custom section field names**
|
||
|
||
The tests must cover:
|
||
|
||
```text
|
||
profiles: network, username
|
||
experience: company, position, location, period, description, nested roles
|
||
education: school, area, degree, grade, location, period, description
|
||
projects: name, period, description
|
||
skills: name, proficiency, keywords
|
||
languages: language, fluency
|
||
interests: name, keywords
|
||
awards: title, date, awarder, description
|
||
certifications: title, date, issuer, description
|
||
publications: title, date, publisher, description
|
||
volunteer: organization, location, period, description
|
||
references: name, position, phone, description
|
||
summary and cover-letter: rich text
|
||
```
|
||
|
||
Also cover:
|
||
|
||
- empty/conditional email, phone, location, website, and custom contact fields;
|
||
- structured item websites and inline website links;
|
||
- section-heading icons, item icons, level containers, active/inactive level decorations;
|
||
- rich heading levels, blockquote, paragraph/list/list-item/marker/content, link, strong, emphasis, underline, strike,
|
||
inline code, colored text spans, mark/highlight, hard break, and horizontal-rule nodes;
|
||
- cover-letter recipient and content;
|
||
- hidden sections/items and entries removed by existing filtering;
|
||
- custom sections of every supported type;
|
||
- one section referenced on different authored pages without node-key collision.
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/tree.test.ts src/semantic/field-registry.test.ts src/semantic/binding-inventory.test.ts
|
||
pnpm --filter @reactive-resume/pdf typecheck
|
||
```
|
||
|
||
Expected: PASS with deterministic node keys across repeated builds.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/pdf pnpm-lock.yaml
|
||
git commit -m "feat(pdf): build shared semantic resume tree"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Register All Template Regions and Parts
|
||
|
||
**Files:**
|
||
- Create: `packages/pdf/src/semantic/template-manifest.ts`
|
||
- Create: `packages/pdf/src/semantic/template-manifest.test.ts`
|
||
- Create: `packages/pdf/src/templates/azurill/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/bronzor/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/chikorita/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/ditgar/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/ditto/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/gengar/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/glalie/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/kakuna/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/lapras/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/leafish/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/meowth/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/onyx/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/pikachu/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/rhyhorn/semantic.ts`
|
||
- Create: `packages/pdf/src/templates/scizor/semantic.ts`
|
||
- Modify: `packages/pdf/src/templates/index.ts`
|
||
- Modify: `packages/pdf/src/semantic/tree.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `TemplateSemanticManifest`, `getTemplateSemanticManifest`, registry fingerprint input.
|
||
- Consumed by: tree builder, editor completion/docs, adapter coverage.
|
||
|
||
- [ ] **Step 1: Write failing exhaustiveness and placement tests**
|
||
|
||
```ts
|
||
it.each(templateSchema.options)("%s publishes a semantic manifest", (template) => {
|
||
expect(getTemplateSemanticManifest(template)).toBeDefined();
|
||
});
|
||
|
||
it.each(["bronzor", "scizor"] as const)("%s preserves layout origin separately from physical placement", (template) => {
|
||
const tree = buildTemplateFixtureTree(template);
|
||
const sidebarOrigin = findNodes(tree, { kind: "section", origin: "sidebar" });
|
||
|
||
expect(sidebarOrigin.length).toBeGreaterThan(0);
|
||
expect(sidebarOrigin.every((node) => node.attributes.placement === "main")).toBe(true);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run: `pnpm --filter @reactive-resume/pdf test -- src/semantic/template-manifest.test.ts`
|
||
|
||
Expected: FAIL on the manifest sentinel or the first template exhaustiveness assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement the manifest contract and all 15 files**
|
||
|
||
Each manifest declares regions, header placement, special summary placement, and template parts. At minimum register:
|
||
|
||
```text
|
||
azurill: timeline-line, timeline-dot, timeline-marker, timeline-content
|
||
bronzor: interleaved-section-row alias on the existing section node
|
||
ditgar: featured-summary, sidebar-background, item-header-border
|
||
ditto: header-band, picture-anchor, contact-offset
|
||
gengar: featured-summary, sidebar-background
|
||
glalie: sidebar-background
|
||
leafish: header-intro, header-body, header-contact-band
|
||
meowth: inline-item-header-leading, inline-item-header-middle, inline-item-header-trailing
|
||
pikachu: header-divider
|
||
rhyhorn: contact-item-content, contact-item-last
|
||
scizor: header-name-rule
|
||
```
|
||
|
||
Chikorita, Kakuna, Lapras, Onyx, and other templates still publish explicit region/header manifests even when they have
|
||
no unique template parts. Bronzor and Scizor record original layout `origin` separately from physical `placement`.
|
||
Manifest validation fails when a registered part lacks a primitive/alias binding or when an existing template chrome
|
||
binding has no stable registered part.
|
||
|
||
- [ ] **Step 4: Verify manifests, tree fingerprint stability, and all-template smoke construction**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/template-manifest.test.ts src/templates/index.test.ts
|
||
pnpm --filter @reactive-resume/pdf typecheck
|
||
```
|
||
|
||
Expected: PASS for every template and every registered part owner.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/pdf
|
||
git commit -m "feat(pdf): register semantic template manifests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Add the PDF Adapter, Mode Switch, and No-Wrapper Bindings
|
||
|
||
**Files:**
|
||
- Create: `packages/pdf/src/semantic/base-styles.ts`
|
||
- Create: `packages/pdf/src/semantic/adapter.ts`
|
||
- Create: `packages/pdf/src/semantic/context.tsx`
|
||
- Create: `packages/pdf/src/semantic/resolve.ts`
|
||
- Create: `packages/pdf/src/semantic/adapter.test.ts`
|
||
- Create: `packages/pdf/src/semantic/no-wrapper.test.tsx`
|
||
- Create: `packages/pdf/src/semantic/rich-text-bindings.test.tsx`
|
||
- Create: `packages/pdf/src/semantic/pagination.test.tsx`
|
||
- Create: `packages/pdf/src/semantic/issue-fixtures.test.tsx`
|
||
- Modify: `packages/pdf/src/document.tsx`
|
||
- Modify: `packages/pdf/src/templates/shared/context.tsx`
|
||
- Modify: `packages/pdf/src/templates/shared/primitives.tsx`
|
||
- Modify: `packages/pdf/src/templates/shared/sections.tsx`
|
||
- Modify: `packages/pdf/src/templates/shared/rich-text.tsx`
|
||
- Modify: `packages/pdf/src/templates/shared/contact-item.tsx`
|
||
- Modify: `packages/pdf/src/templates/shared/level-display.tsx`
|
||
- Modify: all 15 `*Page.tsx` template files
|
||
|
||
**Interfaces:**
|
||
- Produces: `resolveResumePresentation`, `SemanticRenderProvider`, `useResolvedNode`,
|
||
`ResolvedPdfNodePresentation`.
|
||
- Consumes: compiler result, descriptor tree, template base styles.
|
||
|
||
- [ ] **Step 1: Write failing adapter and issue regression tests**
|
||
|
||
```ts
|
||
it("lets semantic field font weight override template bold defaults", () => {
|
||
const presentation = resolveIssueFixture("#3146", `
|
||
@version 1;
|
||
section[type="experience"] field[name="company"] { font-weight: 400; }
|
||
`);
|
||
|
||
expect(presentation["experience/company"]?.style.fontWeight).toBe("400");
|
||
});
|
||
|
||
it("lets semantic link decoration override the builder underline base", () => {
|
||
const presentation = resolveIssueFixture("#3134", `
|
||
@version 1;
|
||
link { text-decoration: none; }
|
||
`);
|
||
|
||
expect(presentation["header/contact-email/link"]?.style.textDecoration).toBe("none");
|
||
});
|
||
|
||
it("styles Basics/header nodes while rejecting unsupported gradients", () => {
|
||
const valid = resolveIssueFixture("#3137", `
|
||
@version 1;
|
||
header { background-color: #1e293b; }
|
||
name { color: white; }
|
||
`);
|
||
const invalid = compileStylesheet({
|
||
languageVersion: 1,
|
||
text: "@version 1; header { background-image: linear-gradient(red, blue); }",
|
||
});
|
||
|
||
expect(valid["header"]?.style.backgroundColor).toBe("#1e293b");
|
||
expect(invalid.program).toBeNull();
|
||
});
|
||
|
||
it("unbolds only skill names and leaves experience titles unchanged", () => {
|
||
const presentation = resolveIssueFixture("#2223", `
|
||
@version 1;
|
||
section[type="skills"] field[name="name"] { font-weight: 400; }
|
||
`);
|
||
|
||
expect(presentation["skills/item-1/name"]?.style.fontWeight).toBe("400");
|
||
expect(presentation["experience/item-1/company"]?.style.fontWeight).not.toBe("400");
|
||
});
|
||
|
||
it("never applies legacy and semantic custom styles together", () => {
|
||
expect(resolveMode(semanticResumeData)).toBe("semantic");
|
||
expect(resolveMode(legacyResumeData)).toBe("legacy");
|
||
});
|
||
|
||
it("uses authored page context for wrapped physical subpages and repeats fixed nodes", async () => {
|
||
const result = await renderPaginationFixture(`
|
||
@version 1;
|
||
page[page-number="1"] { size: A4; }
|
||
header { -resume-fixed: true; }
|
||
@media (max-width: 600pt) { section-heading { font-size: 9pt; } }
|
||
`);
|
||
|
||
expect(result.physicalPages).toBeGreaterThan(1);
|
||
expect(result.fixedHeaderCount).toBe(result.physicalPages);
|
||
expect(result.headingFontSizes).toEqual(expect.arrayContaining([9]));
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/adapter.test.ts src/semantic/issue-fixtures.test.tsx
|
||
```
|
||
|
||
Expected: FAIL on the adapter sentinel or the first final-style/mode assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement presentation resolution before React rendering**
|
||
|
||
```ts
|
||
export type ResolvedPdfNodePresentation = {
|
||
style?: Style;
|
||
size?: number | string;
|
||
break?: true;
|
||
wrap?: false;
|
||
fixed?: boolean;
|
||
minPresenceAhead?: number;
|
||
orphans?: number;
|
||
widows?: number;
|
||
};
|
||
|
||
export function resolveResumePresentation(input: {
|
||
data: ResumeData;
|
||
template: Template;
|
||
applied?: StylesheetSource;
|
||
mode: StylesheetMode;
|
||
}): ResolvedResumePresentation;
|
||
```
|
||
|
||
Builder/template defaults become the base map. Semantic values compose after that map. Crash-prevention values remain
|
||
last only when documented. Structural filtering and ordering happen on descriptors before templates map children.
|
||
|
||
Section, item, field, contact, and template renderers iterate the resolved descriptor children rather than independently
|
||
mapping raw resume arrays. Each descriptor carries a render binding to the existing primitive/render function. Hidden
|
||
descriptors are absent; ordered descriptors determine invocation order. When one legacy `Text` primitive combines
|
||
multiple fields, split it into keyed field primitives only with a before/after PDF raster parity fixture proving the
|
||
default output did not change.
|
||
|
||
For rich text, extend the existing custom `react-pdf-html` renderers/stylesheet so normalized heading, blockquote,
|
||
paragraph, list, list-item, marker, content, link, strong, emphasis, underline, strike, inline-code, text-span, mark,
|
||
hard-break, and horizontal-rule nodes receive deterministic keys and resolved styles. The descriptor builder and
|
||
renderers consume the same normalized HTML; neither may infer a different tree.
|
||
|
||
- [ ] **Step 4: Bind existing nodes without wrappers**
|
||
|
||
Add `nodeKey` props to shared primitives and template layout nodes. `useResolvedNode(nodeKey)` returns style and primitive
|
||
props. Do not wrap a primitive merely to create a semantic target. Preserve legacy hooks while `mode !== "semantic"`.
|
||
|
||
The no-wrapper test mocks React PDF primitives, renders each template with an empty semantic stylesheet, and compares
|
||
primitive type/count/order against legacy mode. Layout `Page`/`View` counts must match; intentionally split text fields
|
||
require approved raster parity and an explicit binding-inventory entry.
|
||
|
||
- [ ] **Step 5: Verify shared behavior and all 15 render smokes**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic src/templates/shared src/templates/index.test.ts
|
||
pnpm --filter @reactive-resume/pdf typecheck
|
||
```
|
||
|
||
Expected: PASS for #3146, #3134, #3137, #2223, rich-text bindings, authored/physical-page behavior, fixed nodes,
|
||
page-size/media phases, no-layout-wrapper parity, browser/server final primitive-prop identity, and all templates.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/pdf
|
||
git commit -m "feat(pdf): resolve semantic stylesheet presentation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Add Terminable Browser and Server PDF Preflight Workers
|
||
|
||
**Files:**
|
||
- Create: `packages/pdf/src/semantic/preflight-core.tsx`
|
||
- Create: `packages/pdf/src/semantic/preflight-core.test.tsx`
|
||
- Create: `apps/server/src/workers/stylesheet-preflight.ts`
|
||
- Create: `apps/server/src/services/stylesheet-preflight.ts`
|
||
- Create: `apps/server/src/services/stylesheet-preflight.test.ts`
|
||
- Modify: `apps/server/tsdown.config.ts`
|
||
- Modify: `apps/server/package.json`
|
||
- Modify: `packages/api/src/context.ts`
|
||
- Modify: `apps/server/src/rpc/handler.ts`
|
||
- Modify: `packages/pdf/src/browser.tsx`
|
||
- Modify: `packages/pdf/src/server.tsx`
|
||
- Modify: `packages/pdf/package.json`
|
||
- Modify: `pnpm-lock.yaml`
|
||
|
||
**Interfaces:**
|
||
- Produces: `renderPreflightPdf`, server `StylesheetPreflightRunner`, `PdfPreflightResult`.
|
||
- Consumed by: API mutation and the browser preflight worker added in Task 15.
|
||
|
||
- [ ] **Step 1: Add the direct server PDF parser dependency**
|
||
|
||
Run: `pnpm --filter server add "@reactive-resume/pdf@workspace:*" pdfjs-dist@latest`
|
||
|
||
Expected: server package and lockfile directly declare the PDF workspace package and parser used inside the worker.
|
||
|
||
- [ ] **Step 2: Write failing real-worker preflight tests**
|
||
|
||
```ts
|
||
it("accepts a bounded candidate render in an isolated worker", async () => {
|
||
const result = await preflightRunner.run({
|
||
data: defaultResumeData,
|
||
template: defaultResumeData.metadata.template,
|
||
stylesheet: validStylesheet,
|
||
limits: { timeoutMs: 5_000, maxPages: 20, maxBytes: 10_000_000, maxOldGenerationMb: 256 },
|
||
});
|
||
|
||
expect(result).toEqual(expect.objectContaining({ ok: true, pageCount: 1 }));
|
||
});
|
||
|
||
it("terminates a real worker when the render exceeds its deadline", async () => {
|
||
const result = await preflightRunner.run({
|
||
data: veryLargeResumeData,
|
||
template: "onyx",
|
||
stylesheet: validStylesheet,
|
||
limits: { timeoutMs: 1, maxPages: 20, maxBytes: 10_000_000, maxOldGenerationMb: 64 },
|
||
});
|
||
|
||
expect(result).toEqual(expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_TIMEOUT" }));
|
||
expect(preflightRunner.activeWorkerCount).toBe(0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/preflight-core.test.tsx
|
||
pnpm --filter server test -- src/services/stylesheet-preflight.test.ts
|
||
```
|
||
|
||
Expected: FAIL on the preflight sentinel or real-worker termination assertion, not on module resolution.
|
||
|
||
- [ ] **Step 4: Implement a worker-safe render core**
|
||
|
||
`renderPreflightPdf` compiles/resolves the candidate and returns PDF bytes. It performs no timers or DB operations.
|
||
The server worker imports it, parses the generated bytes with `pdfjs-dist` to obtain `numPages`, checks byte/page limits,
|
||
and posts a plain result.
|
||
|
||
Use immutable server-owned production limits; clients cannot raise them:
|
||
|
||
```ts
|
||
export const STYLESHEET_PREFLIGHT_LIMITS = {
|
||
timeoutMs: 5_000,
|
||
maxPages: 20,
|
||
maxBytes: 10_000_000,
|
||
maxPageWidthPt: 2_000,
|
||
maxPageHeightPt: 20_000,
|
||
maxPageAreaPt2: 20_000_000,
|
||
maxOldGenerationMb: 256,
|
||
} as const;
|
||
```
|
||
|
||
Resolve authored page sizes and reject width, height, or area violations before starting React PDF rendering. Tests may
|
||
inject stricter limits through an internal runner constructor; API input never contains limits.
|
||
|
||
- [ ] **Step 5: Build and terminate the Node worker explicitly**
|
||
|
||
Add `stylesheet-preflight-worker` as a second `tsdown` entry. In production, start the emitted `.mjs` worker; in source
|
||
development/tests, use the TS entry with the inherited `tsx` loader. Set worker-thread `resourceLimits`, terminate on
|
||
timeout, and remove listeners in every exit path. A timeout must stop CPU work rather than merely reject a
|
||
`Promise.race`.
|
||
|
||
```ts
|
||
export type StylesheetPreflightRunner = {
|
||
run(input: StylesheetPreflightInput): Promise<PdfPreflightResult>;
|
||
};
|
||
```
|
||
|
||
Inject the runner into API request context from `apps/server/src/rpc/handler.ts`; keep it optional in non-mutation
|
||
in-process contexts. The stylesheet mutation returns a controlled unavailable error if no runner is configured.
|
||
|
||
- [ ] **Step 6: Define the browser-worker contract for Task 15**
|
||
|
||
The browser uses a separate terminable Vite worker that imports `renderPreflightPdf` plus PDF.js, returns a transferable
|
||
`ArrayBuffer` and page count, and is terminated/recreated on timeout. Do not claim memory isolation on the main thread.
|
||
|
||
- [ ] **Step 7: Verify real isolation, build entries, and focused PDF behavior**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/preflight-core.test.tsx src/browser.test.tsx src/server.test.tsx
|
||
pnpm --filter server test -- src/services/stylesheet-preflight.test.ts
|
||
pnpm --filter server build
|
||
pnpm --filter @reactive-resume/pdf typecheck
|
||
```
|
||
|
||
Expected: PASS; built output includes `stylesheet-preflight-worker.mjs`, timeouts leave no active worker, and page/byte/
|
||
memory limits return deterministic diagnostic codes.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add packages/pdf packages/api apps/server pnpm-lock.yaml
|
||
git commit -m "feat(server): isolate stylesheet PDF preflight"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Add Behavioral Legacy Conversion and Activation Parity
|
||
|
||
**Files:**
|
||
- Create: `packages/pdf/src/semantic/legacy-converter.ts`
|
||
- Create: `packages/pdf/src/semantic/legacy-converter.test.ts`
|
||
- Create: `packages/pdf/src/semantic/legacy-parity.ts`
|
||
- Create: `packages/pdf/src/semantic/legacy-parity.test.ts`
|
||
- Create: `packages/pdf/src/semantic/legacy-render-parity.test.tsx`
|
||
- Create: `packages/pdf/src/semantic/test/rasterize-pdf.ts`
|
||
- Create: `packages/pdf/src/semantic/__fixtures__/legacy/*.json`
|
||
- Modify: `packages/pdf/package.json`
|
||
- Modify: `pnpm-lock.yaml`
|
||
|
||
**Interfaces:**
|
||
- Produces: `convertLegacyStyleRules`, `compareLegacySemanticPresentation`.
|
||
- Consumes: existing legacy schema/PDF resolvers, serializer, semantic adapter.
|
||
|
||
- [ ] **Step 1: Add test-only raster dependencies**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf add -D pdfjs-dist@latest @napi-rs/canvas@latest pixelmatch@latest pngjs@latest @types/pngjs@latest
|
||
```
|
||
|
||
Expected: PDF package and lockfile directly declare the test-only raster stack.
|
||
|
||
- [ ] **Step 2: Write failing golden and parity tests**
|
||
|
||
```ts
|
||
it("converts scope, slots, units, labels, and disabled rules deterministically", () => {
|
||
const result = convertLegacyStyleRules(legacyFixtureResume);
|
||
|
||
expect(result.source.languageVersion).toBe(1);
|
||
expect(result.source.text).toMatchFileSnapshot("./__fixtures__/legacy/mixed-rules.expected.css");
|
||
});
|
||
|
||
it("matches final primitive props and rendered pixels at activation", async () => {
|
||
const conversion = convertLegacyStyleRules(legacyFixtureResume);
|
||
const comparison = await compareLegacySemanticRender({
|
||
data: legacyFixtureResume,
|
||
convertedSource: conversion.source,
|
||
});
|
||
|
||
expect(comparison.primitivePropMismatches).toEqual([]);
|
||
expect(comparison.pixelDiffRatio).toBe(0);
|
||
expect(comparison.mismatches).toEqual([]);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/legacy-converter.test.ts src/semantic/legacy-parity.test.ts src/semantic/legacy-render-parity.test.tsx
|
||
```
|
||
|
||
Expected: FAIL on the converter sentinel or golden/render-parity assertion, not on module resolution.
|
||
|
||
- [ ] **Step 4: Implement behavioral conversion**
|
||
|
||
Sanitize with `styleRulesSchema`, then evaluate effective legacy output including:
|
||
|
||
- global → section type → section ID property merging and same-specificity array order;
|
||
- schema/PDF numeric clamps and color conversion;
|
||
- `Bold` after-rule precedence (#3146);
|
||
- final link underline preference (#3134);
|
||
- icon and level `fontSize` to explicit icon size;
|
||
- rich-text paragraph/list/link/bold/mark paths;
|
||
- award `bold={false}` and template feature exceptions.
|
||
|
||
Emit `@version 1;`, safe comments, quoted UUID selectors, explicit point units, and commented disabled/no-effect blocks.
|
||
Never emit `@resume-disabled`. Compare final mocked primitive props after every existing inline/template/safety layer, then
|
||
render and raster-compare the legacy and semantic PDFs for the migration fixtures. Presentation-map equality alone does
|
||
not satisfy activation parity.
|
||
|
||
- [ ] **Step 5: Add mandatory fixtures**
|
||
|
||
```text
|
||
merge-specificity
|
||
array-order-tie
|
||
disabled-rules
|
||
sanitized-intent-3199
|
||
clamped-spacing
|
||
link-underline-3134
|
||
rich-text-all-slots
|
||
icon-level-size
|
||
award-unbold
|
||
primary-text-bold-3146
|
||
custom-section-type
|
||
section-id-uuid
|
||
all-templates-smoke
|
||
```
|
||
|
||
- [ ] **Step 6: Verify converter, parity, and existing legacy tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/legacy-converter.test.ts src/semantic/legacy-parity.test.ts src/semantic/legacy-render-parity.test.tsx src/templates/shared/style-rules.test.ts
|
||
pnpm --filter @reactive-resume/schema test -- src/resume/data.test.ts src/resume/style-rules.test.ts
|
||
```
|
||
|
||
Expected: PASS; parity is guaranteed for current data/template/base settings at activation, not after later template
|
||
changes.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add packages/pdf pnpm-lock.yaml
|
||
git commit -m "feat(pdf): convert legacy styles to Semantic CSS"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Add the Revisioned Stylesheet API State Machine
|
||
|
||
**Files:**
|
||
- Create: `packages/api/src/features/resume/stylesheet.ts`
|
||
- Create: `packages/api/src/features/resume/stylesheet-service.ts`
|
||
- Create: `packages/api/src/features/resume/stylesheet-preflight.ts`
|
||
- Create: `packages/api/src/features/resume/stylesheet-observability.ts`
|
||
- Create: `packages/api/src/features/resume/stylesheet-observability.test.ts`
|
||
- Create: `packages/api/src/features/resume/stylesheet-service.test.ts`
|
||
- Create: `packages/api/src/features/resume/stylesheet-service.integration.test.ts`
|
||
- Modify: `packages/api/src/features/resume/router.ts`
|
||
- Modify: `packages/api/src/dto/resume.ts`
|
||
- Modify: `packages/api/src/features/resume/crud.ts`
|
||
- Modify: `packages/api/src/features/resume/versions.ts`
|
||
- Modify: `packages/api/src/features/resume/events.ts`
|
||
- Modify: `packages/api/src/features/resume/events.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `resume.stylesheet.getState`, `resume.stylesheet.mutate`; state transitions
|
||
`edit_source | activate | deactivate | restore_history`.
|
||
- Consumes: persistence columns, compiler, parity, preflight.
|
||
|
||
- [ ] **Step 1: Write failing state-machine tests**
|
||
|
||
```ts
|
||
const commonMutationInput = {
|
||
id: "resume-1",
|
||
expectedRevision: 3,
|
||
expectedRenderDataVersion: 8,
|
||
editGeneration: 1,
|
||
} as const;
|
||
|
||
it("stores invalid source while preserving applied source", async () => {
|
||
const result = await mutateStylesheet({
|
||
...commonMutationInput,
|
||
transition: "edit_source",
|
||
source: invalidSource,
|
||
});
|
||
|
||
expect(result.stylesheet.source).toEqual(invalidSource);
|
||
expect(result.stylesheet.applied).toEqual(previousApplied);
|
||
expect(result.revision).toBe(4);
|
||
expect(result.renderDataVersion).toBe(8);
|
||
});
|
||
|
||
it("preflights outside the transaction then compares both versions", async () => {
|
||
await mutateStylesheet({ ...commonMutationInput, transition: "edit_source", source: validSource });
|
||
|
||
expect(callOrder).toEqual(["readSnapshot", "compile", "preflight", "begin", "lock", "compare", "update", "commit"]);
|
||
});
|
||
|
||
it("requires explicit parity-checked activation", async () => {
|
||
const result = await mutateStylesheet({ ...commonMutationInput, transition: "activate", source: validSource });
|
||
|
||
expect(result.stylesheet.mode).toBe("semantic");
|
||
expect(parityCheck).toHaveBeenCalledOnce();
|
||
expect(result.revision).toBe(4);
|
||
expect(result.renderDataVersion).toBe(8);
|
||
});
|
||
|
||
it("deactivates to legacy without deleting either Semantic CSS source", async () => {
|
||
const result = await mutateStylesheet({ ...commonMutationInput, transition: "deactivate" });
|
||
|
||
expect(result.stylesheet.mode).toBe("legacy");
|
||
expect(result.stylesheet.source).toEqual(previousSource);
|
||
expect(result.stylesheet.applied).toEqual(previousApplied);
|
||
expect(result.revision).toBe(4);
|
||
expect(result.renderDataVersion).toBe(8);
|
||
});
|
||
|
||
it("preserves all other rules and last-valid applied source after a syntax error and reload", async () => {
|
||
await mutateStylesheet({ ...commonMutationInput, transition: "edit_source", source: invalidSyntaxFixture });
|
||
const reloaded = await getStylesheetState("resume-1");
|
||
|
||
expect(reloaded.stylesheet.source).toEqual(invalidSyntaxFixture);
|
||
expect(reloaded.stylesheet.applied).toEqual(previousApplied);
|
||
expect(reloadedLegacyRules).toEqual(previousLegacyRules);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run: `pnpm --filter @reactive-resume/api test -- src/features/resume/stylesheet-service.test.ts`
|
||
|
||
Expected: FAIL on the state-machine sentinel or source/applied/CAS assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement strongly typed DTO and transition rules**
|
||
|
||
```ts
|
||
type StylesheetMutationCommon = {
|
||
id: string;
|
||
expectedRevision: number;
|
||
expectedRenderDataVersion: number;
|
||
editGeneration: number;
|
||
};
|
||
|
||
type MutateResumeStylesheetInput = StylesheetMutationCommon &
|
||
(
|
||
| { transition: "edit_source"; source: StylesheetSource }
|
||
| { transition: "activate"; source: StylesheetSource }
|
||
| { transition: "deactivate" }
|
||
| {
|
||
transition: "restore_history";
|
||
restore: {
|
||
mode: StylesheetMode;
|
||
source: StylesheetSource;
|
||
applied: StylesheetSource;
|
||
};
|
||
}
|
||
);
|
||
```
|
||
|
||
Implement the same discriminated union in Zod so extra/missing fields fail at the route boundary. All test inputs include
|
||
`editGeneration`, both expected versions, and only the payload allowed by their transition.
|
||
|
||
Normal edits ignore client `applied`. Activation runs compiler, parity, and preflight. Deactivation changes only mode to
|
||
legacy and retains both Semantic CSS sources for reactivation/rollback. Restore independently validates and preflights historical
|
||
applied source. Import validates imported applied; duplicate copies content with revision zero; version restore restores
|
||
content but increments current revision.
|
||
|
||
Every stylesheet transition increments and acknowledges `stylesheetRevision` only. It returns the current
|
||
`renderDataVersion` unchanged; content/base-setting mutations own that column. A preflight CAS compares both so a content
|
||
change still invalidates the snapshot.
|
||
|
||
- [ ] **Step 4: Implement snapshot/preflight/short-CAS flow**
|
||
|
||
Read immutable data/revisions, compile and preflight outside a transaction, then short-lock and compare both
|
||
`stylesheetRevision` and `renderDataVersion`. Return typed 409 conflicts with canonical state. Publish SSE mutation
|
||
`"stylesheet"` after commit.
|
||
|
||
Add a real PostgreSQL integration test that runs two concurrent mutations against one resume, pauses the first between
|
||
preflight and CAS, changes render data through the second connection, then proves the first receives a conflict and
|
||
neither update is lost. Do not rely only on mocked call order.
|
||
|
||
Add structured events for `semantic_css.compile`, `semantic_css.preflight`, `semantic_css.convert_legacy`,
|
||
`semantic_css.parity_check`, and `semantic_css.activate`. Include duration, language version, source byte count, template,
|
||
diagnostic codes, page count, revision, and success/failure; hash resume IDs and never log source, comments, resume
|
||
content, or personal fields. Tests spy on logging and assert sensitive text is absent.
|
||
|
||
- [ ] **Step 5: Verify lifecycle and conflict cases**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/api test -- src/features/resume/stylesheet-service.test.ts src/features/resume/stylesheet-observability.test.ts src/features/resume/service.test.ts src/features/resume/events.test.ts
|
||
dotenvx run -f .env.local -- pnpm --filter @reactive-resume/api test -- src/features/resume/stylesheet-service.integration.test.ts
|
||
pnpm --filter @reactive-resume/api typecheck
|
||
```
|
||
|
||
Expected: PASS for real concurrent CAS, stale revisions, content changes during preflight, locked resumes, invalid
|
||
source/reload (#3199), render failure, activation, deactivation, restore, import, duplicate, version restore, and forged
|
||
applied values.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/api
|
||
git commit -m "feat(api): add semantic stylesheet state machine"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Add Public Projection, Redaction, and PDF Fallback
|
||
|
||
**Files:**
|
||
- Create: `packages/pdf/src/semantic/public-projection.ts`
|
||
- Create: `packages/pdf/src/semantic/public-projection.test.ts`
|
||
- Create: `packages/api/src/features/resume/public-style-projection.ts`
|
||
- Create: `packages/api/src/features/resume/public-style-projection.test.ts`
|
||
- Create: `packages/api/src/features/resume/public-pdf.ts`
|
||
- Create: `packages/api/src/features/resume/public-pdf.test.ts`
|
||
- Create: `packages/api/src/features/resume/public-render-rate-limit.ts`
|
||
- Create: `packages/api/src/features/resume/public-render-rate-limit.test.ts`
|
||
- Modify: `packages/api/src/features/resume/access-policy.ts`
|
||
- Modify: `packages/api/src/features/resume/access-policy.test.ts`
|
||
- Modify: `packages/api/src/features/resume/sharing.ts`
|
||
- Modify: `packages/api/src/features/resume/router.ts`
|
||
- Modify: `packages/api/src/dto/resume.ts`
|
||
- Modify: `packages/api/package.json`
|
||
- Modify: `packages/pdf/package.json`
|
||
- Create: `apps/server/src/http/public-resume-pdf.ts`
|
||
- Create: `apps/server/src/http/public-resume-pdf.test.ts`
|
||
- Modify: `apps/server/src/http/app.ts`
|
||
- Modify: `apps/server/src/http/app.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `resume.getStyleProjection` public oRPC query and authorized server-rendered fallback service.
|
||
- Consumes: resolved presentation, canonical render hash, public access/password policy.
|
||
|
||
- [ ] **Step 1: Write failing public-data tests**
|
||
|
||
```ts
|
||
it("never returns editable or applied stylesheet source to non-owners", () => {
|
||
const result = redactResumeForViewer(ownerResumeWithStylesheet, false);
|
||
|
||
expect(result.data.metadata.stylesheet).toBeUndefined();
|
||
});
|
||
|
||
it("rejects a projection whose resolved nodes do not match the render hash", async () => {
|
||
const valid = await createPublicStyleProjection(publicRenderInput);
|
||
const tampered = { ...valid, nodes: { ...valid.nodes, name: { style: { color: "red" } } } };
|
||
|
||
await expect(validatePublicStyleProjection(publicRenderInput.data, tampered)).resolves.toBe(false);
|
||
});
|
||
|
||
it("returns a projection through the public route without stylesheet sources", async () => {
|
||
const result = await getStyleProjection({ username: "jane", slug: "resume", requestHeaders });
|
||
|
||
expect(result).toEqual(expect.objectContaining({ formatVersion: 1, nodes: expect.any(Object) }));
|
||
expect(JSON.stringify(result)).not.toContain("@version");
|
||
});
|
||
|
||
it("shares an IP-and-resume render budget across projection and PDF fallback", async () => {
|
||
await exhaustPublicRenderBudget({ ip: "203.0.113.7", resumeId: "resume-1" });
|
||
|
||
await expect(getStyleProjection(publicInput)).rejects.toMatchObject({ code: "RATE_LIMIT_EXCEEDED" });
|
||
await expect(createPublicResumePdf(publicInput)).rejects.toMatchObject({ code: "RATE_LIMIT_EXCEEDED" });
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/public-projection.test.ts
|
||
pnpm --filter @reactive-resume/api test -- src/features/resume/public-style-projection.test.ts src/features/resume/public-pdf.test.ts src/features/resume/public-render-rate-limit.test.ts src/features/resume/access-policy.test.ts
|
||
```
|
||
|
||
Expected: FAIL on projection/redaction/rate-limit assertions after typed shells compile.
|
||
|
||
- [ ] **Step 3: Implement fully resolved projection and hash**
|
||
|
||
Projection includes format/language/tree versions, registry/adapter fingerprints, domain-separated render hash, and
|
||
resolved nodes keyed by stable node key. Resolve variables and selectors before serialization. Public clients verify all
|
||
versions/fingerprints/hash and never receive source locations, comments, diagnostics, selectors, or variables.
|
||
|
||
Mount `getStyleProjection` in `sharing.ts` and `resumeRouter` as a concrete
|
||
`GET /resumes/{username}/{slug}/style-projection` procedure. Reuse `getBySlug` visibility/password checks and rate
|
||
rules, then add a dedicated shared IP+resume token bucket because `getBySlug` itself has no expensive-render limiter.
|
||
Projection generation and public PDF fallback consume that budget; ordinary resume JSON reads do not. Add the output
|
||
schema to `dto/resume.ts`, cache by `renderDataHash`, add intentional PDF exports for semantic tree/projection/preflight,
|
||
and add API export `./features/resume/public-pdf`; no app imports private workspace source paths.
|
||
|
||
- [ ] **Step 4: Extend fallback without weakening authorization**
|
||
|
||
Add a distinct public/password-aware fallback handler and route beside the existing signed owner-download flow. The
|
||
server handler imports only `@reactive-resume/api/features/resume/public-pdf`; that exported service reuses access policy
|
||
and rate-limit behavior. Keep `Cache-Control` correct for public/password/private data and leave `resume-pdf.ts`
|
||
signed-token behavior unchanged. Emit `semantic_css.render_fallback` with mismatch reason, fingerprints, and duration,
|
||
but no resume data or stylesheet source.
|
||
|
||
- [ ] **Step 5: Verify projection, redaction, password, rate-limit, and fallback tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/public-projection.test.ts
|
||
pnpm --filter @reactive-resume/api test -- src/features/resume/public-style-projection.test.ts src/features/resume/public-pdf.test.ts src/features/resume/public-render-rate-limit.test.ts src/features/resume/access-policy.test.ts
|
||
pnpm --filter server test -- src/http/public-resume-pdf.test.ts src/http/resume-pdf.test.ts src/http/app.test.ts
|
||
```
|
||
|
||
Expected: PASS; projection mismatch refetches/falls back and never bypasses public/password checks.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/pdf packages/api apps/server
|
||
git commit -m "feat: add public semantic style projection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Add the Web Stylesheet Store, Worker, and Mutation Queue
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/features/resume/stylesheet/protocol.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/stylesheet.worker.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/preflight.worker.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/worker-client.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/store.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/store.test.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/worker-client.test.ts`
|
||
- Modify: `apps/web/src/routes/builder/$resumeId/route.tsx`
|
||
- Modify: `apps/web/src/features/resume/builder/draft.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `useStylesheetStore`, `initializeStylesheetStore`, serialized compile/save runtime.
|
||
- Consumes: API mutation state, compiler, immutable semantic tree snapshot.
|
||
|
||
- [ ] **Step 1: Write failing queue/generation/history tests**
|
||
|
||
```ts
|
||
it("advances stylesheet revision while retaining the acknowledged resume render-data version", async () => {
|
||
store.setSourceText("generation two");
|
||
await acknowledge({ generation: 1, revision: 4, renderDataVersion: 8, source: "generation one" });
|
||
|
||
expect(store.getState().revision).toBe(4);
|
||
expect(store.getState().renderDataVersion).toBe(8);
|
||
expect(store.getState().source.text).toBe("generation two");
|
||
expect(nextMutation.expectedRevision).toBe(4);
|
||
expect(nextMutation.expectedRenderDataVersion).toBe(8);
|
||
});
|
||
|
||
it("rebases a conflict without dropping unsaved source", async () => {
|
||
store.setSourceText("local unsaved source");
|
||
await conflict({ revision: 8, stylesheet: remoteStylesheet });
|
||
|
||
expect(store.getState().source.text).toBe("local unsaved source");
|
||
expect(store.getState().revision).toBe(8);
|
||
});
|
||
|
||
it("restarts a timed-out preflight worker and keeps last-valid applied source", async () => {
|
||
await preflightTimeout();
|
||
|
||
expect(terminatedWorker.terminate).toHaveBeenCalledOnce();
|
||
expect(replacementWorker).toBeDefined();
|
||
expect(store.getState().applied).toEqual(previousApplied);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter web test -- src/features/resume/stylesheet/store.test.ts src/features/resume/stylesheet/worker-client.test.ts
|
||
```
|
||
|
||
Expected: FAIL on the store/worker sentinel or queue/generation assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement structured-clone-safe worker protocol**
|
||
|
||
Compiler requests carry request ID, source, language version, edit generation, semantic tree, base settings, and page
|
||
contexts. Responses carry plain program/diagnostic/resolved values only; never clone CSSTree nodes or class instances.
|
||
Discard out-of-order results by request ID.
|
||
|
||
The separate preflight worker imports the PDF browser preflight core and PDF.js, returns a transferable PDF
|
||
`ArrayBuffer`, page count, and byte count, and is terminated/recreated when its deadline expires. Tests assert the worker
|
||
instance terminates rather than leaving background rendering alive.
|
||
|
||
- [ ] **Step 4: Implement one-in-flight/one-replaceable-pending mutation runtime**
|
||
|
||
Always persist raw source through `edit_source`, including compiler errors. Only local/server preflight success changes
|
||
applied. Consume every acknowledgement revision; gate visible state by generation. Keep stylesheet undo/redo snapshots
|
||
separate from whole-resume history and use `restore_history`.
|
||
|
||
Stylesheet acknowledgements advance `revision` and copy the current server `renderDataVersion`; they never increment the
|
||
render-data version themselves. Resume update/SSE responses that change content replace `renderDataVersion` and force a
|
||
queued stylesheet candidate to preflight/retry against the new snapshot.
|
||
|
||
- [ ] **Step 5: Initialize and clean up per resume**
|
||
|
||
Builder route initializes from owner stylesheet state and cleans up workers, timers, requests, and store data on unmount
|
||
or resume switch. Subscribe to `"stylesheet"` SSE mutations so cross-tab changes refetch canonical stylesheet state while
|
||
preserving a focused local draft. Add `.cm-editor` to editable-focus detection so global resume undo does not intercept
|
||
editor keys.
|
||
|
||
- [ ] **Step 6: Verify store, worker, and existing draft behavior**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter web test -- src/features/resume/stylesheet/store.test.ts src/features/resume/stylesheet/worker-client.test.ts src/features/resume/builder/draft.test.ts
|
||
pnpm --filter web typecheck
|
||
```
|
||
|
||
Expected: PASS for compile/save debounce, stale worker results, worker termination/restart, serialized saves, conflict
|
||
rebase, cross-tab SSE, failed browser preflight, invalid source retention, history restore, and cleanup.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/features/resume/stylesheet apps/web/src/routes/builder apps/web/src/features/resume/builder
|
||
git commit -m "feat(web): add Semantic CSS editor state and worker"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: Replace the Custom Styles Form with the CodeMirror Editor
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/features/resume/stylesheet/editor.tsx`
|
||
- Create: `apps/web/src/features/resume/stylesheet/status.tsx`
|
||
- Create: `apps/web/src/features/resume/stylesheet/toolbar.tsx`
|
||
- Create: `apps/web/src/features/resume/stylesheet/legacy-banner.tsx`
|
||
- Create: `apps/web/src/features/resume/stylesheet/focus-mode.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/editor.test.tsx`
|
||
- Create: `apps/web/src/features/resume/stylesheet/focus-mode.test.ts`
|
||
- Modify: `apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx`
|
||
- Modify: `apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx`
|
||
- Modify: `apps/web/src/routes/builder/$resumeId/-sidebar/right/index.tsx`
|
||
- Modify: `apps/web/src/routes/builder/$resumeId/-store/sidebar.ts`
|
||
- Modify: `apps/web/package.json`
|
||
- Modify: `pnpm-lock.yaml`
|
||
|
||
**Interfaces:**
|
||
- Produces: compact/lazy editor, toolbar, status, activation banner, desktop/mobile focus mode.
|
||
- Consumes: Task 15 store.
|
||
|
||
- [ ] **Step 1: Install direct editor dependencies**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter web add \
|
||
@codemirror/state@latest \
|
||
@codemirror/view@latest \
|
||
@codemirror/language@latest \
|
||
@codemirror/lang-css@latest \
|
||
@codemirror/commands@latest \
|
||
@codemirror/search@latest \
|
||
@codemirror/autocomplete@latest \
|
||
@codemirror/lint@latest \
|
||
prettier@latest
|
||
```
|
||
|
||
Expected: direct web dependencies and updated lockfile.
|
||
|
||
- [ ] **Step 2: Write failing component/focus-mode tests**
|
||
|
||
```tsx
|
||
it("shows last-valid status when source has errors", async () => {
|
||
render(<CustomStylesSectionBuilder />);
|
||
|
||
expect(await screen.findByText(/preview uses the last valid version/i)).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: /activate semantic css/i })).toBeDisabled();
|
||
});
|
||
|
||
it("resizes and restores the desktop right panel", () => {
|
||
const restore = enterStylesheetFocusMode({ rightPanel, currentLayout });
|
||
expect(rightPanel.current?.resize).toHaveBeenCalledWith("45%");
|
||
|
||
restore();
|
||
expect(setLayout).toHaveBeenCalledWith(currentLayout);
|
||
});
|
||
|
||
it("shows a read-only notice for an active semantic resume when authoring is disabled", () => {
|
||
render(<CustomStylesSectionBuilder />, { flags: { semanticCssAuthoring: false }, mode: "semantic" });
|
||
|
||
expect(screen.getByText(/semantic styles remain active/i)).toBeInTheDocument();
|
||
expect(screen.queryByText(/target scope/i)).not.toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter web test -- src/features/resume/stylesheet/editor.test.tsx src/features/resume/stylesheet/focus-mode.test.ts
|
||
```
|
||
|
||
Expected: FAIL on the editor/focus sentinel or first accessible status/resize assertion, not on module resolution.
|
||
|
||
- [ ] **Step 4: Implement direct CodeMirror lifecycle**
|
||
|
||
Create one `EditorView` per visible host, destroy on unmount, tag external replacements to avoid feedback, use
|
||
`Compartment`s for theme/read-only/diagnostics, and keep the document LTR even when the surrounding locale is RTL. Lazy
|
||
load the feature from the sidebar so the initial builder chunk does not include CodeMirror.
|
||
|
||
- [ ] **Step 5: Compose existing UI components**
|
||
|
||
Use:
|
||
|
||
- `Badge` for Applied/Warning/Error state;
|
||
- `Alert` for inactive legacy conversion and error explanation;
|
||
- `Button` with Phosphor icons and `data-icon`;
|
||
- `Tooltip` for compact toolbar labels;
|
||
- `ScrollArea` for diagnostics/reference;
|
||
- desktop panel resize for focus mode;
|
||
- `Sheet`, `SheetContent`, and required `SheetTitle` for mobile focus mode.
|
||
|
||
Do not add replacement shadcn components; all required components already exist.
|
||
|
||
- [ ] **Step 6: Remove legacy form internals but retain flag fallback**
|
||
|
||
When authoring is off and mode is legacy, keep the existing legacy form during compatibility rollout. When authoring is
|
||
off and mode is semantic, show a read-only `Alert` explaining that semantic styles remain active but this instance does
|
||
not allow editing; never show a legacy form that would attempt ignored/rejected changes. When authoring is on, render the
|
||
Semantic CSS shell. Do not delete legacy schema/PDF behavior. Conversion fills an inactive draft and shows explicit activation.
|
||
|
||
- [ ] **Step 7: Verify compact/focus/mobile/accessibility behavior**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter web test -- src/features/resume/stylesheet 'src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx'
|
||
pnpm --filter web typecheck
|
||
```
|
||
|
||
Expected: PASS with accessible names, required mobile sheet title, no global undo interception, and no initial
|
||
CodeMirror chunk before opening Custom Styles.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add apps/web/package.json apps/web/src pnpm-lock.yaml
|
||
git commit -m "feat(web): replace custom style form with Semantic CSS editor"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: Add Registry-Driven Editor Intelligence and Explicit Formatting
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/features/resume/stylesheet/editor-extensions.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/formatter.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/editor-extensions.test.ts`
|
||
- Create: `apps/web/src/features/resume/stylesheet/formatter.test.ts`
|
||
- Modify: `apps/web/src/features/resume/stylesheet/editor.tsx`
|
||
- Modify: `apps/web/src/features/resume/stylesheet/toolbar.tsx`
|
||
|
||
**Interfaces:**
|
||
- Produces lint mapping, completion, hover, search, color swatches, and `formatSemanticCss`.
|
||
- Consumes compiler diagnostics and registries.
|
||
|
||
- [ ] **Step 1: Write failing extension/formatter tests**
|
||
|
||
```ts
|
||
it("completes semantic selectors, current IDs, properties, directives, and system variables", async () => {
|
||
const labels = await getCompletionLabels(contextAt("--resume-"));
|
||
|
||
expect(labels).toContain("--resume-primary-color");
|
||
expect(labels).toContain("--resume-sidebar-width");
|
||
expect(labels).not.toContain("--resume-font-family");
|
||
});
|
||
|
||
it("formats only on command and preserves comments and cursor", async () => {
|
||
const result = await formatSemanticCss("/* keep */ section{color:red}", 18);
|
||
|
||
expect(result.formatted).toContain("/* keep */");
|
||
expect(result.cursorOffset).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("leaves malformed source untouched when formatting fails", async () => {
|
||
await expect(formatEditorDocument(view, "section {", 9)).rejects.toThrow();
|
||
expect(view.dispatch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("preserves exact clipboard text and accepts IME composition as one editor transaction", async () => {
|
||
await copySourceToClipboard(view.state.doc.toString());
|
||
composeText(view, "セクション");
|
||
|
||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(view.state.doc.toString());
|
||
expect(getHistoryEventCount(view)).toBe(1);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter web test -- src/features/resume/stylesheet/editor-extensions.test.ts src/features/resume/stylesheet/formatter.test.ts
|
||
```
|
||
|
||
Expected: FAIL on the extension/formatter sentinel or completion/transaction assertion, not on module resolution.
|
||
|
||
- [ ] **Step 3: Implement registry-only completions and hover**
|
||
|
||
Override unrestricted browser-CSS completion. Complete semantic elements/attributes/roles, current section/item IDs,
|
||
field names, current-template parts, registered properties/values/units, `@media`, `@version 1`, user variables,
|
||
read-only `--resume-*`, and structural `-resume-*`. Hover docs come from the same registries used by compiler validation.
|
||
|
||
- [ ] **Step 4: Implement diagnostics, search, and color widgets**
|
||
|
||
Map compiler ranges to CodeMirror lint diagnostics. Use `hoverTooltip`, `@codemirror/search`, and a visible-range
|
||
`ViewPlugin` for compiler-confirmed color tokens. A color widget opens one existing React `ColorPicker` popover; do not
|
||
mount one React root per token.
|
||
|
||
- [ ] **Step 5: Implement lazy explicit formatting**
|
||
|
||
Dynamic-import `prettier/standalone` and `prettier/plugins/postcss`, call `formatWithCursor({ parser: "css" })`, and apply
|
||
the result as one CodeMirror transaction/undo step. Do not format on paste, save, compile, conversion, or activation.
|
||
|
||
- [ ] **Step 6: Verify intelligence and production chunks**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter web test -- src/features/resume/stylesheet/editor-extensions.test.ts src/features/resume/stylesheet/formatter.test.ts
|
||
pnpm --filter web build
|
||
```
|
||
|
||
Expected: PASS for search/replace, exact copy/paste, IME composition, diagnostics, completion, hover, color widgets, and
|
||
formatting; build output has separate lazy chunks for CodeMirror/compiler/preflight workers and Prettier.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/features/resume/stylesheet apps/web/package.json pnpm-lock.yaml
|
||
git commit -m "feat(web): add Semantic CSS editor intelligence"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: Integrate Applied Styles with Preview, Export, Public Viewer, and Versions
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/features/resume/preview/preview.browser.tsx`
|
||
- Modify: `apps/web/src/features/resume/preview/preview.browser.test.tsx`
|
||
- Modify: `apps/web/src/features/resume/export/pdf-document.tsx`
|
||
- Modify: `apps/web/src/features/resume/public/pdf-viewer.tsx`
|
||
- Modify: `apps/web/src/features/resume/public/pdf-viewer.test.tsx`
|
||
- Modify: `apps/web/src/features/resume/public/public-resume.tsx`
|
||
- Modify: `apps/web/src/features/resume/public/public-resume.test.tsx`
|
||
- Modify: `apps/web/src/routes/builder/$resumeId/-components/version-history.tsx`
|
||
|
||
**Interfaces:**
|
||
- Produces one applied/projection-aware rendering path for preview/export/public surfaces.
|
||
- Consumes semantic PDF entrypoints, stylesheet store, public projection.
|
||
|
||
- [ ] **Step 1: Write failing last-valid and projection fallback tests**
|
||
|
||
```ts
|
||
it("keeps the active preview when editable source becomes invalid", async () => {
|
||
render(<ResumePreviewClient />);
|
||
await waitFor(() => expect(createResumePdfBlob).toHaveBeenCalledWith(expect.anything(), validAppliedOptions));
|
||
|
||
setEditableSource("section {");
|
||
|
||
expect(createResumePdfBlob).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ source: "section {" }));
|
||
});
|
||
|
||
it("refetches a mismatched public projection then falls back to the authorized PDF", async () => {
|
||
render(<PdfViewer data={publicResumeData} styleProjection={mismatchedProjection} />);
|
||
|
||
await waitFor(() => expect(refetchProjection).toHaveBeenCalledOnce());
|
||
expect(fetchAuthorizedFallbackPdf).toHaveBeenCalledOnce();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter web test -- src/features/resume/preview/preview.browser.test.tsx src/features/resume/public/pdf-viewer.test.tsx src/features/resume/public/public-resume.test.tsx
|
||
```
|
||
|
||
Expected: FAIL on the last-valid/projection-fallback assertion after typed prop shells compile.
|
||
|
||
- [ ] **Step 3: Pass applied presentation through all owner render paths**
|
||
|
||
Preview and browser export depend on `mode` plus `applied`, never editable source. Preserve the existing staged crossfade
|
||
and request-ID cancellation. Replace the preview's empty `catch` with controlled status/toast behavior while keeping the
|
||
last active PDF visible.
|
||
|
||
- [ ] **Step 4: Validate public projection and fallback**
|
||
|
||
Public viewer validates format/tree/registry/adapter versions and render hash. On mismatch, refetch once, then call the
|
||
authorized/rate-limited server PDF fallback. Legacy public resumes continue the current blob path during rollout.
|
||
|
||
- [ ] **Step 5: Reinitialize after import/duplicate/version restore**
|
||
|
||
Owner query responses return canonical stylesheet state/revisions. After version restore, reset stylesheet worker/store
|
||
from server state without restoring historical concurrency revision. JSON export includes stylesheet content but no
|
||
revision columns.
|
||
|
||
- [ ] **Step 6: Verify rendering parity and lifecycle**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter web test -- src/features/resume/preview src/features/resume/public
|
||
pnpm --filter @reactive-resume/pdf test -- src/browser.test.tsx src/server.test.tsx src/semantic
|
||
pnpm --filter web typecheck
|
||
```
|
||
|
||
Expected: PASS; browser preview, browser export, public viewer, and server export agree on applied content.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/web packages/pdf packages/api
|
||
git commit -m "feat: integrate Semantic CSS rendering surfaces"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: Add Documentation, E2E Acceptance, Visual Coverage, and Rollout Gates
|
||
|
||
**Files:**
|
||
- Modify: `docs/guides/using-custom-styles.mdx`
|
||
- Create: `docs/guides/semantic-css-reference.mdx`
|
||
- Create: `tests/e2e/specs/semantic-css/legacy-conversion.spec.ts`
|
||
- Create: `tests/e2e/specs/semantic-css/invalid-last-valid.spec.ts`
|
||
- Create: `tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts`
|
||
- Create: `tests/e2e/specs/semantic-css/revision-conflict.spec.ts`
|
||
- Create: `tests/e2e/specs/semantic-css/default-mode.spec.ts`
|
||
- Create: `tests/e2e/specs/semantic-css/dormant-mode.spec.ts`
|
||
- Create: `tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts`
|
||
- Create: `tests/e2e/specs/semantic-css/template-visual.spec.ts`
|
||
- Create: `tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/*.png`
|
||
- Create: `packages/pdf/src/semantic/all-templates-smoke.test.tsx`
|
||
- Create: `packages/pdf/src/semantic/all-templates-presentation.test.ts`
|
||
- Create: `tooling/semantic-css/generate-reference.ts`
|
||
- Create: `tooling/semantic-css/generate-reference.test.ts`
|
||
- Modify: `tooling/package.json`
|
||
- Modify: `package.json`
|
||
- Modify: `tests/e2e/README.md`
|
||
- Modify: `.github/workflows/e2e.yml`
|
||
|
||
**Interfaces:**
|
||
- Produces user reference and release gates.
|
||
- Consumes all prior milestones.
|
||
|
||
- [ ] **Step 1: Write the final failing end-to-end acceptance tests**
|
||
|
||
The portable fixture must include:
|
||
|
||
```css
|
||
@version 1;
|
||
|
||
:root {
|
||
--accent: var(--resume-primary-color);
|
||
}
|
||
|
||
header > name {
|
||
color: var(--accent);
|
||
}
|
||
|
||
section:is([type="experience"], [type="education"]) > section-heading {
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
section[id="projects"] > section-items > item {
|
||
padding: 6pt;
|
||
}
|
||
|
||
section[id="experience"] item[id="experience-item-2"] field[name="period"] {
|
||
color: var(--accent);
|
||
}
|
||
|
||
rich-text list-item > list-item-content {
|
||
line-height: 1.25;
|
||
}
|
||
|
||
region[placement="sidebar"] section {
|
||
background-color: rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
section[type="projects"] {
|
||
break-inside: avoid;
|
||
-resume-min-presence-ahead: 24pt;
|
||
}
|
||
|
||
@media (max-width: 600pt) {
|
||
region[placement="sidebar"] section-heading {
|
||
font-size: 9pt;
|
||
}
|
||
}
|
||
|
||
resume[template="azurill"] template-part[name="timeline-dot"] {
|
||
background-color: var(--accent);
|
||
}
|
||
```
|
||
|
||
The E2E test pastes it into at least Onyx, Azurill, and Ditto; verifies group, exact section, header, system variable,
|
||
placement, exact item/field, rich text, media query, pagination directive, and template-part behavior; introduces invalid
|
||
syntax; verifies last-valid preview/export; fixes it; then verifies preview/export update together.
|
||
|
||
- [ ] **Step 2: Verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false pnpm exec playwright test \
|
||
tests/e2e/specs/semantic-css/legacy-conversion.spec.ts \
|
||
tests/e2e/specs/semantic-css/invalid-last-valid.spec.ts \
|
||
tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts \
|
||
tests/e2e/specs/semantic-css/revision-conflict.spec.ts \
|
||
tests/e2e/specs/semantic-css/template-visual.spec.ts
|
||
pnpm --filter @reactive-resume/pdf test -- src/semantic/all-templates-smoke.test.tsx
|
||
```
|
||
|
||
Expected: FAIL on visible Semantic CSS behavior, screenshot, or documentation-staleness assertions; module/configuration errors
|
||
must be fixed before continuing.
|
||
|
||
- [ ] **Step 3: Write migration and language documentation**
|
||
|
||
Document:
|
||
|
||
- Semantic CSS vs browser CSS;
|
||
- `@version`;
|
||
- semantic tree/selectors and exact IDs;
|
||
- read-only `--resume-*` tokens;
|
||
- supported property matrix and units;
|
||
- structural/pagination directives;
|
||
- diagnostics and last-valid behavior;
|
||
- legacy conversion/activation;
|
||
- portability guidance and template-part manifests;
|
||
- unsupported fonts/assets/Grid/interaction;
|
||
- gradients/shadows as future SVG-backed extensions.
|
||
|
||
Generate registry tables from typed registries; fail tests if generated reference output is stale.
|
||
|
||
`tooling/semantic-css/generate-reference.ts` imports the public registry export and writes only the generated reference
|
||
sections. Add root script `docs:semantic-css` and tooling test that generates into a temporary file and compares it with
|
||
the committed MDX. Add `@reactive-resume/resume` as a tooling workspace dependency and a Vitest `test` script before
|
||
running:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/tooling add "@reactive-resume/resume@workspace:*"
|
||
pnpm --filter @reactive-resume/tooling add -D vitest@latest
|
||
pnpm docs:semantic-css
|
||
pnpm --filter @reactive-resume/tooling test -- semantic-css/generate-reference.test.ts
|
||
```
|
||
|
||
- [ ] **Step 4: Add all-template smoke and visual regression**
|
||
|
||
Render all 15 templates with the comprehensive fixture in `all-templates-smoke.test.tsx` and snapshot each resolved
|
||
presentation map in `all-templates-presentation.test.ts`. `template-visual.spec.ts` uses Playwright
|
||
`expect(page).toHaveScreenshot()` for one deterministic first-page preview per template; committed Linux/Chromium
|
||
baselines live in its `-snapshots` directory and update only with:
|
||
|
||
```bash
|
||
pnpm exec playwright test tests/e2e/specs/semantic-css/template-visual.spec.ts --update-snapshots
|
||
```
|
||
|
||
Registry coverage asserts every semantic kind, conditional field, role, and template part is exercised. The existing
|
||
E2E workflow already uploads Playwright reports; extend its commands so Semantic CSS tests run under explicit flag states:
|
||
|
||
```bash
|
||
FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false pnpm exec playwright test --grep-invert "@semantic-css"
|
||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false pnpm exec playwright test \
|
||
tests/e2e/specs/semantic-css/legacy-conversion.spec.ts \
|
||
tests/e2e/specs/semantic-css/invalid-last-valid.spec.ts \
|
||
tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts \
|
||
tests/e2e/specs/semantic-css/revision-conflict.spec.ts \
|
||
tests/e2e/specs/semantic-css/template-visual.spec.ts
|
||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=true pnpm exec playwright test tests/e2e/specs/semantic-css/default-mode.spec.ts
|
||
FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false pnpm exec playwright test \
|
||
tests/e2e/specs/semantic-css/dormant-mode.spec.ts \
|
||
tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts
|
||
```
|
||
|
||
Document those commands in `tests/e2e/README.md`. The default-on test asserts a new resume starts semantic; the dormant
|
||
test asserts the legacy form/render path and old-client update preservation remain available. The flag-off-semantic test
|
||
seeds an active semantic resume, verifies semantic rendering remains active, and verifies Custom Styles shows a
|
||
read-only unavailable notice instead of the legacy form. Tag every Semantic CSS test title with `@semantic-css` so the baseline
|
||
flag-off run excludes only this feature suite.
|
||
|
||
- [ ] **Step 5: Perform manual GUI walkthrough and record evidence**
|
||
|
||
Start the app with the documented Postgres/environment setup. Use the computer-use workflow to:
|
||
|
||
1. Open a legacy resume and inspect converted inactive source.
|
||
2. Activate after parity.
|
||
3. Paste the portable stylesheet.
|
||
4. Exercise compact and expanded/focus modes.
|
||
5. Trigger an error and confirm the preview remains last-valid.
|
||
6. Resolve the error and export the matching PDF.
|
||
7. Test desktop and mobile layout.
|
||
|
||
Record one concise video beginning immediately before the successful demonstration and ending immediately after export.
|
||
|
||
- [ ] **Step 6: Run package and repository gates**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @reactive-resume/schema test
|
||
pnpm --filter @reactive-resume/resume test
|
||
pnpm --filter @reactive-resume/pdf test
|
||
pnpm --filter @reactive-resume/api test
|
||
pnpm --filter web test
|
||
pnpm exec turbo boundaries
|
||
pnpm typecheck
|
||
pnpm build
|
||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false pnpm exec playwright test \
|
||
tests/e2e/specs/semantic-css/legacy-conversion.spec.ts \
|
||
tests/e2e/specs/semantic-css/invalid-last-valid.spec.ts \
|
||
tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts \
|
||
tests/e2e/specs/semantic-css/revision-conflict.spec.ts \
|
||
tests/e2e/specs/semantic-css/template-visual.spec.ts
|
||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=true pnpm exec playwright test tests/e2e/specs/semantic-css/default-mode.spec.ts
|
||
FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false pnpm exec playwright test \
|
||
tests/e2e/specs/semantic-css/dormant-mode.spec.ts \
|
||
tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts
|
||
pnpm check
|
||
git diff --check
|
||
```
|
||
|
||
Expected: every command exits 0; `pnpm check` may rewrite files, so inspect and include only intended formatting.
|
||
|
||
- [ ] **Step 7: Verify rollout states**
|
||
|
||
Test:
|
||
|
||
1. both flags false: dormant backend and legacy editor/rendering;
|
||
2. authoring true/default false: opt-in conversion and explicit activation;
|
||
3. authoring false on an already semantic resume: rendering still honors persisted semantic mode;
|
||
4. both true: new resumes begin semantic with empty v1 source;
|
||
5. mixed old client update: server preserves stylesheet;
|
||
6. public projection mismatch: authorized fallback;
|
||
7. rollback: legacy rules retained and semantic mode can be disabled per resume without double application.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add docs tests/e2e packages/pdf tooling package.json pnpm-lock.yaml .github/workflows/e2e.yml
|
||
git commit -m "docs: add Semantic CSS reference and acceptance coverage"
|
||
```
|
||
|
||
---
|
||
|
||
## Final Review Checklist
|
||
|
||
- [ ] Every production behavior was introduced by a test that first failed for the expected reason.
|
||
- [ ] Compiler v1 is immutable and selected by persisted version.
|
||
- [ ] Generated/default source contains `@version 1;`; missing hand-written directive only warns in v1.
|
||
- [ ] Reserved `--resume-*` variables expose base visual settings but not font family or assets.
|
||
- [ ] Compiler, worker protocol, and public projection contain only structured-clone/JSON-safe plain values.
|
||
- [ ] Generic updates and patches cannot overwrite server-owned stylesheet state.
|
||
- [ ] Preflight happens outside locks; short CAS checks stylesheet revision and render-data version.
|
||
- [ ] No semantic instrumentation added a React PDF layout wrapper.
|
||
- [ ] Structural selectors match source order and apply only once.
|
||
- [ ] All 15 templates publish manifests and pass smoke/visual coverage.
|
||
- [ ] #3146, #3134, #3137, #2223, and #3199 are named regression fixtures.
|
||
- [ ] Legacy conversion is behavioral, deterministic, parity-gated, and never double-applied.
|
||
- [ ] Editable/applied source and diagnostics are owner-only.
|
||
- [ ] Public projection hash/fingerprint mismatches use an authorized, rate-limited fallback.
|
||
- [ ] CodeMirror and Prettier remain lazy chunks.
|
||
- [ ] Desktop focus mode preserves the preview through panel resize; mobile Sheet has an accessible title.
|
||
- [ ] Invalid source survives reload while preview/export use the last valid applied source.
|
||
- [ ] DOCX/Markdown output remains unchanged.
|
||
- [ ] Documentation tables are generated from runtime registries.
|
||
- [ ] Full tests, typechecks, boundaries, build, lint/format, E2E, and manual video walkthrough are green.
|
||
|
||
## Execution Notes
|
||
|
||
- Recommended execution style: subagent-driven development with one fresh implementer per task and a specification review
|
||
plus code-quality review after each task.
|
||
- Use one logical commit per task. Do not squash or amend unless explicitly requested.
|
||
- Backend safety (Task 7) can deploy dormant before PDF/editor work. Do not enable authoring until Tasks 1–14 are green.
|
||
- The largest risk is Tasks 8–10: descriptor parity across shared sections, rich text, and all templates. Stop and revise
|
||
the semantic registry if these tasks require styling-only wrappers.
|
||
- The second largest risk is Task 12: legacy behavioral parity. Activation must remain blocked on mismatch rather than
|
||
silently changing existing resumes.
|
||
- The public projection and server preflight may add meaningful CPU cost. Capture render duration, source size, output
|
||
page count, preflight failures, activation failures, projection mismatch, and fallback usage in existing structured
|
||
server logs before opt-in rollout.
|