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:andcss:) 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>.cssin the Document Layer. The cascade lookupproject → _tenant → classpath:vance-defaults/_vance/report-themes/runs viaDocumentService.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):
default.css— always (bundled)_vance/report-themes/<theme>.css— iftheme:is set- The file referenced via
css:— ifcss: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.vuestrips<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 /
/themeCommand. There is no Tool or REST endpoint that lists available Themes. To get the list, list the_vance/report-themes/folder viadoc_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
## Languagesblock 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:
- Kinds do not work in the Shadow Root. A Markdown document contains embedded Kinds — inline Fences (
canvas\n…) andvance: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 manualcreateApp/rendermounting per Kind placeholder (breaksprovide/inject, error-prone lifecycle management). Both are significantly more than a preview feature is worth. - 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. - 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):
- Load Doc —
DocumentService.findById(id),READ-checked (existingPermissionServicepath, no new enforcement). 404 if not found, 403 if noREAD. - Mime Gate —
text/markdownonly. 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 asexportPdf. - Parse Frontmatter —
ReportFrontMatter.parse(content)returns(body, theme, css).bodyis not needed here (the client renders the Body itself), buttheme/cssare. - Assemble Theme CSS —
ReportThemeResolver.resolveStylesheet(...)(existing method, same code as PDF path). Returns the three-layer CSS string (default → theme → css-ref) or onlydefault.cssif Theme/Ref is missing (fail-open, as before). - Filter — the CSS string goes through a
CssSanitizer(new, see §9.5) that removes dangerous constructs. - Scope — the filtered CSS string goes through a
CssScopePrefixer(new, see §9.6) that prefixes every selector with.markdown-document-preview. - 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 storageId — If-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@importis deleted (with WARN log).url()with external targets —url(https://…),url(http://…),url(file://…),url(jar:…),url(ftp://…)are removed. Onlyurl(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 allowsdata:). This is stricter than the PDF path (which resolvesurl()against base URInulland skips it), because the browser is a real resource loader and aurl('https://evil/x.png')actually fires a request (exfil/SSRF).javascript:URIs — inurl()or as an attribute value.- IE relics —
expression(...),behavior,-moz-binding.
Allowed:
- All standard CSS properties (
color,font-*,margin,padding,border,background,display, …). @media printand@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-faceonly withdata:-src— external font URLs are filtered likeurl(). v1 recommends system fonts; to use custom fonts, embed them asdata:.- 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 isselectorlist { 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 bundledacme.csscontains exactly such comments — this case is produced, not constructed. - Prefix each selector with
.markdown-document-preview.markdown-document-preview(except for@media/@page/@font-faceblocks — these retain their@and are further prefixed internally, or remain untouched for@font-face/@page/@keyframesbecause 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.vue — no 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 touchMarkdownView; it merely wraps it. - Kinds work: the VNodes from
MarkdownVieware normal Vue components (InlineKindBox/EmbeddedKindBox/LinkCard), on the component tree, withprovide/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">{{ 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-preview — font-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).
MarkdownViewremains 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-themeslisting 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 codePreview → MarkdownDocumentPreview (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.findByPathenforcesREADon the target Project/document; a reference to an external Project for which the caller has noREADfails (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 URInulland skipped. A Theme therefore cannot enforce@import 'https://evil'orbackground: 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-cssendpoint delivers server-filtered + server-scoped CSS; newMarkdownDocumentPreview.vuecomponent (Cortex tab only), usesuseMarkdownTokensComposable (extracted fromMarkdownView.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.