Vancetope — Report Themes

A Report Theme is a named CSS file that controls the appearance of a Markdown report exported as PDF — per customer, per Project, per document. Two optional Frontmatter keys (theme: and css:) select layers that are loaded after the bundled default theme; later CSS rules win the cascade.

PDF-only. The theme system exclusively affects the PDF export path (PdfReportRenderer → openhtmltopdf). DOCX/ODT render via a programmatic AST visitor (Apache POI / ODF Toolkit) and have no CSS path — theme:/css: are ignored there. A web UI preview is not part of this spec (see §9).

Persistence: Themes are stored as CSS files under _vance/report-themes/<name>.css in the Document Layer. The cascade lookup project → _tenant → classpath:vance-defaults/_vance/report-themes/ runs via DocumentService.lookupCascade — the same mechanism as Recipes, Manuals, Templates, and the Model Catalog.

See also: document-templates (same cascade) document-refs (css: resolution) cortex (File → Export PDF) report-export (LLM Tool Manual)

1. Terms and Delimitation

Term What it is
Report Theme A CSS file under _vance/report-themes/<name>.css that is resolved via the Document Cascade. Affects only PDF export.
Default Theme default.css, bundled on the Classpath. Is always loaded — provides the @page setup and basic typography. Extracted 1:1 from the former printCss() string constant.
theme: Frontmatter Key Names a Theme in the Markdown Frontmatter (theme: acme). Resolves the Theme via the Cascade. Name validates against [a-z0-9-]+.
css: Frontmatter Key A vance: document reference or bare path to an additional CSS file (css: vance:/styles/round.css). Loads after the Theme.
Layer One of the three CSS layers (default → theme → css-ref). Additive; later wins the cascade.

Report Themes are not a web UI theme. They do not change how the Cortex or the Chat looks. They exclusively control the PDF generated by the “File → Export PDF” menu item or the report_from_markdown Tool. The web UI renders Markdown via marked + DOMPurify; a Theme in the PDF has no equivalent there (see §9).

Report Themes are not a DOCX/ODT style. These formats have no CSS path; their renderers programmatically build the document via POI/ODF AST visitors. A theme system for all formats would be a separate architectural Project and explicitly not the subject of this spec.

Report Themes are not a Prompt Template. They do not change the content of the report — no Pebble variables, no conditional rendering. They are pure styling.


2. Frontmatter — The Two Keys

Both keys are optional, both can be set simultaneously, both are parsed only for Markdown (text/markdown). Other Mimes (YAML/JSON/Plain) have no Vance Frontmatter; the export uses the Default Theme for them.

---
theme: acme
css: vance:/styles/round-borders.css
---

# My Report
...
Key Type Validation Resolution
theme Theme name ([a-z0-9-]+) Regex check; invalid name → WARN + skip _vance/report-themes/<name>.css via DocumentService.lookupCascade (Project → _tenant → classpath)
css vance: document reference or bare path DocumentRefResolver against Project Root; unresolvable → WARN + skip DocumentService.findByPath (in the resolved Project, possibly cross-Project via //authority/)

Both keys can be present together. Load order (last rule wins the CSS cascade):

  1. default.css — always (bundled)
  2. _vance/report-themes/<theme>.css — if theme: is set
  3. The file referenced via css: — if css: is set

Thus, a per-document override (css:) trumps a per-Project Theme (theme:), which in turn overrides the bundled Default. The web UI preview does not show this (see §9).

Frontmatter is stripped from the Body. Before the Body goes to commonmark-java, ReportFrontMatter.parse completely removes the --- fence. Without this step, commonmark would render ---\ntheme: acme\n--- as a setext H2 (“theme: acme” as heading text) — ugly in the PDF. The $meta: block, which Vance documents use for their own metadata, is removed in the same pass; it does not belong in the exported report anyway.


3. Theme Storage Location and Cascade

Themes are located under _vance/report-themes/. Three layers, innermost wins:

Layer Location Purpose
Classpath (bundled) vance-brain/src/main/resources/vance-defaults/_vance/report-themes/<name>.css Included Themes (default.css always; acme.css as an example). Cannot be overwritten, only overlaid.
_tenant (tenant-wide) _vance/report-themes/<name>.css in the _vance Tenant Operator override for all Projects of the Tenant.
Project _vance/report-themes/<name>.css in the current Project Project-specific Theme — trumps tenant-wide and bundled.

The cascade runs via DocumentService.lookupCascade — the same mechanism as for Recipes, Manuals, and Templates: first-hit-wins, no merge. A Project Theme replaces a tenant-wide Theme completely, not additively. To work additively tenant-wide, place the Theme in _tenant and use css: for per-Project or per-document overrides.

The Default cannot be overlaid via the Cascade. default.css is the lowest layer and is always loaded, even if a Theme with the same name exists in the Project. To replace the Default, write a Theme that overrides its rules (and use the CSS cascade, which follows the load order).

No subfolders. A Theme is exactly one file at _vance/report-themes/<name>.css. The name must not contain slashes (regex [a-z0-9-]+); there is no _vance/report-themes/customer/acme.css. To have customer-specific Themes, name them acme.css, customer-acme.css, etc.


4. Theme Authoring — The One Hard Rule

openhtmltopdf parses the HTML document as XHTML, including the <style> block. This means: CSS comments must not contain angle brackets.

/* ❌ BREAKS the parser — <name> is interpreted as an XML tag */
/* acme theme: _vance/report-themes/<name>.css override */
/* ✓ OK — Plain text without < or > */
/* acme theme — override for _vance/report-themes/ */

An angle bracket in a CSS comment leads to a cryptic error:

org.xml.sax.SAXParseException; lineNumber: 41; columnNumber: 3;
The element type "name" must be terminated by the matching end-tag "</name>".

The bundled default.css is the canonical reference author: comments in plain text, rules in the openhtmltopdf subset. acme.css shows a complete override example.

openhtmltopdf is not a browser. Supported: @page, @bottom-right/@top-center for Running Marginals, the small typographic subset (font-family, font-size, color, margin, padding, border, border-radius, background). Not supported: CSS animations, @media queries, JavaScript, CSS variables (var(--x)), Grid layouts. Pixel tuning for screen view does not belong here — it is optimized for print.


5. css: — The Per-Document Override

The css: key takes a vance: document reference or a bare path. Both are resolved via DocumentRefResolver — the same grammar that also governs Skill and Guard references (see document-refs).

Notation Meaning
css: vance:/styles/round.css Absolute in the current Project
css: styles/round.css Relative to the Project Root
css: vance://other-project/styles/round.css Cross-Project (//authority/ form)
css: vance:round.css Relative to the referrer folder (here Project Root, because Frontmatter is at the Root)

The resolved file is loaded via DocumentService.findByPath — with read permission check. A dead reference logs a WARN and falls back to the previous layer (Default + Theme if applicable); the render does not break.

When to use css: instead of theme:?

  • theme: for a reusable, named Theme that multiple documents share (e.g., “acme house style”).
  • css: for a per-document or per-Project customization that does not deserve its own Theme name — e.g., a special stylesheet only for the annual report.

Both together: theme: acme provides the house rules, css: vance:/reports/2026/annual.css refines the specific document afterwards.


6. Render Pipeline — Where CSS Takes Effect

Markdown-Source
    │
    ▼
ReportFrontMatter.parse          ← strips ---\ntheme:…\ncss:…\n--- from the Body
    │   → body (without Frontmatter), theme, css
    ▼
MarkdownReportContext            ← carries theme/css as fields
    │
    ▼
PdfReportRenderer.render
    │   ├─ commonmark-java: body → bodyHtml
    │   ├─ ReportThemeResolver.resolveStylesheet(tenant, project, theme, css)
    │   │     ├─ loadDefault()       ← classpath: vance-defaults/_vance/report-themes/default.css
    │   │     ├─ loadTheme()        ← DocumentService.lookupCascade(_vance/report-themes/<name>.css)
    │   │     └─ loadCssRef()       ← DocumentRefResolver + DocumentService.findByPath
    │   └─ buildHtmlDocument(context, bodyHtml)
    │         <style> [default] \n [theme] \n [css-ref] </style>
    ▼
openhtmltopdf (XHTML-Parser → PDF)

The CSS is loaded server-side as a string and injected into the <style> block — not via <link> and not via @import. This prevents the SAFE_URI_RESOLVER (which blocks file:/jar:/loopback) from interfering with Theme loading; a Theme cannot accidentally pull local files. CSS url() references within the stylesheets are resolved by openhtmltopdf against the base URI null and thus skipped — so a Theme cannot pull external resources anyway.

Fail-open. A missing Theme or a dead css: reference does not break the render — it falls back to the previous layer. Only a missing Default (internal misconfiguration) leaves an empty Default block and a WARN; the render still proceeds.


7. Trigger Paths — Who Reads theme:/css:

Two places create a MarkdownReportContext and pass on the Frontmatter values:

Path Frontmatter Source Code
report_from_markdown Tool documentRef or inline markdown — both are parsed ReportFromMarkdownTool
Cortex “File → Export PDF” The Markdown document open in the tab DocumentController.exportPdf

Both call ReportFrontMatter.parse(source) before building the Context. This ensures identical behavior: whether the Agent or the human exports, the Theme takes effect.

Inline markdown (Tool) is also parsed — an Agent passing inline Markdown with theme: Frontmatter gets the same Theme as a saved document. This is intentional, not accidental.


8. What Is Not Part of This Spec

  • DOCX/ODT Themes. These formats have no CSS path; a theme system for them is an architectural Project (separate Theme descriptors in Java, POI/ODF style objects). Explicitly Phase 3, if at all.
  • Web UI Preview. MarkdownView.vue strips <style> via DOMPurify (deliberately: untrusted Chat/web-fetch content). A live Theme preview requires a controlled CSS injection point separate from the sanitizer — not a sanitizer softening. See §9.
  • Theme Editor in the UI. Themes are CSS files; they are edited like any Vance document in the Cortex (or installed via Kit). There is no dedicated “Theme Editor” build, no preview-while-editing.
  • Theme Discovery / /theme Command. There is no Tool or REST endpoint that lists available Themes. To get the list, list the _vance/report-themes/ folder via doc_list. Themes are metadata, not a first-class object of the interface.
  • Multilingual Themes. CSS is not localizable; a Theme applies to all languages. Language is controlled by the ## Languages block in the System Prompt, not the Theme.

9. Web UI Preview (Phase 3 — This Spec)

9.1 What the Problem Was

The web UI renders Markdown via marked + DOMPurify. DOMPurify deliberately strips <style> and <link> tags: the Markdown Body in a Chat or from web_fetch is untrusted content, and a <style> tag there would be an attack vector (CSS exfil, url() leaks, @import chains). This remains unchanged — the sanitizer will not be softened.

9.2 The Solution: Three Layers, Three Injection Points

The web preview separates the three layers by trust level and gives each its own path into the DOM:

Layer Source Trust Injection Point
Theme CSS _vance/report-themes/<name>.css + css: reference semi-trusted (Operator/Tenant/Project Doc, not user-supplied) server-filtered + server-scoped, as <style> in the light DOM
Base Styles Vance code (<style scoped> of the component) fully trusted normal Vue scoped style, like any component
Markdown Body marked + DOMPurify untrusted (Chat, web-fetch, LLM output) innerHTML, unchanged DOMPurify

The key: the Theme CSS does not come through the Markdown Body and not through the sanitizer. It is a separate <style> element that the component itself sets — the same host that MarkdownView uses today, but with a different source for the CSS.

9.3 Why No Shadow DOM

Shadow DOM was the first idea — it would have provided true CSS encapsulation across a DOM boundary. It was rejected for three reasons:

  1. Kinds do not work in the Shadow Root. A Markdown document contains embedded Kinds — inline Fences (canvas\n…) and vance: links ([Plan](vance:/plan.canvas)). These are currently rendered as Vue VNodes (<InlineKindBox>, <EmbeddedKindBox>, <LinkCard>) — components with reactivity, lifecycle, provide/inject. In the Shadow Root, there would be two ugly ways: innerHTML (loses the VNodes, they are not an HTML string) or manual createApp/render mounting per Kind placeholder (breaks provide/inject, error-prone lifecycle management). Both are significantly more than a preview feature is worth.
  2. Vue reactivity breaks. MarkdownView’s render function returns a VNode array — this only works in the light DOM. In the Shadow Root, mounting would have to be built manually.
  3. Encapsulation comes from elsewhere. If the server filters and scopes the CSS before it goes to the client, the client no longer needs a DOM boundary — the scoped CSS can be safely injected into the light DOM, and the VNodes continue to function normally.

Encapsulation is therefore a server responsibility, not a DOM architecture. This is the fundamental decision of this phase.

9.4 Server: The theme-css Endpoint

Endpoint: GET /brain/{tenant}/documents/{id}/theme-css

Response: text/css; charset=utf-8

What the server does (in this order):

  1. Load DocDocumentService.findById(id), READ-checked (existing PermissionService path, no new enforcement). 404 if not found, 403 if no READ.
  2. Mime Gatetext/markdown only. Other Mimes return empty CSS (200, Content-Length: 0), no error — a non-Markdown Doc has no Theme, and the client should not show an error state. This is the same gate as exportPdf.
  3. Parse FrontmatterReportFrontMatter.parse(content) returns (body, theme, css). body is not needed here (the client renders the Body itself), but theme/css are.
  4. Assemble Theme CSSReportThemeResolver.resolveStylesheet(...) (existing method, same code as PDF path). Returns the three-layer CSS string (default → theme → css-ref) or only default.css if Theme/Ref is missing (fail-open, as before).
  5. Filter — the CSS string goes through a CssSanitizer (new, see §9.5) that removes dangerous constructs.
  6. Scope — the filtered CSS string goes through a CssScopePrefixer (new, see §9.6) that prefixes every selector with .markdown-document-preview.
  7. Respond — the scoped, filtered CSS string as text/css.

Why doc-based, not theme-name-based: the client already has the Doc ID (it has the Doc open), but it does not know the Theme name without reading the Doc. Parsing the Frontmatter and resolving the Cascade is server logic (ReportFrontMatter + ReportThemeResolver) that the client should not replicate. Furthermore, a Doc can have a css: reference that pulls a completely different stylesheet — a theme-name endpoint cannot provide that. The doc-based endpoint is the superset.

Caching: Cache-Control: public, max-age=60 (60s). Themes rarely change, but if they do, a reload should re-render the Doc immediately. ETag from the Doc storageIdIf-None-Match → 304. No Redis cache needed; the client cache is sufficient, and a Theme edit triggers a Doc reload anyway.

9.5 Server: CssSanitizer — What Is Filtered

The Theme CSS comes from semi-trusted sources (Operator/Tenant/Project Doc, not user-supplied Chat). It is not untrusted HTML — the main attack vector “<script> in CSS” does not exist because CSS does not generate HTML elements. The attack surface of CSS is small but real. The filter is an allowlist for CSS constructs, not for HTML:

Removed (strictly):

  • @import — a Theme must not pull external stylesheets. Every @import is deleted (with WARN log).
  • url() with external targets — url(https://…), url(http://…), url(file://…), url(jar:…), url(ftp://…) are removed. Only url(data:…) (inline images/fonts) remains allowed. No relative paths (a Theme in the browser cannot resolve a Vance Doc Ref — that would be a separate mechanism; v1 only allows data:). This is stricter than the PDF path (which resolves url() against base URI null and skips it), because the browser is a real resource loader and a url('https://evil/x.png') actually fires a request (exfil/SSRF).
  • javascript: URIs — in url() or as an attribute value.
  • IE relics — expression(...), behavior, -moz-binding.

Allowed:

  • All standard CSS properties (color, font-*, margin, padding, border, background, display, …).
  • @media print and @media screen (the preview is screen, but a Theme can address both).
  • @page (ignored in the browser, but harmless; the PDF path uses it).
  • @font-face only with data:-src — external font URLs are filtered like url(). v1 recommends system fonts; to use custom fonts, embed them as data:.
  • Selectors of all kinds (h1, .class, #id, >, +, ~, :hover, :first-child, …). Selectors are not filtered — they are scoped (§9.6).

Implementation: a simple token scanner, not a full CSS parser. CSS is tolerant enough that “find @import and delete the line” + “find url(...) and check the schema” are sufficient. The danger of an incomplete filter is a leftover @import (gross syntax error in CSS, does not break the page) or a leftover url('https://…') (a request that is fired — this is the real risk, which is why the url() filter is the most important).

9.6 Server: CssScopePrefixer — How Scoping Is Done

Every selector in the filtered CSS gets .markdown-document-preview as a prefix — doubled: .markdown-document-preview.markdown-document-preview. h1 { color: red } becomes .markdown-document-preview.markdown-document-preview h1 { color: red }.

Why doubled. MarkdownView (the child of the preview component) has Vue-Scoped-Styles of the form .markdown-view[data-v-xxx] a { color: var(--color-primary) } (specificity 0,2,1). A simple scope rule .markdown-document-preview a has only 0,1,1 and loses the cascade — the Theme colors for links, code background, and other properties that MarkdownView also styles would not apply (only properties that MarkdownView ignores — h1 color, pre border — would survive). Doubling gives the Theme rule the same specificity 0,2,1; source order then decides ties, and the Theme <style> is in the DOM after MarkdownView’s Scoped Styles → the Theme wins. Browser verification: all four acme Theme colors (h1 #8a6d1a, pre border #c9a227, pre background #fffaf0, link #a06a1a) apply in the preview.

This prevents a Theme from accidentally styling body { background: red } — it becomes .markdown-document-preview.markdown-document-preview body { … }, which never matches (there is no body below the component).

Implementation: a simple selector prefixer, not a full CSS parser.

  • Split at } — each block is selectorlist { declarations }.
  • Split the selector list at , — each individual selector.
  • CSS comments are removed from the selector list before prefixing. A comment before the selector (/* note */ h1) would otherwise end up between the scope prefix and the element (.markdown-document-preview /* note */ h1) and break the cascade. The bundled acme.css contains exactly such comments — this case is produced, not constructed.
  • Prefix each selector with .markdown-document-preview.markdown-document-preview (except for @media/@page/@font-face blocks — these retain their @ and are further prefixed internally, or remain untouched for @font-face/@page/@keyframes because their selectors do not target the document).
  • & combinator (Sass/Less) is not supported — Themes should be flat CSS.

Limitation: the prefixer is not a full parser. Nested selectors (CSS Nesting, &) are not correctly prefixed. This is accepted — Themes should be flat CSS (the bundled default.css/acme.css are flat), and the PDF path does not need scoping (there, the “encapsulation” is the entire PDF). A Theme author who writes nesting gets the correct result in the PDF and a broken one in the preview — a documented compromise, not a bug.

9.7 Client: MarkdownView as Child (No Composable Extract)

The token→VNode logic (vnodesForTokens, Inline Kinds, Frontmatter strip, KaTeX lazy load, link rewriting) remains in MarkdownView.vueno refactor into a Composable. MarkdownDocumentPreview.vue uses MarkdownView as a child component.

  • No code duplication of the token walker logic: the complex logic remains in one place (MarkdownView); the preview component delegates the Markdown Body to it.
  • No risk for Chat/Inbox/Search: a Composable extract would have touched MarkdownView.vue — the central component for untrusted content (Chat, Inbox, Search, LinkCards). A refactor there is a risk for the surfaces that must never see Theme CSS. The preview component does not touch MarkdownView; it merely wraps it.
  • Kinds work: the VNodes from MarkdownView are normal Vue components (InlineKindBox/EmbeddedKindBox/LinkCard), on the component tree, with provide/inject/lifecycle — everything works because we are in the light DOM.

The only difference between MarkdownView and MarkdownDocumentPreview: the preview component additionally loads the Theme CSS from the endpoint and injects it as a <style> element in the light DOM above the MarkdownView child. The Markdown Body still goes through sanitize() — DOMPurify strips <style> from the Body, the Theme CSS comes from elsewhere.

9.8 Client: MarkdownDocumentPreview.vue

New component, used only in the Cortex tab (not in Chat/Inbox/Search/LinkCards — these retain MarkdownView). Registered as codePreview for the Markdown Kind in builtInKinds.ts; DocumentTabShell.vue passes :document-id to the codePreview only for Markdown.

Props: source: string | null (Markdown Body), documentId: string | null (for the Theme CSS fetch).

Render Structure (light DOM):

<div class="markdown-document-preview">
  <style v-if="themeCss">&#123;{ themeCss }}</style>   <!-- server-filtered + scoped -->
  <MarkdownView :source="source" :referrer-dir="..." />  <!-- Child, unchanged -->
</div>

Theme CSS Fetch: on mount (and on documentId change) GET /documents/{id}/theme-css via brainFetchText. themeCss is a ref<string>, default empty. In-memory cache per tab (switching between tabs/docs does not reload as long as it’s the same Doc). On error (404/403/network) → empty + WARN console, no error state (fail-open like the PDF path). On success → CSS string into the <style>.

Frontmatter Strip Suppressed: MarkdownView renders a chip strip with the Frontmatter key/value pairs (theme: acme) above the Body. In the preview interface, this is configuration metadata, not report content — it reads as clutter above the rendered document. The component hides the strip via scoped CSS :deep(.markdown-view__frontmatter) { display: none }. MarkdownView retains the strip for Chat/Inbox/Search (where the metadata is contextual); only the dedicated preview suppresses it. The Body is unaffected — the Frontmatter has already been removed from the rendered Markdown by MarkdownView’s extractFrontmatter; here, only the chip strip is hidden.

Base Styles: the component has its own <style scoped> styles (only minimal base typography on .markdown-document-previewfont-size, line-height, word-break). The actual typography of the rendered Markdown comes from MarkdownView’s own scoped styles (the child). The Theme CSS is in the light DOM within the component, but applies to the same elements via the scope prefix (§9.6). Cascade: MarkdownView scoped (Base) → Theme (server-scoped) → Theme wins (doubled scope class, §9.6).

No KaTeX Problem: KaTeX CSS is currently loaded dynamically into the <head>. In the light DOM, this applies to the preview just as it does to MarkdownView — no extra effort. (In the Shadow DOM, KaTeX CSS would not have applied — another point in favor of light DOM.)

9.9 What Phase 3 Is Not

  • No Theme preview in Chat/Inbox/Search/LinkCards. These surfaces show untrusted content; a Theme there would be an injection vector (even if filtered — the Theme would be applied to Chat content, which is not intended). MarkdownView remains strictly without Theme, everywhere except in the Cortex tab.
  • No Theme preview for CodeMirror edit mode. The preview applies only to the view mode (rendered Markdown). The edit mode is CodeMirror, not Markdown HTML.
  • No Theme editor in the web UI. Themes are managed as CSS Documents (Cortex, doc_write, Kit) — the same interface as any other document. A visual editor is a separate feature.
  • No Theme discovery in the web UI. The endpoint resolves the Theme for a given Doc; the UI does not show “which Themes are available”. (This could later be a GET /report-themes listing endpoint, not part of this phase.)
  • No hot-reload on Theme edit. If the Operator changes the Theme CSS, the Doc must be reloaded (because the client cache holds for 60s). A WebSocket push on Theme change is conceivable, but not part of this phase.

9.10 Files (in addition to §11)

File Role
vance-brain/.../documents/DocumentController.java New handler themeCss(id)GET /documents/{id}/theme-css, text/css; exportPdf() handler with ReportThemeResolver injection
vance-brain/.../tools/report/CssSanitizer.java New — filters @import, external url() (only data:), javascript: URIs, IE relics from CSS
vance-brain/.../tools/report/CssScopePrefixer.java New — prefixes every selector with .markdown-document-preview.markdown-document-preview (doubled, §9.6)
vance-brain/.../test/.../CssSanitizerTest.java New — 22 filter tests
vance-brain/.../test/.../CssScopePrefixerTest.java New — 23 scoping tests (incl. comment strip)
vance-brain/.../test/.../DocumentControllerThemeCssTest.java New — 9 endpoint integration tests
client/.../components/MarkdownView.vue Unchanged — remains the central component for Chat/Inbox/Search; no refactor, no Composable extract
client/.../components/MarkdownDocumentPreview.vue New — Theme preview component, Cortex tab only; uses MarkdownView as child, injects Theme CSS, hides Frontmatter strip
client/.../document/builtInKinds.ts Markdown codePreviewMarkdownDocumentPreview (view mode only)
client/.../cortex/components/DocumentTabShell.vue Passes :document-id to the codePreview only for Markdown Kind

10. Security

  • Theme CSS is not user-supplied HTML. The CSS comes server-side from the Document Cascade (or bundled), not from the parsed Markdown Body. The sanitizer filter does not apply here — the attack vector “untrusted <style> in Markdown” does not exist because the Theme does not originate from the Markdown at all.
  • css: references are checked for read permission. DocumentService.findByPath enforces READ on the target Project/document; a reference to an external Project for which the caller has no READ fails (fail-open, not fail-loud — the render proceeds, just without this CSS layer).
  • No external resource pulls. openhtmltopdf receives the CSS as a string in the <style> block; url() references in the CSS are resolved against base URI null and skipped. A Theme therefore cannot enforce @import 'https://evil' or background: url('file:///etc/passwd').
  • Theme names are path-safe. Regex [a-z0-9-]+ — no traversal, no special characters. The Cascade path is built by the Resolver, never taken from user input.

11. Files

File Role
vance-brain/.../tools/report/ReportThemeResolver.java Three-layer CSS assembly (default + theme + css-ref), @Service
vance-brain/.../tools/report/ReportFrontMatter.java Strips theme:/css: from the Frontmatter, returns (body, theme, css)
vance-brain/.../tools/report/MarkdownReportContext.java Record; new theme/css fields, 5-arg compatibility constructor
vance-brain/.../tools/report/PdfReportRenderer.java Injects ReportThemeResolver, builds <style> block; printCss() removed
vance-brain/.../tools/report/ReportFromMarkdownTool.java Parses Frontmatter, passes theme/css to the Context
vance-brain/.../documents/DocumentController.java exportPdf endpoint: parses Frontmatter, passes theme/css to the Context
vance-brain/.../resources/vance-defaults/_vance/report-themes/default.css Bundled Default (extracted from former printCss())
vance-brain/.../resources/vance-defaults/_vance/report-themes/acme.css Example override: rounded code blocks + warm accent
vance-brain/.../resources/vance-defaults/_vance/manuals/report-export.md LLM Tool Manual — Frontmatter contract, author trap, Frontmatter stripping
vance-brain/.../resources/vance-defaults/_vance/manuals/report-themes.md Operator Manual — “how to create a Theme”
vance-brain/.../test/.../ReportThemeResolverTest.java 14 tests: three layers, fail-open, order, Frontmatter
readme/cortex-export-pdf.md Implementation documentation of the Cortex menu item

12. History / Planning

  • Phase 1 (productive): theme: key + server-side Theme Cascade, PDF-only. Built with this spec.
  • Phase 2 (productive): css: key as per-document override, PDF-only. Built with this spec.
  • Phase 3 (in progress, this spec §9): Web UI Theme Preview for the Cortex tab. Doc-based GET /documents/{id}/theme-css endpoint delivers server-filtered + server-scoped CSS; new MarkdownDocumentPreview.vue component (Cortex tab only), uses useMarkdownTokens Composable (extracted from MarkdownView.vue). No Shadow DOM — encapsulation via server-side CSS scoping, so embedded Kinds function as VNodes.
  • Phase 4 (open, if at all): DOCX/ODT Theme support via Java Theme descriptors. Architectural Project, not a small feature.