clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

fix: make packed SVG palettes data-driven

Dami committed 7eea6d2
Embed every shared palette behind root appearance selectors and synchronize packed SVG output with the editable document. Document the CSS-controlled palette contract and the separate export-time geometry boundary for agents and users.
commit
7eea6d2be8a8c770788227bb550290899188a9db
parent
b3c92c7027619e10e42274ac96128f19095f2d82
author
Dami <dami.etoile@gmail.com>
15 files changed, +135 -37
Mapps/docs/docs/collaboration/sharing.md8++++++++
Mapps/docs/docs/style/presentation.md8++++++++
Mapps/docs/docs/style/svg-normalization.md10++++++++--
Mapps/docs/scripts/check-docs.mjs6++++++
Mapps/docs/static/llms-full.txt26+++++++++++++++++++++++--
Mapps/docs/static/skills/diagramming/SKILL.md13++++++++++---
Mapps/editor/PERSISTENCE.md12++++++++----
Mapps/editor/test/editable-svg.test.js15++++++++++++++-
Mapps/editor/test/svg-contract.test.js12+++++++-----
Mdocs/svg-normalization.md10++++++++--
Mservices/api/test/aliases.test.ts2+-
Mservices/shell/test/index.test.ts4++++
Mshared/svg/index.d.ts2+-
Mshared/svg/index.js31+++++++++++++++++-------------
Mskills/diagramming/SKILL.md13++++++++++---
M apps/docs/docs/collaboration/sharing.md +8 -0
M apps/docs/docs/style/presentation.md +8 -0
M apps/docs/docs/style/svg-normalization.md +8 -2
@@ -115,7 +115,7 @@ The [renderer catalog](https://diagram.zip/render/v1/catalog) reports every engi
115115 | Normalizer | `svg-normalizer-2` | Sanitization, annotation, or serialization behavior changes. |116116 | Profile | `neutral-svg-semantic-2` | Renderer recognition or role assignment changes. |117117 | Palette | `diagramzip-palette-2` | Materialized role colors change. |118-| Materializer | `svg-materializer-3` | Appearance rules, padding, frame, or output assembly changes. |118+| Materializer | `svg-materializer-4` | Appearance rules, padding, frame, or output assembly changes. |119119 | Renderer | Catalog version and build | Engine output or its integration changes. |120120 121121 These versions advance independently. A renderer upgrade does not require a schema change, and a palette update does not require new geometry.
@@ -133,7 +133,7 @@ Canonical and materialized SVG expose their active contract through `data-dz-*`
133133 | `data-dz-engine` | Renderer engine identifier. |134134 | `data-dz-profile` | Selected normalization profile. |135135 | `data-dz-palette` | `renderer` for raw SVG, or the selected Diagram.zip palette build. |136-| `data-dz-appearance` | Materialized appearance. |136+| `data-dz-appearance` | Active palette selector and materialized geometry mode. |137137 | `data-dz-conformance` | Active capability level. |138138 | `data-dz-appearances` | Supported non-raw appearances, separated by spaces. |139139 | `data-dz-bounds` | Canonical diagram bounds as `x y width height`. |
@@ -159,6 +159,12 @@ The identifiers above are examples. Inspect the artifact or catalog instead of h
159159 160160 Consumers should treat `data-dz-appearances` as an allowlist. Adding an unsupported value by hand does not create a supported artifact.161161 162+Materialized SVG embeds raw, light, dark, and automatic palette rules keyed by163+the root `data-dz-appearance`. Changing that one value can select palette CSS164+without rewriting element paint. Transparent versus framed output may have165+different bounds; materialization still prepares the outer `viewBox`, padding,166+canvas, and frame geometry.167+162168 ## Consumer guarantees163169 164170 A normalized SVG has these guarantees:
M apps/docs/scripts/check-docs.mjs +6 -0
M apps/docs/static/llms-full.txt +24 -2
@@ -147,6 +147,14 @@ The shared appearances apply one Diagram.zip palette to supported renderer outpu
147147 - Transparent appearances omit the outer canvas.148148 - Framed appearances add a matching canvas, standard padding, and a border.149149 150+When the editor creates an anonymous packed SVG URL, it embeds self-contained151+palette rules selected by the root `data-dz-appearance`. The `/svg/{packed}`152+route synchronizes that single value from the embedded153+`diagram.presentation.appearance`. Changing the data value selects raw, light,154+dark, or automatic palette CSS without rewriting SVG paint. Transparent or155+framed bounds are prepared before packing because CSS cannot change the outer156+`viewBox`.157+150158 The editor disables appearances that the current renderer profile cannot support. Diagrams.net supports raw and framed appearances while its authored paint remains unchanged.151159 152160 Excalidraw and TikZ support every appearance. Their neutral canvas, ink, and line paint adapt while authored non-neutral paint remains renderer-defined.
@@ -311,7 +319,7 @@ The [renderer catalog](https://diagram.zip/render/v1/catalog) reports every engi
311319 | Normalizer | `svg-normalizer-2` | Sanitization, annotation, or serialization behavior changes. |312320 | Profile | `neutral-svg-semantic-2` | Renderer recognition or role assignment changes. |313321 | Palette | `diagramzip-palette-2` | Materialized role colors change. |314-| Materializer | `svg-materializer-3` | Appearance rules, padding, frame, or output assembly changes. |322+| Materializer | `svg-materializer-4` | Appearance rules, padding, frame, or output assembly changes. |315323 | Renderer | Catalog version and build | Engine output or its integration changes. |316324 317325 These versions advance independently. A renderer upgrade does not require a schema change, and a palette update does not require new geometry.
@@ -329,7 +337,7 @@ Canonical and materialized SVG expose their active contract through `data-dz-*`
329337 | `data-dz-engine` | Renderer engine identifier. |330338 | `data-dz-profile` | Selected normalization profile. |331339 | `data-dz-palette` | `renderer` for raw SVG, or the selected Diagram.zip palette build. |332-| `data-dz-appearance` | Materialized appearance. |340+| `data-dz-appearance` | Active palette selector and materialized geometry mode. |333341 | `data-dz-conformance` | Active capability level. |334342 | `data-dz-appearances` | Supported non-raw appearances, separated by spaces. |335343 | `data-dz-bounds` | Canonical diagram bounds as `x y width height`. |
@@ -355,6 +363,12 @@ The identifiers above are examples. Inspect the artifact or catalog instead of h
355363 356364 Consumers should treat `data-dz-appearances` as an allowlist. Adding an unsupported value by hand does not create a supported artifact.357365 366+Materialized SVG embeds raw, light, dark, and automatic palette rules keyed by367+the root `data-dz-appearance`. Changing that one value can select palette CSS368+without rewriting element paint. Transparent versus framed output may have369+different bounds; materialization still prepares the outer `viewBox`, padding,370+canvas, and frame geometry.371+358372 ## Consumer guarantees359373 360374 A normalized SVG has these guarantees:
@@ -487,6 +501,14 @@ requiring publication. An open published alias uses its stable server render.
487501 An anonymous draft uses a compressed, self-contained editable SVG URL and does488502 not create server state.489503 504+Before packing an anonymous URL, the editor prepares the selected transparent505+or framed geometry and embeds self-contained palette CSS. The root506+`data-dz-appearance` value selects raw, light, dark, or automatic palette rules.507+The `/svg/{packed}` route validates the SVG and synchronizes that one value from508+the embedded `diagram.presentation.appearance`. Consumers do not need to509+simulate the palette or patch SVG paint client-side. Frame padding and the outer510+`viewBox` are prepared before packing because CSS cannot change SVG bounds.511+490512 Packed URLs contain the rendered SVG, diagram source, options, metadata, and491513 presentation settings. Anyone with the URL can inspect that content and reopen492514 it as an editable SVG. Do not use a packed URL for secrets. Diagram.zip rejects
M apps/docs/static/skills/diagramming/SKILL.md +10 -3
M apps/editor/PERSISTENCE.md +8 -4
M apps/editor/test/editable-svg.test.js +14 -1
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
22 import { readFile } from 'node:fs/promises'33 import test from 'node:test'44 import { fileURLToPath } from 'node:url'5-import { canonicalizeSvg } from '../../../shared/svg/index.js'5+import { attachEditableDocument, canonicalizeSvg, materializeSvg } from '../../../shared/svg/index.js'66 import {77 EditableSvgError,88 editableDocumentFor,
@@ -60,6 +60,18 @@ const cases = [
6060 },6161 ]6262 63+test('uses the embedded presentation as the packed SVG palette selector', async () => {64+ const state = cases[1].state65+ const rendered = await fixture('mermaid')66+ const canonical = canonicalizeSvg(rendered, state.meta, state.type, cases[1].version)67+ const light = materializeSvg(canonical, 'light-transparent')68+ const packed = attachEditableDocument(light, editableDocumentFor(state))69+70+ assert.match(packed, /data-dz-appearance=\"dark-transparent\"/)71+ assert.ok(packed.includes('data-dz-appearance^=\"light-\"'))72+ assert.ok(packed.includes('data-dz-appearance^=\"dark-\"'))73+})74+6375 test('exports and reimports a deterministic golden set of renderer SVGs', async () => {6476 for (const entry of cases) {6577 const state = entry.state
@@ -73,6 +85,7 @@ test('exports and reimports a deterministic golden set of renderer SVGs', async
7385 assert.equal(first, repeated, `${entry.name} export must be deterministic`)7486 assert.equal(first, reexported, `${entry.name} import/export must be byte-stable`)7587 assert.deepEqual(imported, state, `${entry.name} state must round trip exactly`)88+ assert.ok(first.includes("data-dz-appearance=\"" + state.presentation.appearance + "\""))7689 assert.match(first, /data-dz-document="1"/)7790 assert.equal((first.match(/<metadata data-dz-kind="document"/g) ?? []).length, 1)7891 }
M apps/editor/test/svg-contract.test.js +7 -5
@@ -91,13 +91,15 @@ test('materializes explicit and automatic palettes without changing diagram geom
9191 const automatic = materializeSvg(semanticCanonical, 'auto-transparent')9292 9393 for (const output of [light, dark, automatic]) {94- assert.match(output, /data-dz-materializer="svg-materializer-3"/)94+ assert.match(output, /data-dz-materializer="svg-materializer-4"/)9595 assert.match(output, /data-dz-palette="diagramzip-palette-2"/)9696 assert.match(output, /<rect x="1" y="1" width="8" height="8"/)9797 assert.doesNotMatch(output, /<(?:rect|path|polygon)[^>]*data-dz-role="canvas"/)9898 }99- assert.match(light, /--dz-ink:#0f172a/)100- assert.match(dark, /--dz-ink:#f8fafc/)99+ for (const output of [light, dark, automatic]) {100+ assert.ok(output.includes("data-dz-appearance^=\"light-\"") && output.includes("--dz-ink:#0f172a"))101+ assert.ok(output.includes("data-dz-appearance^=\"dark-\"") && output.includes("--dz-ink:#f8fafc"))102+ }101103 assert.match(automatic, /@media\(prefers-color-scheme:dark\)/)102104 })103105
@@ -189,11 +191,11 @@ test('normalizes TRN table structure and relationship zones', () => {
189191 assert.match(trn, /class="trn-zone-shape trn-branch-4"[^>]*data-dz-fill="accent-4"[^>]*data-dz-stroke="line-muted"/)190192 assert.match(trn, /class="trn-operation-label"[^>]*data-dz-fill="on-accent"/)191193 const light = materializeSvg(trn, 'light-transparent')192- assert.match(light, /:root\[data-dz-profile="trn-semantic-4"\]\{--dz-accent-1:#dbeafe;--dz-accent-2:#fef3c7;--dz-accent-3:#ede9fe;--dz-accent-4:#ccfbf1;--dz-on-accent:#0f172a;/)194+ assert.match(light, /:root\[data-dz-profile="trn-semantic-4"\]\[data-dz-appearance\^="light-"\]/)193195 const dark = materializeSvg(trn, 'dark-transparent')194196 assert.match(dark, /data-dz-appearance="dark-transparent"/)195197 assert.match(dark, /--dz-accent-4:#34d399/)196- assert.doesNotMatch(dark, /--dz-accent-1:#dbeafe/)198+ assert.match(dark, /:root\[data-dz-appearance\^="dark-"\]\{[^}]*--dz-accent-4:#34d399/)197199 })198200 199201 test('themes every Svgbob stroke without treating open curves as object surfaces', () => {
M docs/svg-normalization.md +8 -2
M services/api/test/aliases.test.ts +1 -1
M services/shell/test/index.test.ts +4 -0
M shared/svg/index.d.ts +1 -1
M shared/svg/index.js +18 -13
@@ -12,7 +12,7 @@ const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\
1212 export const SVG_SCHEMA = '1'1313 export const EDITABLE_SVG_SCHEMA = '1'1414 export const NORMALIZER_BUILD = 'svg-normalizer-2'15-export const MATERIALIZER_BUILD = 'svg-materializer-3'15+export const MATERIALIZER_BUILD = 'svg-materializer-4'1616 export const PALETTE_BUILD = 'diagramzip-palette-2'1717 export const RAW_PROFILE = 'safe-raw-1'1818 export const APPEARANCES = Object.freeze([
@@ -910,6 +910,10 @@ export function attachEditableDocument(source, document) {
910910 if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {911911 throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')912912 }913+ const appearance = document?.diagram?.presentation?.appearance914+ if (typeof appearance === 'string' && APPEARANCES.includes(appearance)) {915+ root.attributes.set('data-dz-appearance', appearance)916+ }913917 root.children = root.children.filter(child => child.type === 'text'914918 || !(localName(child) === 'metadata' && child.attributes.get('data-dz-kind') === 'document'))915919 root.attributes.set('data-dz-document', EDITABLE_SVG_SCHEMA)
@@ -1011,17 +1015,18 @@ function branchPaletteVariables(palette) {
10111015 return `--dz-accent-1:${palette.accent1};--dz-accent-2:${palette.accent2};--dz-accent-3:${palette.accent3};--dz-accent-4:${palette.accent4};--dz-on-accent:${palette.onAccent};`10121016 }10131017 1014-function materializerCss(appearance) {1015- const scheme = appearance.startsWith('dark-') ? DARK_PALETTE : LIGHT_PALETTE1016- const automatic = appearance.startsWith('auto-')1017- const variables = automatic1018- ? `:root{${paletteVariables(LIGHT_PALETTE)}}@media(prefers-color-scheme:dark){:root{${paletteVariables(DARK_PALETTE)}}}`1019- : `:root{${paletteVariables(scheme)}}`1020- const trnSelector = ':root[data-dz-profile="trn-semantic-4"]'1021- const trnVariables = automatic1022- ? `${trnSelector}{${branchPaletteVariables(TRN_LIGHT_BRANCH_PALETTE)}}@media(prefers-color-scheme:dark){${trnSelector}{${branchPaletteVariables(DARK_PALETTE)}}}`1023- : appearance.startsWith('light-') ? `${trnSelector}{${branchPaletteVariables(TRN_LIGHT_BRANCH_PALETTE)}}` : ''1024- return `${variables}${trnVariables}[data-dz-role="canvas"]:not([data-dz-owned="materializer"]){display:none!important}[data-dz-fill="none"]{fill:none!important}[data-dz-fill="surface-1"]{fill:var(--dz-surface-1)!important}[data-dz-fill="surface-2"]{fill:var(--dz-surface-2)!important}[data-dz-fill="surface-3"]{fill:var(--dz-surface-3)!important}[data-dz-fill="ink"]{fill:var(--dz-ink)!important;color:var(--dz-ink)!important}[data-dz-fill="ink-muted"]{fill:var(--dz-ink-muted)!important;color:var(--dz-ink-muted)!important}[data-dz-fill="line"]{fill:var(--dz-line)!important}[data-dz-fill="accent-1"]{fill:var(--dz-accent-1)!important}[data-dz-fill="accent-2"]{fill:var(--dz-accent-2)!important}[data-dz-fill="accent-3"]{fill:var(--dz-accent-3)!important}[data-dz-fill="accent-4"]{fill:var(--dz-accent-4)!important}[data-dz-fill="on-accent"]{fill:var(--dz-on-accent)!important;color:var(--dz-on-accent)!important}[data-dz-stroke="line"]{stroke:var(--dz-line)!important}[data-dz-stroke="line-muted"]{stroke:var(--dz-line-muted)!important}[data-dz-stroke="accent-1"]{stroke:var(--dz-accent-1)!important}[data-dz-stroke="accent-2"]{stroke:var(--dz-accent-2)!important}[data-dz-stroke="accent-3"]{stroke:var(--dz-accent-3)!important}[data-dz-stroke="accent-4"]{stroke:var(--dz-accent-4)!important}`1018+function materializerCss() {1019+ const light = ':root[data-dz-appearance^="light-"]'1020+ const dark = ':root[data-dz-appearance^="dark-"]'1021+ const automatic = ':root[data-dz-appearance^="auto-"]'1022+ const themed = ':root:not([data-dz-appearance="raw"])'1023+ const trnLight = ':root[data-dz-profile="trn-semantic-4"][data-dz-appearance^="light-"]'1024+ const trnAutomatic = ':root[data-dz-profile="trn-semantic-4"][data-dz-appearance^="auto-"]'1025+ const variables = `${light}{${paletteVariables(LIGHT_PALETTE)}}${dark}{${paletteVariables(DARK_PALETTE)}}${automatic}{${paletteVariables(LIGHT_PALETTE)}}@media(prefers-color-scheme:dark){${automatic}{${paletteVariables(DARK_PALETTE)}}}`1026+ const trnVariables = `${trnLight}{${branchPaletteVariables(TRN_LIGHT_BRANCH_PALETTE)}}${trnAutomatic}{${branchPaletteVariables(TRN_LIGHT_BRANCH_PALETTE)}}@media(prefers-color-scheme:dark){${trnAutomatic}{${branchPaletteVariables(DARK_PALETTE)}}}`1027+ const materializerVisibility = `:root[data-dz-appearance="raw"] [data-dz-owned="materializer"],:root[data-dz-appearance$="-transparent"] [data-dz-owned="materializer"]{display:none!important}`1028+ const roles = `${themed} [data-dz-role="canvas"]:not([data-dz-owned="materializer"]){display:none!important}${themed} [data-dz-fill="none"]{fill:none!important}${themed} [data-dz-fill="surface-1"]{fill:var(--dz-surface-1)!important}${themed} [data-dz-fill="surface-2"]{fill:var(--dz-surface-2)!important}${themed} [data-dz-fill="surface-3"]{fill:var(--dz-surface-3)!important}${themed} [data-dz-fill="ink"]{fill:var(--dz-ink)!important;color:var(--dz-ink)!important}${themed} [data-dz-fill="ink-muted"]{fill:var(--dz-ink-muted)!important;color:var(--dz-ink-muted)!important}${themed} [data-dz-fill="line"]{fill:var(--dz-line)!important}${themed} [data-dz-fill="accent-1"]{fill:var(--dz-accent-1)!important}${themed} [data-dz-fill="accent-2"]{fill:var(--dz-accent-2)!important}${themed} [data-dz-fill="accent-3"]{fill:var(--dz-accent-3)!important}${themed} [data-dz-fill="accent-4"]{fill:var(--dz-accent-4)!important}${themed} [data-dz-fill="on-accent"]{fill:var(--dz-on-accent)!important;color:var(--dz-on-accent)!important}${themed} [data-dz-stroke="line"]{stroke:var(--dz-line)!important}${themed} [data-dz-stroke="line-muted"]{stroke:var(--dz-line-muted)!important}${themed} [data-dz-stroke="accent-1"]{stroke:var(--dz-accent-1)!important}${themed} [data-dz-stroke="accent-2"]{stroke:var(--dz-accent-2)!important}${themed} [data-dz-stroke="accent-3"]{stroke:var(--dz-accent-3)!important}${themed} [data-dz-stroke="accent-4"]{stroke:var(--dz-accent-4)!important}`1029+ return `${variables}${trnVariables}${materializerVisibility}${roles}`10251030 }10261031 10271032 function canonicalBounds(root) {
@@ -1058,7 +1063,7 @@ export function materializeSvg(canonical, appearance) {
10581063 root.attributes.set('data-dz-appearance', appearance)10591064 root.attributes.set('data-dz-palette', PALETTE_BUILD)10601065 root.attributes.set('data-dz-materializer', MATERIALIZER_BUILD)1061- root.children.unshift(element('style', [['data-dz-owned', 'materializer']], [{ type: 'text', value: materializerCss(appearance) }]))1066+ root.children.unshift(element('style', [['data-dz-owned', 'materializer']], [{ type: 'text', value: materializerCss() }]))10621067 if (appearance.endsWith('-framed')) {10631068 const [x, y, width, height] = bounds10641069 const padding = 24
M skills/diagramming/SKILL.md +10 -3