- add schema-validated Details editing with diagnostics and guarded actions\n- preserve preview view state across appearance changes and refine preview controls\n- improve local draft recovery, reset behavior, toasts, theming, and accessibility
- commit
- b6d14f1e53c054fdc1f3d6ae5cd29cd92dc197cf
- parent
- e4d514384f74d4d04eb89111910b79e9fb453212
- author
- Dami <dami.etoile@gmail.com>
@@ -5,7 +5,7 @@
55 <meta name="viewport" content="width=device-width, initial-scale=1.0">66 <meta name="theme-color" content="#ffffff">77 <meta name="description" content="Create diagrams from text and share them as a link.">8- <link rel="icon" href="/icon.svg?v=2" type="image/svg+xml">8+ <link rel="icon" href="/icon.svg?v=3" type="image/svg+xml">99 <title>untitled.zip</title>1010 </head>1111 <body>@@ -1,4 +1,12 @@
11 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">2- <path d="M16,15,12,7,8,15Zm-1,0L11.5,8" fill="none" stroke="#2ca9bc" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>3- <circle cx="12" cy="12" r="9" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>2+ <style>3+ .accent { stroke: #80ff80; }4+ .ring { stroke: #000; }5+ @media (prefers-color-scheme: dark) {6+ .accent { stroke: #213f21; }7+ .ring { stroke: #fff; }8+ }9+ </style>10+ <path class="accent" d="M16,15,12,7,8,15Zm-1,0L11.5,8" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>11+ <circle class="ring" cx="12" cy="12" r="9" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>412 </svg>@@ -0,0 +1,114 @@
1+import { APPEARANCES } from './client-svg.js'2+import { normalizeMetadata, normalizePresentation } from './state.js'3+4+export const DETAILS_MODEL_URI = 'inmemory://diagram.zip/details.json'5+export const DETAILS_SCHEMA_URI = 'https://diagram.zip/schemas/details.v1.json'6+7+export const DETAILS_DOCUMENT_SCHEMA = {8+ $schema: 'https://json-schema.org/draft/2020-12/schema',9+ $id: DETAILS_SCHEMA_URI,10+ title: 'Diagram details',11+ description: 'Metadata and presentation settings for the current diagram.',12+ type: 'object',13+ additionalProperties: false,14+ required: ['title', 'description', 'presentation'],15+ properties: {16+ title: {17+ type: 'string',18+ maxLength: 200,19+ description: 'Document title. The browser tab and shared embeds use this value.',20+ },21+ description: {22+ type: 'string',23+ maxLength: 2000,24+ description: 'A plain-text description of what the diagram shows.',25+ },26+ presentation: {27+ type: 'object',28+ additionalProperties: false,29+ required: ['appearance', 'background', 'padding', 'frame'],30+ properties: {31+ appearance: {32+ type: 'string',33+ enum: APPEARANCES,34+ description: 'raw preserves renderer paint; themed appearances use the shared Diagram.zip palette.',35+ },36+ background: {37+ type: 'string',38+ pattern: '^(?:|#[0-9a-fA-F]{6})$',39+ description: 'Renderer-default canvas color as an empty string or a six-digit hex color. Used by raw appearance.',40+ },41+ padding: {42+ type: 'integer',43+ minimum: 0,44+ maximum: 256,45+ description: 'Renderer-default canvas padding in pixels. Used by raw appearance.',46+ },47+ frame: {48+ type: 'boolean',49+ description: 'Add a bounded renderer-default canvas. Used by raw appearance.',50+ },51+ },52+ },53+ },54+}55+56+export function detailsDocumentFor(state = {}) {57+ const meta = normalizeMetadata(state.meta)58+ const presentation = normalizePresentation(state.presentation)59+ return {60+ title: meta.title,61+ description: meta.description,62+ presentation,63+ }64+}65+66+export function serializeDetailsDocument(state) {67+ return `${JSON.stringify(detailsDocumentFor(state), null, 2)}\n`68+}69+70+export function parseDetailsDocument(source) {71+ let value72+ try {73+ value = JSON.parse(source)74+ } catch (error) {75+ throw new Error(`Details must be valid JSON. ${error instanceof Error ? error.message : ''}`.trim())76+ }77+ assertObject(value, 'Details must be a JSON object.')78+ assertKeys(value, ['title', 'description', 'presentation'], 'details')79+ assertRequired(value, ['title', 'description', 'presentation'], 'details')80+ if (typeof value.title !== 'string' || value.title.length > 200) {81+ throw new Error('title must be a string with at most 200 characters.')82+ }83+ if (typeof value.description !== 'string' || value.description.length > 2000) {84+ throw new Error('description must be a string with at most 2000 characters.')85+ }86+87+ assertObject(value.presentation, 'presentation must be a JSON object.')88+ assertKeys(value.presentation, ['appearance', 'background', 'padding', 'frame'], 'presentation')89+ assertRequired(value.presentation, ['appearance', 'background', 'padding', 'frame'], 'presentation')90+ let presentation91+ try {92+ presentation = normalizePresentation(value.presentation)93+ } catch {94+ throw new Error('presentation has an invalid appearance, background, padding, or frame value.')95+ }96+ return {97+ meta: normalizeMetadata({ title: value.title, description: value.description }),98+ presentation,99+ }100+}101+102+function assertObject(value, message) {103+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(message)104+}105+106+function assertKeys(value, allowed, label) {107+ const unknown = Object.keys(value).find(key => !allowed.includes(key))108+ if (unknown) throw new Error(`${label} contains unknown property "${unknown}".`)109+}110+111+function assertRequired(value, required, label) {112+ const missing = required.find(key => !Object.hasOwn(value, key))113+ if (missing) throw new Error(`${label} is missing required property "${missing}".`)114+}@@ -10,7 +10,16 @@ import { configureMonacoWorkers } from './monaco-workers.js'
1010 1111 const THEME_NAME = 'diagramzip'1212 13-export function createEditor({ element, source, diagramType, onChange, onSave }) {13+export function createEditor({14+ element,15+ source,16+ diagramType,17+ language,18+ modelUri,19+ ariaLabel = 'Diagram source editor',20+ onChange,21+ onSave,22+}) {1423 configureMonacoWorkers()1524 registerMonacoLanguages(monaco)1625 defineTheme()@@ -18,13 +27,13 @@ export function createEditor({ element, source, diagramType, onChange, onSave })
1827 let suppressChanges = 01928 const model = monaco.editor.createModel(2029 source,21- editorLanguageFor(diagramType),22- monaco.Uri.parse('inmemory://diagram.zip/source'),30+ language ?? editorLanguageFor(diagramType),31+ monaco.Uri.parse(modelUri ?? 'inmemory://diagram.zip/source'),2332 )2433 const rootStyle = getComputedStyle(document.documentElement)2534 const editor = monaco.editor.create(element, {2635 model,27- ariaLabel: 'Diagram source editor',36+ ariaLabel,2837 automaticLayout: true,2938 contextmenu: true,3039 fixedOverflowWidgets: true,@@ -52,12 +61,12 @@ export function createEditor({ element, source, diagramType, onChange, onSave })
5261 return {5362 backend: 'monaco',5463 getValue: () => model.getValue(),55- setDocument({ source: nextSource, diagramType: nextType }) {64+ setDocument({ source: nextSource, diagramType: nextType = diagramType, language: nextLanguage = language }) {5665 suppressChanges += 15766 try {5867 const previousPosition = editor.getPosition()5968 model.setValue(nextSource)60- monaco.editor.setModelLanguage(model, editorLanguageFor(nextType))69+ monaco.editor.setModelLanguage(model, nextLanguage ?? editorLanguageFor(nextType))6170 if (previousPosition) {6271 const lineNumber = Math.min(previousPosition.lineNumber, model.getLineCount())6372 const column = Math.min(previousPosition.column, model.getLineMaxColumn(lineNumber))@@ -70,6 +79,10 @@ export function createEditor({ element, source, diagramType, onChange, onSave })
7079 },7180 focus: () => editor.focus(),7281 layout: () => editor.layout(),82+ setTheme() {83+ defineTheme()84+ monaco.editor.setTheme(THEME_NAME)85+ },7386 dispose() {7487 contentSubscription.dispose()7588 editor.dispose()@@ -81,7 +94,7 @@ export function createEditor({ element, source, diagramType, onChange, onSave })
8194 function defineTheme() {8295 const style = getComputedStyle(document.documentElement)8396 monaco.editor.defineTheme(THEME_NAME, {84- base: 'vs',97+ base: document.documentElement.dataset.theme === 'dark' ? 'vs-dark' : 'vs',8598 inherit: true,8699 rules: [87100 { token: 'keyword', foreground: tokenColor(style, '--syntax-keyword'), fontStyle: 'bold' },@@ -98,7 +111,7 @@ function defineTheme() {
98111 colors: {99112 'editor.background': themeColor(style, '--surface'),100113 'editor.foreground': themeColor(style, '--ink'),101- 'editorCursor.foreground': themeColor(style, '--ink'),114+ 'editorCursor.foreground': themeColor(style, '--accent'),102115 'editorGutter.background': themeColor(style, '--surface'),103116 'editorLineNumber.foreground': themeColor(style, '--muted'),104117 'editor.lineHighlightBackground': themeColor(style, '--editor-active-line'),@@ -3,6 +3,11 @@ import 'monaco-editor/languages/definitions/sql/register.js'
33 import 'monaco-editor/languages/definitions/xml/register.js'44 import 'monaco-editor/languages/definitions/yaml/register.js'55 import { jsonDefaults } from 'monaco-editor/language/json/monaco.contribution.js'6+import {7+ DETAILS_DOCUMENT_SCHEMA,8+ DETAILS_MODEL_URI,9+ DETAILS_SCHEMA_URI,10+} from '../details-document.js'611 import { GENERIC_LANGUAGE_ID, JSON5_LANGUAGE_ID } from '../editor-languages.js'712 813 const keywords = [@@ -21,10 +26,14 @@ export function registerMonacoLanguages(monaco) {
2126 jsonDefaults.setDiagnosticsOptions({2227 validate: true,2328 allowComments: false,24- schemas: [],29+ schemas: [{30+ uri: DETAILS_SCHEMA_URI,31+ fileMatch: [DETAILS_MODEL_URI],32+ schema: DETAILS_DOCUMENT_SCHEMA,33+ }],2534 enableSchemaRequest: false,2635 schemaRequest: 'ignore',27- schemaValidation: 'warning',36+ schemaValidation: 'error',2837 comments: 'error',2938 trailingCommas: 'error',3039 })@@ -1,10 +1,18 @@
1-export function createEditor({ element, source, diagramType, onChange, onSave }) {1+export function createEditor({2+ element,3+ source,4+ diagramType,5+ language,6+ ariaLabel = 'Diagram source editor',7+ onChange,8+ onSave,9+}) {210 let suppressChanges = 0311 const textarea = document.createElement('textarea')412 textarea.className = 'source-editor-textarea'513 textarea.value = source6- textarea.dataset.language = diagramType7- textarea.setAttribute('aria-label', 'Diagram source editor')14+ textarea.dataset.language = language ?? diagramType15+ textarea.setAttribute('aria-label', ariaLabel)816 textarea.setAttribute('autocapitalize', 'off')917 textarea.setAttribute('autocomplete', 'off')1018 textarea.setAttribute('autocorrect', 'off')@@ -27,13 +35,13 @@ export function createEditor({ element, source, diagramType, onChange, onSave })
2735 return {2836 backend: 'textarea',2937 getValue: () => textarea.value,30- setDocument({ source: nextSource, diagramType: nextType }) {38+ setDocument({ source: nextSource, diagramType: nextType = diagramType, language: nextLanguage = language }) {3139 suppressChanges += 13240 try {3341 const start = Math.min(textarea.selectionStart, nextSource.length)3442 const end = Math.min(textarea.selectionEnd, nextSource.length)3543 textarea.value = nextSource36- textarea.dataset.language = nextType44+ textarea.dataset.language = nextLanguage ?? nextType3745 textarea.setSelectionRange(start, end)3846 } finally {3947 suppressChanges -= 1@@ -2,6 +2,10 @@ export function grayCode(index) {
22 return index ^ (index >> 1)33 }44 5+export function defaultExampleAppearance(type) {6+ return type === 'diagramsnet' ? 'auto-framed' : 'auto-transparent'7+}8+59 export function exampleVariant(index, context) {610 const gray = grayCode(index)711 return {@@ -20,7 +20,7 @@ import vegalite from '../../../examples/diagrams/discretizing-scale.vlite?raw'
2020 import wavedrom from '../../../examples/diagrams/wavedrom.json5?raw'2121 import wireviz from '../../../examples/diagrams/wireviz.yaml?raw'2222 import { diagramTypes } from './diagram-types.js'23-import { exampleVariant } from './example-variants.js'23+import { defaultExampleAppearance, exampleVariant } from './example-variants.js'2424 import { DEFAULT_SOURCE } from './state.js'2525 2626 const EXAMPLES = {@@ -226,10 +226,15 @@ export function exampleFor(type) {
226226 export function exampleStateFor(type) {227227 const index = diagramTypes.findIndex(diagramType => diagramType.id === type)228228 const [title, description] = EXAMPLE_CONTEXT[type] ?? ['Diagram example', 'A diagram.zip catalog example.']229+ const variant = exampleVariant(Math.max(index, 0), { title, description })229230 return {230231 type,231232 source: exampleFor(type),232233 options: {},233- ...exampleVariant(Math.max(index, 0), { title, description }),234+ ...variant,235+ presentation: {236+ ...variant.presentation,237+ appearance: defaultExampleAppearance(type),238+ },234239 }235240 }@@ -1,8 +1,14 @@
11 import { diagramTypeFromQuery, diagramTypes, isKnownDiagramType, urlWithDiagramType } from './diagram-types.js'22 import { exampleStateFor } from './examples.js'33 import { refreshMatchingExampleMetadata } from './example-variants.js'4+import {5+ DETAILS_MODEL_URI,6+ parseDetailsDocument,7+ serializeDetailsDocument,8+} from './details-document.js'49 import { createSourceEditor } from './source-editor.js'510 import { stateForTypeChange } from './type-drafts.js'11+import { workingStateIsDirty, workingStateSnapshot } from './working-state.js'612 import {713 DEFAULT_DIAGRAM_TYPE,814 documentTitle,@@ -32,12 +38,15 @@ import './style.css'
3238 const LOCAL_DRAFT_KEY = 'diagram.zip:draft:local:v2'3339 const ALIAS_DRAFT_PREFIX = 'diagram.zip:draft:alias:v1:'3440 const WRITE_CAPABILITY_PREFIX = 'diagram.zip:write:v1:'35-// TODO: Reintroduce dark mode once every renderer has a native or renderer-specific dark theme.3641 const persistence = new PersistenceClient()42+const systemColorScheme = matchMedia('(prefers-color-scheme: dark)')43+document.documentElement.dataset.theme = systemColorScheme.matches ? 'dark' : 'light'3744 let renderTimer45+let detailsCommitTimer3846 let activeType3947 let saving = false4048 let initialLoadError = ''49+let restoredDetailsSource = null4150 let remote = {4251 aliasId: null,4352 contentId: null,@@ -61,8 +70,8 @@ document.querySelector('#app').innerHTML = `
6170 <main class="app-shell">6271 <header class="app-header">6372 <div class="document-identity">64- <a class="brand" href="/" aria-label="New diagram" title="New diagram"><img class="brand-mark" src="/icon.svg?v=2" alt=""></a>65- <input class="document-title" id="diagram-title" maxlength="200" placeholder="Untitled diagram" aria-label="Diagram title">73+ <a class="brand" href="/" aria-label="New diagram" title="New diagram"><img class="brand-mark" src="/icon.svg?v=3" alt=""></a>74+ <span class="document-title" id="diagram-title">Untitled</span>6675 </div>6776 <div class="header-meta">6877 <span class="render-status" data-state="idle" role="status">Ready</span>@@ -77,12 +86,6 @@ document.querySelector('#app').innerHTML = `
7786 <path d="M12 7.5v12"/>7887 </svg>7988 </a>80- <button class="header-icon-action" id="details" type="button" aria-label="Details" title="Details">81- <svg class="header-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">82- <path d="M6 3.5h8l4 4v13H6v-17Z"/>83- <path d="M14 3.5V8h4M9 12h6M9 15.5h6"/>84- </svg>85- </button>8689 <button class="header-icon-action" id="save" type="button" data-save-state="save" data-dirty="true" aria-label="Save" title="Save">8790 <svg class="header-action-icon save-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">8891 <g data-save-icon="save">@@ -117,29 +120,72 @@ document.querySelector('#app').innerHTML = `
117120 <aside class="draft-bar" id="draft-bar" aria-live="polite" hidden>118121 <p>This device has changes that are not part of the saved share link.</p>119122 <div>120- <button class="secondary-action" id="restore-saved" type="button">Restore saved</button>123+ <button class="secondary-action" id="restore-saved" type="button">124+ <span>Restore saved</span>125+ <svg class="draft-reset-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">126+ <path d="M4.5 9V4.5H9"/>127+ <path d="M5.2 7.2A8 8 0 1 1 4.6 16"/>128+ </svg>129+ </button>121130 <button class="primary-action" id="make-copy" type="button">Make a copy</button>122131 </div>123132 </aside>124133 125134 <div class="mobile-tabs" role="tablist" aria-label="Workspace panel">126- <button type="button" role="tab" aria-selected="true" data-panel="editor">Edit</button>127- <button type="button" role="tab" aria-selected="false" data-panel="preview">Preview</button>135+ <button type="button" role="tab" aria-selected="true" aria-controls="editor-panel" data-panel="editor" tabindex="0">Edit</button>136+ <button type="button" role="tab" aria-selected="false" aria-controls="preview-panel" data-panel="preview" tabindex="-1">Preview</button>128137 </div>129138 130139 <div class="workspace" data-mobile-panel="editor">131- <section class="editor-panel" aria-label="Diagram source">132- <div id="editor"></div>140+ <section class="editor-panel" id="editor-panel" aria-label="Diagram input">141+ <div class="editor-tabs" role="tablist" aria-label="Diagram input">142+ <button id="source-tab" type="button" role="tab" aria-selected="true" aria-controls="editor" tabindex="0">Source</button>143+ <button id="details-tab" type="button" role="tab" aria-selected="false" aria-controls="details-editor" tabindex="-1">Details</button>144+ </div>145+ <div class="editor-surfaces">146+ <div id="editor" role="tabpanel" aria-labelledby="source-tab"></div>147+ <div id="details-editor" role="tabpanel" aria-labelledby="details-tab" hidden></div>148+ </div>149+ <div class="syntax-dock" id="syntax-dock" aria-label="Diagnostics" data-severity="none">150+ <span class="syntax-dock-summary" id="syntax-dock-summary" aria-label="No diagnostics" title="No diagnostics">151+ <svg class="diagnostic-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">152+ <g data-diagnostic-icon="none"><circle cx="12" cy="12" r="8.5"/><path d="m8.5 12 2.2 2.2 4.8-5"/></g>153+ <g data-diagnostic-icon="error"><circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6m0-6-6 6"/></g>154+ <g data-diagnostic-icon="warning"><path d="M12 3.5 21 20H3L12 3.5Z"/><path d="M12 9v5m0 2.7v.1"/></g>155+ <g data-diagnostic-icon="info"><circle cx="12" cy="12" r="8.5"/><path d="M12 10.5V16m0-8.5v.1"/></g>156+ </svg>157+ <span id="syntax-message-count">0</span>158+ </span>159+ <span class="details-validation" id="details-validation" role="status">No problems</span>160+ </div>133161 </section>134- <section class="preview-panel" aria-label="Diagram preview">162+ <section class="preview-panel" id="preview-panel" aria-label="Diagram preview">135163 <div class="preview-stage" id="preview-stage">136164 <img id="preview-image" alt="Rendered diagram" draggable="false" hidden>137165 <p class="preview-empty">Your diagram will appear here.</p>138166 <div class="preview-toolbar" aria-label="Preview controls">139- <button type="button" data-preview-action="zoom-out" aria-label="Zoom out">−</button>140- <button type="button" data-preview-action="zoom-in" aria-label="Zoom in">+</button>141- <button type="button" data-preview-action="fit">Fit</button>142- <button type="button" data-preview-action="one-to-one">1:1</button>167+ <div class="preview-zoom-controls" role="group" aria-label="Zoom">168+ <button type="button" data-preview-action="zoom-out" aria-label="Zoom out">−</button>169+ <button type="button" data-preview-action="zoom-in" aria-label="Zoom in">+</button>170+ <button type="button" data-preview-action="fit">Fit</button>171+ <button type="button" data-preview-action="one-to-one">1:1</button>172+ </div>173+ <span class="preview-toolbar-divider" aria-hidden="true"></span>174+ <div class="preview-appearance-controls" aria-label="Preview appearance">175+ <button id="preview-raw" type="button" aria-pressed="false" title="Show the renderer's original appearance">Raw</button>176+ <button id="preview-theme-toggle" type="button" data-preview-theme="light" aria-pressed="false" aria-label="Preview theme: Light" title="Preview theme: Light · click for dark">177+ <svg class="preview-control-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">178+ <circle cx="12" cy="12" r="8"/>179+ <path class="preview-theme-fill" d="M12 4a8 8 0 0 1 0 16Z"/>180+ </svg>181+ </button>182+ <button id="preview-transparent" type="button" aria-pressed="true" aria-label="Transparent background" title="Transparent background">183+ <svg class="preview-control-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">184+ <rect x="4" y="4" width="16" height="16" rx="1.5"/>185+ <path class="preview-transparency-fill" d="M4 4h8v8H4zM12 12h8v8h-8z"/>186+ </svg>187+ </button>188+ </div>143189 </div>144190 <div class="minimap" id="minimap" aria-label="Preview minimap" hidden>145191 <img id="minimap-image" alt="" draggable="false">@@ -150,65 +196,6 @@ document.querySelector('#app').innerHTML = `
150196 </div>151197 </main>152198 153- <dialog id="details-dialog" aria-labelledby="details-title">154- <form method="dialog" class="dialog-card">155- <div class="dialog-header">156- <h1 id="details-title">Diagram details</h1>157- <button class="icon-button" value="cancel" aria-label="Close">×</button>158- </div>159- <div class="details-fields">160- <label>161- <span>Description</span>162- <textarea id="diagram-description" maxlength="2000" rows="4" placeholder="What does this diagram show?"></textarea>163- </label>164- <fieldset class="presentation-fields">165- <legend>Presentation</legend>166- <label class="appearance-field">167- <span>Appearance</span>168- <select id="diagram-appearance">169- <option value="raw">Renderer default</option>170- <option value="auto-transparent">Match device · transparent</option>171- <option value="light-transparent">Light · transparent</option>172- <option value="dark-transparent">Dark · transparent</option>173- <option value="auto-framed">Match device · framed</option>174- <option value="light-framed">Light · framed</option>175- <option value="dark-framed">Dark · framed</option>176- </select>177- <small id="appearance-note">Renderer default keeps the renderer's paint and enables the canvas controls below.</small>178- </label>179- <label>180- <span>Canvas</span>181- <select id="diagram-background">182- <option value="">Renderer default</option>183- <option value="#ffffff">White</option>184- <option value="#f4f4f4">Soft gray</option>185- </select>186- </label>187- <label>188- <span>Padding</span>189- <div class="number-field"><input id="diagram-padding" type="number" min="0" max="256" step="4"><span>px</span></div>190- </label>191- <label class="checkbox-field"><input id="diagram-frame" type="checkbox"><span>Frame</span></label>192- </fieldset>193- <fieldset class="privacy-fields">194- <legend>Privacy</legend>195- <div>196- <strong id="privacy-label">Open</strong>197- <p id="privacy-description">Anyone with the read link can open and embed this diagram.</p>198- </div>199- <div class="privacy-actions">200- <button class="secondary-action" id="lock-diagram" type="button">Lock with password</button>201- <button class="secondary-action" id="change-password" type="button" hidden>Change password</button>202- <button class="secondary-action" id="unlock-diagram" type="button" hidden>Remove password</button>203- </div>204- </fieldset>205- </div>206- <div class="dialog-actions">207- <button class="primary-action" value="done">Done</button>208- </div>209- </form>210- </dialog>211-212199 <dialog id="share-dialog" aria-labelledby="share-title">213200 <form method="dialog" class="dialog-card">214201 <div class="dialog-header">@@ -216,6 +203,18 @@ document.querySelector('#app').innerHTML = `
216203 <button class="icon-button" value="cancel" aria-label="Close">Ă—</button>217204 </div>218205 <p class="share-status" id="share-status"></p>206+ <fieldset class="privacy-fields share-privacy-fields">207+ <legend>Privacy</legend>208+ <div>209+ <strong id="privacy-label">Open</strong>210+ <p id="privacy-description">Anyone with the read link can open and embed this diagram.</p>211+ </div>212+ <div class="privacy-actions">213+ <button class="secondary-action" id="lock-diagram" type="button">Lock with password</button>214+ <button class="secondary-action" id="change-password" type="button" hidden>Change password</button>215+ <button class="secondary-action" id="unlock-diagram" type="button" hidden>Remove password</button>216+ </div>217+ </fieldset>219218 <div class="share-options">220219 <label><span>Read link</span><div><input id="viewer-link" readonly><button type="button" data-copy="viewer-link">Copy</button></div></label>221220 <label><span>Edit link</span><div><input id="editor-link" readonly><button type="button" data-copy="editor-link">Copy</button></div><small id="editor-link-note">Anyone with this link can update the diagram.</small></label>@@ -260,31 +259,33 @@ document.querySelector('#app').innerHTML = `
260259 </form>261260 </dialog>262261 263- <div class="toast" id="toast" role="status" aria-live="polite"></div>262+ <div class="toast" id="toast" role="status" aria-live="polite" popover="manual"></div>264263 `265264 266265 const typePicker = document.querySelector('#diagram-type')267-const titleInput = document.querySelector('#diagram-title')268-const descriptionInput = document.querySelector('#diagram-description')269-const appearanceInput = document.querySelector('#diagram-appearance')270-const backgroundInput = document.querySelector('#diagram-background')271-const paddingInput = document.querySelector('#diagram-padding')272-const frameInput = document.querySelector('#diagram-frame')266+const titleLabel = document.querySelector('#diagram-title')267+const previewRawButton = document.querySelector('#preview-raw')268+const previewTransparentButton = document.querySelector('#preview-transparent')269+const previewThemeButton = document.querySelector('#preview-theme-toggle')270+let supportedPreviewAppearances = new Set(['raw'])271+let previewTheme = systemColorScheme.matches ? 'dark' : 'light'272+let previewTransparent = true273+let detailsValid = true274+let detailsError = ''275+let detailsState276+let detailsEditor = null277+let previewHasBeenRevealed = false273278 for (const { id, label } of diagramTypes) {274279 typePicker.add(new Option(label, id))275280 }276281 277282 const initialState = await loadInitialState()278-const initialMetadata = normalizeMetadata(initialState.meta)279-const initialPresentation = normalizePresentation(initialState.presentation)283+detailsState = {284+ meta: normalizeMetadata(initialState.meta),285+ presentation: normalizePresentation(initialState.presentation),286+}280287 typePicker.value = initialState.type281-titleInput.value = initialMetadata.title282-descriptionInput.value = initialMetadata.description283-appearanceInput.value = initialPresentation.appearance284-backgroundInput.value = initialPresentation.background285-paddingInput.value = String(initialPresentation.padding)286-frameInput.checked = initialPresentation.frame287-syncPresentationControls()288+syncPreviewAppearanceControls()288289 updateDocumentMetadata()289290 activeType = initialState.type290291 typeDrafts.set(initialState.type, initialState)@@ -297,6 +298,19 @@ const sourceEditor = await createSourceEditor({
297298 onSave: saveDiagram,298299 })299300 301+detailsEditor = await createSourceEditor({302+ element: document.querySelector('#details-editor'),303+ source: restoredDetailsSource ?? serializeDetailsDocument(initialState),304+ diagramType: initialState.type,305+ language: 'json',306+ modelUri: DETAILS_MODEL_URI,307+ ariaLabel: 'Diagram details editor',308+ onChange: scheduleDetailsUpdate,309+ onSave: saveDiagram,310+})311+validateDetailsDocument(false)312+applySystemTheme()313+300314 const preview = new PreviewController({301315 stage: document.querySelector('#preview-stage'),302316 image: document.querySelector('#preview-image'),@@ -304,28 +318,28 @@ const preview = new PreviewController({
304318 minimap: document.querySelector('#minimap'),305319 minimapImage: document.querySelector('#minimap-image'),306320 minimapViewport: document.querySelector('#minimap-viewport'),321+ autoTheme: previewTheme,307322 onAppearances: updateAppearanceOptions,323+ onError: message => showToast(message, 4000),324+})325+systemColorScheme.addEventListener('change', () => {326+ applySystemTheme()327+ if (detailsState.presentation.appearance.startsWith('auto-')) {328+ preview.setAutoTheme(systemColorScheme.matches ? 'dark' : 'light')329+ syncPreviewAppearanceControls()330+ }308331 })309332 310333 typePicker.addEventListener('change', () => {334+ if (!ensureValidDetails()) {335+ typePicker.value = activeType336+ return337+ }311338 const type = typePicker.value312339 history.replaceState(null, '', urlWithDiagramType(location.href, type))313340 applyState(stateForTypeChange(typeDrafts, activeType, type, currentState(), exampleStateFor))314341 })315342 316-document.querySelector('#details').addEventListener('click', () => {317- document.querySelector('#details-dialog').showModal()318- descriptionInput.focus()319-})320-for (const input of [titleInput, descriptionInput, appearanceInput, backgroundInput, paddingInput, frameInput]) {321- input.addEventListener('input', () => {322- syncPresentationControls()323- updateDocumentMetadata()324- clearTimeout(commitDetails.timeout)325- commitDetails.timeout = setTimeout(commitDetails, 220)326- })327-}328-document.querySelector('#details-dialog').addEventListener('close', commitDetails)329343 document.querySelector('#lock-diagram').addEventListener('click', lockDiagram)330344 document.querySelector('#change-password').addEventListener('click', changeDiagramPassword)331345 document.querySelector('#unlock-diagram').addEventListener('click', unlockDiagram)@@ -339,10 +353,27 @@ document.querySelector('#share-save').addEventListener('click', async () => {
339353 })340354 document.querySelector('.brand').addEventListener('click', event => {341355 event.preventDefault()342- if (!confirm('Start a new diagram?')) return356+ if (remote.dirty && !confirm('Discard these local changes and start a new diagram?')) return343357 startNewDiagram()344358 })345359 360+previewRawButton.addEventListener('click', () => {361+ if (detailsState.presentation.appearance === 'raw') {362+ setPreviewAppearance(`${previewTheme}-${previewTransparent ? 'transparent' : 'framed'}`)363+ } else {364+ setPreviewAppearance('raw')365+ }366+})367+previewTransparentButton.addEventListener('click', () => {368+ const prefix = appearancePrefix(detailsState.presentation.appearance)369+ const transparent = !previewTransparent370+ setPreviewAppearance(`${prefix}-${transparent ? 'transparent' : 'framed'}`)371+})372+previewThemeButton.addEventListener('click', () => {373+ const theme = previewTheme === 'light' ? 'dark' : 'light'374+ setPreviewAppearance(`${theme}-${previewTransparent ? 'transparent' : 'framed'}`)375+})376+346377 document.querySelectorAll('[data-preview-action]').forEach(button => {347378 button.addEventListener('click', () => {348379 const action = button.dataset.previewAction@@ -353,23 +384,36 @@ document.querySelectorAll('[data-preview-action]').forEach(button => {
353384 })354385 })355386 356-document.querySelectorAll('.mobile-tabs button').forEach(button => {357- button.addEventListener('click', () => {358- document.querySelector('.workspace').dataset.mobilePanel = button.dataset.panel359- document.querySelectorAll('.mobile-tabs button').forEach(tab => {360- tab.setAttribute('aria-selected', String(tab === button))361- })362- if (button.dataset.panel === 'preview') preview.fit()363- if (button.dataset.panel === 'editor') sourceEditor.layout()364- })387+wireTabs(document.querySelector('.editor-tabs'), button => {388+ const details = button.id === 'details-tab'389+ document.querySelector('#editor').hidden = details390+ document.querySelector('#details-editor').hidden = !details391+ if (details) detailsEditor.layout()392+ else sourceEditor.layout()393+})394+395+wireTabs(document.querySelector('.mobile-tabs'), button => {396+ document.querySelector('.workspace').dataset.mobilePanel = button.dataset.panel397+ if (button.dataset.panel === 'preview') {398+ if (!previewHasBeenRevealed) {399+ previewHasBeenRevealed = true400+ requestAnimationFrame(() => preview.fit())401+ }402+ } else {403+ activeInputEditor().layout()404+ }365405 })366406 367407 document.querySelectorAll('[data-copy]').forEach(button => {368408 button.addEventListener('click', async () => {369409 const input = document.querySelector(`#${button.dataset.copy}`)370410 if (!input.value) return371- await copy(input.value)372- showToast('Copied')411+ try {412+ await copy(input.value)413+ showToast('Copied')414+ } catch {415+ showToast('Could not copy to the clipboard.', 4000)416+ }373417 })374418 })375419 @@ -383,39 +427,128 @@ window.addEventListener('keydown', event => {
383427 }384428 })385429 430+function wireTabs(tablist, onSelect) {431+ const tabs = [...tablist.querySelectorAll('[role="tab"]')]432+ const activate = button => {433+ for (const tab of tabs) {434+ const selected = tab === button435+ tab.setAttribute('aria-selected', String(selected))436+ tab.tabIndex = selected ? 0 : -1437+ }438+ onSelect(button)439+ }440+ tablist.activateTab = activate441+ for (const tab of tabs) {442+ tab.addEventListener('click', () => activate(tab))443+ tab.addEventListener('keydown', event => {444+ let index445+ if (event.key === 'ArrowRight') index = (tabs.indexOf(tab) + 1) % tabs.length446+ else if (event.key === 'ArrowLeft') index = (tabs.indexOf(tab) - 1 + tabs.length) % tabs.length447+ else if (event.key === 'Home') index = 0448+ else if (event.key === 'End') index = tabs.length - 1449+ else return450+ event.preventDefault()451+ tabs[index].focus()452+ activate(tabs[index])453+ })454+ }455+}456+457+function selectTab(button) {458+ button.closest('[role="tablist"]')?.activateTab?.(button)459+}460+461+function activeInputEditor() {462+ return document.querySelector('#details-tab').getAttribute('aria-selected') === 'true'463+ ? detailsEditor464+ : sourceEditor465+}466+386467 function currentState() {387- const padding = Math.min(256, Math.max(0, Number.parseInt(paddingInput.value, 10) || 0))388468 return {389469 type: typePicker.value,390470 source: sourceEditor.getValue(),391471 options: {},392- meta: normalizeMetadata({ title: titleInput.value, description: descriptionInput.value }),393- presentation: normalizePresentation({394- appearance: appearanceInput.value,395- background: backgroundInput.value,396- padding,397- frame: frameInput.checked,398- }),472+ meta: detailsState.meta,473+ presentation: detailsState.presentation,474+ }475+}476+477+function applySystemTheme() {478+ const resolved = systemColorScheme.matches ? 'dark' : 'light'479+ document.documentElement.dataset.theme = resolved480+ document.querySelector('meta[name="theme-color"]').content = resolved === 'dark' ? '#18191b' : '#ffffff'481+ sourceEditor.setTheme?.()482+ detailsEditor.setTheme?.()483+}484+485+function appearancePrefix(appearance) {486+ if (appearance.startsWith('auto-')) return 'auto'487+ if (appearance.startsWith('dark-')) return 'dark'488+ if (appearance.startsWith('light-')) return 'light'489+ return previewTheme490+}491+492+function setPreviewAppearance(appearance) {493+ if (!ensureValidDetails()) return494+ if (!supportedPreviewAppearances.has(appearance)) {495+ showToast('This renderer does not support that appearance.')496+ return399497 }498+ setDetailsState({499+ ...detailsState,500+ presentation: { ...detailsState.presentation, appearance },501+ })502+ syncPreviewAppearanceControls()503+ commitState()400504 }401505 402-function syncPresentationControls() {403- const usesRendererPresentation = appearanceInput.value === 'raw'404- backgroundInput.disabled = !usesRendererPresentation405- paddingInput.disabled = !usesRendererPresentation406- frameInput.disabled = !usesRendererPresentation407- document.querySelector('#appearance-note').textContent = usesRendererPresentation408- ? "Renderer default keeps the renderer's paint and enables the canvas controls below."409- : 'This appearance uses the shared Diagram.zip palette. Canvas, padding, and frame come from that appearance.'506+function syncPreviewAppearanceControls() {507+ const appearance = detailsState.presentation.appearance508+ const raw = appearance === 'raw'509+ if (!raw) {510+ const prefix = appearancePrefix(appearance)511+ previewTheme = prefix === 'auto' ? (systemColorScheme.matches ? 'dark' : 'light') : prefix512+ previewTransparent = appearance.endsWith('-transparent')513+ }514+515+ previewRawButton.setAttribute('aria-pressed', String(raw))516+ previewRawButton.disabled = raw && supportedPreviewAppearances.size === 1517+ previewTransparentButton.setAttribute('aria-pressed', String(!raw && previewTransparent))518+ previewTransparentButton.disabled = raw || !supportedPreviewAppearances.has(519+ `${appearancePrefix(appearance)}-${previewTransparent ? 'framed' : 'transparent'}`,520+ )521+ const oppositeTheme = previewTheme === 'light' ? 'dark' : 'light'522+ previewThemeButton.dataset.previewTheme = previewTheme523+ previewThemeButton.setAttribute('aria-pressed', String(!raw && previewTheme === 'dark'))524+ previewThemeButton.setAttribute('aria-label', `Preview theme: ${previewTheme === 'dark' ? 'Dark' : 'Light'}`)525+ previewThemeButton.title = `Preview theme: ${previewTheme === 'dark' ? 'Dark' : 'Light'} · click for ${oppositeTheme}`526+ previewThemeButton.disabled = raw || !supportedPreviewAppearances.has(527+ `${oppositeTheme}-${previewTransparent ? 'transparent' : 'framed'}`,528+ )410529 }411530 412531 function updateAppearanceOptions(appearances, appliedAppearance) {413- const supported = new Set(appearances)414- for (const option of appearanceInput.options) option.disabled = !supported.has(option.value)415- if (appearanceInput.value === appliedAppearance) return416- appearanceInput.value = appliedAppearance417- syncPresentationControls()418- queueMicrotask(commitDetails)532+ supportedPreviewAppearances = new Set(appearances)533+ if (!detailsValid) {534+ syncPreviewAppearanceControls()535+ return536+ }537+ const requested = detailsState.presentation.appearance538+ if (!supportedPreviewAppearances.has(requested)) {539+ const prefix = appearancePrefix(requested)540+ const alternateSurface = requested.endsWith('-transparent') ? 'framed' : 'transparent'541+ const alternate = `${prefix}-${alternateSurface}`542+ setDetailsState({543+ ...detailsState,544+ presentation: {545+ ...detailsState.presentation,546+ appearance: supportedPreviewAppearances.has(alternate) ? alternate : appliedAppearance,547+ },548+ })549+ }550+ syncPreviewAppearanceControls()551+ if (detailsState.presentation.appearance !== requested) queueMicrotask(commitState)419552 }420553 421554 function scheduleUpdate(delay = 1200) {@@ -425,16 +558,87 @@ function scheduleUpdate(delay = 1200) {
425558 426559 function commitState(render = true) {427560 const state = currentState()428- storeDraft(state)429- remote.dirty = remote.keyEnvelopeDirty || remote.savedSnapshot !== stateSnapshot(state, remote.mode)561+ remote.dirty = !detailsValid || workingStateIsDirty(state, {562+ mode: remote.mode,563+ keyEnvelopeDirty: remote.keyEnvelopeDirty,564+ savedSnapshot: remote.savedSnapshot,565+ defaultStateFor: exampleStateFor,566+ })567+ syncStoredDraft(state)430568 updateSaveButton()431569 updatePrivacyControls()432570 if (render) preview.render(state)433571 }434572 435-function commitDetails() {436- clearTimeout(commitDetails.timeout)437- commitState()573+function scheduleDetailsUpdate() {574+ clearTimeout(detailsCommitTimer)575+ validateDetailsDocument(false)576+ detailsCommitTimer = setTimeout(() => {577+ if (detailsValid) commitState()578+ else commitState(false)579+ }, 220)580+}581+582+function validateDetailsDocument(showError = false) {583+ try {584+ detailsState = parseDetailsDocument(detailsEditor.getValue())585+ detailsValid = true586+ detailsError = ''587+ updateDetailsValidation()588+ updateDocumentMetadata()589+ syncPreviewAppearanceControls()590+ return true591+ } catch (error) {592+ detailsValid = false593+ detailsError = error instanceof Error ? error.message : 'Details are invalid.'594+ updateDetailsValidation()595+ if (showError) showToast(detailsError, 4000)596+ return false597+ }598+}599+600+function ensureValidDetails() {601+ if (validateDetailsDocument(true)) return true602+ selectTab(document.querySelector('#details-tab'))603+ detailsEditor.focus()604+ return false605+}606+607+function setDetailsState(nextDetails) {608+ detailsState = {609+ meta: normalizeMetadata(nextDetails.meta),610+ presentation: normalizePresentation(nextDetails.presentation),611+ }612+ detailsValid = true613+ detailsError = ''614+ detailsEditor.setDocument({615+ source: serializeDetailsDocument(detailsState),616+ diagramType: typePicker.value,617+ language: 'json',618+ })619+ updateDetailsValidation()620+ updateDocumentMetadata()621+}622+623+function updateDetailsValidation() {624+ const status = document.querySelector('#details-validation')625+ const tab = document.querySelector('#details-tab')626+ const dock = document.querySelector('#syntax-dock')627+ const summary = document.querySelector('#syntax-dock-summary')628+ const count = document.querySelector('#syntax-message-count')629+ status.textContent = detailsValid ? 'No problems' : detailsError630+ status.title = detailsError631+ count.textContent = detailsValid ? '0' : '1'632+ dock.dataset.severity = detailsValid ? 'none' : 'error'633+ summary.setAttribute('aria-label', detailsValid ? 'No diagnostics' : '1 error')634+ summary.title = detailsValid ? 'No diagnostics' : '1 error'635+ tab.dataset.invalid = String(!detailsValid)636+ tab.setAttribute('aria-invalid', String(!detailsValid))637+ if (detailsValid) {638+ tab.removeAttribute('aria-describedby')639+ } else {640+ tab.setAttribute('aria-describedby', 'details-validation')641+ }438642 }439643 440644 async function loadInitialState() {@@ -459,7 +663,10 @@ async function loadInitialState() {
459663 }460664 setRemoteAlias(alias, writeCapability, alias.state)461665 const draft = loadDraft(`${ALIAS_DRAFT_PREFIX}${aliasId}`)462- if (draft?.revision === alias.revision) return draft.state666+ if (draft?.revision === alias.revision) {667+ restoredDetailsSource = draft.detailsSource668+ return draft.state669+ }463670 return alias.state464671 } catch (error) {465672 initialLoadError = error instanceof PersistenceError@@ -472,13 +679,17 @@ async function loadInitialState() {
472679 const draft = loadDraft(LOCAL_DRAFT_KEY)473680 if (requestedType) {474681 if (draft?.state.type === requestedType) {682+ restoredDetailsSource = draft.detailsSource475683 const state = refreshMatchingExampleMetadata(draft.state, exampleStateFor(requestedType))476684 if (state !== draft.state) storeDraft(state)477685 return state478686 }479687 return exampleStateFor(requestedType)480688 }481- if (draft) return draft.state689+ if (draft) {690+ restoredDetailsSource = draft.detailsSource691+ return draft.state692+ }482693 return exampleStateFor(DEFAULT_DIAGRAM_TYPE)483694 }484695 @@ -489,20 +700,15 @@ function applyState(state, commit = true) {
489700 activeType = state.type490701 typeDrafts.set(state.type, { ...state, meta, presentation })491702 typePicker.value = state.type492- titleInput.value = meta.title493- descriptionInput.value = meta.description494- appearanceInput.value = presentation.appearance495- backgroundInput.value = presentation.background496- paddingInput.value = String(presentation.padding)497- frameInput.checked = presentation.frame498- syncPresentationControls()499- updateDocumentMetadata()703+ setDetailsState({ meta, presentation })704+ syncPreviewAppearanceControls()500705 sourceEditor.setDocument({ source: state.source, diagramType: state.type })501706 if (commit) commitState()502707 else preview.render(currentState())503708 }504709 505710 function openShareDialog() {711+ if (!ensureValidDetails()) return506712 commitState()507713 populateShareDialog()508714 document.querySelector('#share-dialog').showModal()@@ -574,6 +780,7 @@ function setShareField(id, value, placeholder) {
574780 575781 async function saveDiagram() {576782 if (saving) return false783+ if (!ensureValidDetails()) return false577784 commitState(false)578785 const state = currentState()579786 if (remote.aliasId && !remote.dirty) {@@ -639,7 +846,7 @@ async function lockedPayloadForState(state) {
639846 throw new Error('This diagram is missing its browser encryption key.')640847 }641848 const contentIsUnchanged = remote.savedMode === 'locked'642- && remote.savedSnapshot === stateSnapshot(state, 'locked')849+ && remote.savedSnapshot === workingStateSnapshot(state, 'locked')643850 && remote.encryptedContent644851 && remote.encryptedMetadata645852 if (contentIsUnchanged) {@@ -684,7 +891,7 @@ async function cacheSavedRenders(state) {
684891 })685892 const results = await Promise.allSettled(uploads)686893 if (results.every(result => result.status === 'rejected')) {687- preview.setStatus('Saved; render cache unavailable', 'ready')894+ showToast('Saved, but the render cache is unavailable.', 4000)688895 }689896 }690897 @@ -713,6 +920,7 @@ async function svgToPngBlob(svg) {
713920 }714921 715922 async function lockDiagram() {923+ if (!ensureValidDetails()) return716924 const password = await askPassword({717925 title: 'Lock diagram',718926 copy: 'The source, metadata, and saved render will be encrypted in this browser. The password cannot be recovered.',@@ -882,17 +1090,6 @@ function askConflict() {
8821090 })8831091 }8841092 885-function stateSnapshot(state, mode = 'open') {886- return JSON.stringify({887- mode,888- type: state.type,889- source: state.source,890- options: state.options ?? {},891- meta: normalizeMetadata(state.meta),892- presentation: normalizePresentation(state.presentation),893- })894-}895-8961093 function normalizeStoredState(value) {8971094 if (!value || typeof value !== 'object' || !isKnownDiagramType(value.type) || typeof value.source !== 'string') {8981095 return null@@ -915,7 +1112,11 @@ function loadDraft(key) {
9151112 const draft = JSON.parse(localStorage.getItem(key))9161113 const state = normalizeStoredState(draft?.state)9171114 if (!state) return null918- return { state, revision: draft.revision ?? null }1115+ return {1116+ state,1117+ revision: draft.revision ?? null,1118+ detailsSource: typeof draft.detailsSource === 'string' ? draft.detailsSource : null,1119+ }9191120 } catch {9201121 return null9211122 }@@ -925,12 +1126,22 @@ function storeDraft(state) {
9251126 if (remote.mode === 'locked') return9261127 const key = remote.aliasId ? `${ALIAS_DRAFT_PREFIX}${remote.aliasId}` : LOCAL_DRAFT_KEY9271128 try {928- localStorage.setItem(key, JSON.stringify({ state, revision: remote.revision }))1129+ const detailsSource = !detailsValid && detailsEditor1130+ ? detailsEditor.getValue()1131+ : null1132+ localStorage.setItem(key, JSON.stringify({ state, revision: remote.revision, detailsSource }))9291133 } catch {9301134 // The editor remains usable when storage is unavailable.9311135 }9321136 }9331137 1138+function syncStoredDraft(state) {1139+ if (remote.mode === 'locked') return1140+ const key = remote.aliasId ? `${ALIAS_DRAFT_PREFIX}${remote.aliasId}` : LOCAL_DRAFT_KEY1141+ if (remote.dirty) storeDraft(state)1142+ else removeDraft(key)1143+}1144+9341145 function removeDraft(key) {9351146 try {9361147 localStorage.removeItem(key)@@ -965,7 +1176,7 @@ function setRemoteAlias(alias, writeCapability, state, bundleKey = null) {
9651176 savedMode: alias.mode,9661177 writeCapability,9671178 savedState: state,968- savedSnapshot: stateSnapshot(state, alias.mode),1179+ savedSnapshot: workingStateSnapshot(state, alias.mode),9691180 bundleKey,9701181 keyEnvelope: alias.mode === 'locked' ? alias.keyEnvelope : null,9711182 encryptedContent: alias.mode === 'locked' ? alias.encryptedContent : null,@@ -1004,16 +1215,37 @@ function updateSaveButton() {
10041215 10051216 function updateDraftControls() {10061217 const bar = document.querySelector('#draft-bar')1007- const hasLocalOverlay = Boolean(remote.aliasId && remote.savedState && remote.dirty)1008- bar.hidden = !hasLocalOverlay1009- document.querySelector('#restore-saved').disabled = saving1010- document.querySelector('#make-copy').disabled = saving1218+ const restore = document.querySelector('#restore-saved')1219+ const makeCopy = document.querySelector('#make-copy')1220+ const hasAliasOverlay = Boolean(remote.aliasId && remote.savedState && remote.dirty)1221+ const hasAnonymousDraft = Boolean(!remote.aliasId && remote.dirty)1222+ bar.hidden = !hasAliasOverlay && !hasAnonymousDraft1223+ restore.disabled = saving1224+ makeCopy.disabled = saving1225+ makeCopy.hidden = hasAnonymousDraft1226+ bar.dataset.anonymous = String(hasAnonymousDraft)1227+ if (hasAnonymousDraft) {1228+ const type = typePicker.selectedOptions[0]?.textContent ?? 'diagram'1229+ bar.querySelector('p').textContent = `This device has changes to the ${type} example.`1230+ restore.querySelector('span').textContent = ''1231+ restore.setAttribute('aria-label', 'Reset')1232+ restore.title = 'Reset'1233+ } else {1234+ bar.querySelector('p').textContent = 'This device has changes that are not part of the saved share link.'1235+ restore.querySelector('span').textContent = 'Restore saved'1236+ restore.removeAttribute('aria-label')1237+ restore.removeAttribute('title')1238+ }10111239 }10121240 10131241 function restoreSavedDiagram() {1242+ if (!remote.aliasId) {1243+ resetAnonymousExample()1244+ return1245+ }10141246 if (!remote.aliasId || !remote.savedState || !remote.dirty) return10151247 clearTimeout(renderTimer)1016- clearTimeout(commitDetails.timeout)1248+ clearTimeout(detailsCommitTimer)10171249 remote.mode = remote.savedMode10181250 remote.keyEnvelopeDirty = false10191251 remote.dirty = false@@ -1026,8 +1258,24 @@ function restoreSavedDiagram() {
10261258 showToast('Restored saved diagram')10271259 }10281260 1261+function resetAnonymousExample() {1262+ if (remote.aliasId || !remote.dirty) return1263+ if (!confirm('Discard local changes and reset this example?')) return1264+ clearTimeout(renderTimer)1265+ clearTimeout(detailsCommitTimer)1266+ removeDraft(LOCAL_DRAFT_KEY)1267+ typeDrafts.clear()1268+ const state = exampleStateFor(typePicker.value)1269+ applyState(state, false)1270+ remote.dirty = false1271+ history.replaceState(null, '', urlWithDiagramType(location.href, state.type))1272+ updateSaveButton()1273+ showToast('Example reset')1274+}1275+10291276 async function makeDraftCopy() {10301277 if (saving || !remote.aliasId || !remote.dirty) return1278+ if (!ensureValidDetails()) return10311279 commitState(false)10321280 const state = currentState()10331281 saving = true@@ -1055,6 +1303,7 @@ function updatePrivacyControls() {
10551303 }10561304 10571305 function startNewDiagram() {1306+ if (!remote.aliasId) removeDraft(LOCAL_DRAFT_KEY)10581307 remote = {10591308 aliasId: null,10601309 contentId: null,@@ -1070,7 +1319,7 @@ function startNewDiagram() {
10701319 encryptedContent: null,10711320 encryptedMetadata: null,10721321 keyEnvelopeDirty: false,1073- dirty: true,1322+ dirty: false,10741323 }10751324 typeDrafts.clear()10761325 history.pushState(null, '', urlWithDiagramType('/', DEFAULT_DIAGRAM_TYPE))@@ -1078,28 +1327,46 @@ function startNewDiagram() {
10781327 }10791328 10801329 function updateDocumentMetadata() {1081- const title = titleInput.value.trim()1082- const description = descriptionInput.value.trim()1330+ const title = detailsState.meta.title.trim()1331+ const description = detailsState.meta.description.trim()1332+ titleLabel.textContent = title || 'Untitled'10831333 document.title = documentTitle(title)10841334 document.querySelector('meta[name="description"]').content = description || 'Create diagrams from text and share them as a link.'10851335 }10861336 10871337 async function copy(value) {1088- if (navigator.clipboard) return navigator.clipboard.writeText(value)1338+ if (navigator.clipboard) {1339+ try {1340+ await navigator.clipboard.writeText(value)1341+ return1342+ } catch {1343+ // Fall back for browsers that expose Clipboard without granting write access.1344+ }1345+ }10891346 const textarea = document.createElement('textarea')10901347 textarea.value = value1348+ textarea.style.position = 'fixed'1349+ textarea.style.opacity = '0'10911350 document.body.append(textarea)10921351 textarea.select()1093- document.execCommand('copy')1352+ const copied = document.execCommand('copy')10941353 textarea.remove()1354+ if (!copied) throw new Error('Clipboard access is unavailable.')10951355 }10961356 1097-function showToast(message) {1357+function showToast(message, duration = 1800) {10981358 const toast = document.querySelector('#toast')10991359 toast.textContent = message1360+ clearTimeout(showToast.hideTimeout)1361+ if (typeof toast.showPopover === 'function' && !toast.matches(':popover-open')) toast.showPopover()11001362 toast.dataset.visible = 'true'11011363 clearTimeout(showToast.timeout)1102- showToast.timeout = setTimeout(() => delete toast.dataset.visible, 1800)1364+ showToast.timeout = setTimeout(() => {1365+ delete toast.dataset.visible1366+ showToast.hideTimeout = setTimeout(() => {1367+ if (typeof toast.hidePopover === 'function' && toast.matches(':popover-open')) toast.hidePopover()1368+ }, 160)1369+ }, duration)11031370 }11041371 11051372 commitState()@@ -6,15 +6,41 @@ const STATUS_LABELS = {
66 idle: 'Ready',77 loading: 'Rendering',88 ready: 'Rendered',9- error: 'Error',109 }1110 1211 function clamp(value, minimum, maximum) {1312 return Math.min(Math.max(value, minimum), maximum)1413 }1514 15+export function previewThemeForAppearance(appearance) {16+ if (appearance.startsWith('dark-')) return 'dark'17+ if (appearance.startsWith('auto-')) return 'auto'18+ return 'light'19+}20+21+export function previewBackgroundForAppearance(appearance, rawBackground) {22+ if (appearance.startsWith('dark-')) return 'var(--preview-dark-bg)'23+ if (appearance.startsWith('light-')) return 'var(--preview-light-bg)'24+ if (appearance.startsWith('auto-')) return 'var(--preview-auto-bg)'25+ return rawBackground26+}27+28+export function previewViewKey({ type, source, options = {} }) {29+ return JSON.stringify({ type, source, options })30+}31+1632 export class PreviewController {17- constructor({ stage, image, status, minimap, minimapImage, minimapViewport, onAppearances = () => {} }) {33+ constructor({34+ stage,35+ image,36+ status,37+ minimap,38+ minimapImage,39+ minimapViewport,40+ autoTheme = 'light',41+ onAppearances = () => {},42+ onError = () => {},43+ }) {1844 this.stage = stage1945 this.image = image2046 this.status = status@@ -22,6 +48,8 @@ export class PreviewController {
2248 this.minimapImage = minimapImage2349 this.minimapViewport = minimapViewport2450 this.onAppearances = onAppearances51+ this.onError = onError52+ this.autoTheme = autoTheme2553 this.scale = 12654 this.x = 02755 this.y = 0@@ -31,18 +59,19 @@ export class PreviewController {
3159 this.pendingRender = null3260 this.renderLoop = null3361 this.latestRenderKey = null62+ this.latestViewKey = null63+ this.fitOnNextLoad = false3464 this.latestSvgBlob = null65+ this.latestCanonicalSvg = null66+ this.latestPresentation = null67+ this.latestAppearance = null3568 this.latestRendererIdentity = null69+ this.previousImageSize = null3670 this.imageFallbackUrl = null3771 this.activeImageUrl = null3872 this.drag = null3973 40- this.image.addEventListener('load', () => {41- if (this.image.src !== this.activeImageUrl) return42- this.imageFallbackUrl = null43- this.setStatus('Rendered', 'ready')44- this.fit()45- })74+ this.image.addEventListener('load', () => this.handleImageLoad())4675 this.image.addEventListener('error', () => this.retryBlockedImage())4776 this.stage.addEventListener('pointerdown', event => this.startPan(event))4877 this.stage.addEventListener('pointermove', event => this.pan(event))@@ -53,6 +82,24 @@ export class PreviewController {
5382 new ResizeObserver(() => this.updateTransform()).observe(this.stage)5483 }5584 85+ handleImageLoad() {86+ if (this.image.src !== this.activeImageUrl) return87+ this.imageFallbackUrl = null88+ this.setStatus('Rendered', 'ready')89+ if (this.fitOnNextLoad) {90+ this.fitOnNextLoad = false91+ this.previousImageSize = null92+ this.fit()93+ } else {94+ if (this.previousImageSize) {95+ this.x += (this.previousImageSize.width - this.image.naturalWidth) * this.scale / 296+ this.y += (this.previousImageSize.height - this.image.naturalHeight) * this.scale / 297+ this.previousImageSize = null98+ }99+ this.updateTransform()100+ }101+ }102+56103 render({ type, source, options = {}, meta = {}, presentation = {} }) {57104 if (!source.trim()) {58105 this.requestNumber++@@ -62,6 +109,12 @@ export class PreviewController {
62109 return63110 }64111 112+ const viewKey = previewViewKey({ type, source, options })113+ if (this.latestViewKey !== viewKey) {114+ this.latestViewKey = viewKey115+ this.fitOnNextLoad = true116+ }117+65118 const renderKey = JSON.stringify({ type, source, options, meta, presentation })66119 if (this.latestRenderKey === renderKey && this.latestSvgBlob) return Promise.resolve()67120 @@ -116,32 +169,57 @@ export class PreviewController {
116169 const requestedAppearance = presentation.appearance ?? 'raw'117170 const appearance = appearances.includes(requestedAppearance) ? requestedAppearance : 'raw'118171 this.onAppearances(appearances, appearance)119- const displayedSvg = appearance === 'raw'120- ? materializePresentation(canonicalSvg, presentation)121- : materializeSvg(canonicalSvg, appearance)122- const displayed = this.normalizedSvgBlob(displayedSvg)123172 this.latestRenderKey = renderKey124173 this.latestSvgBlob = canonical.blob174+ this.latestCanonicalSvg = canonicalSvg175+ this.latestPresentation = presentation176+ this.latestAppearance = appearance125177 this.latestRendererIdentity = rendered.identity126- this.imageFallbackUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(await displayed.blob.text())}`127- this.stage.style.setProperty('--render-background', displayed.background)128- const nextObjectUrl = URL.createObjectURL(displayed.blob)129- const previousObjectUrl = this.objectUrl130- this.objectUrl = nextObjectUrl131- this.activeImageUrl = nextObjectUrl132- this.image.src = nextObjectUrl133- this.minimapImage.src = nextObjectUrl134- this.image.hidden = false135- this.setStatus('Loading image…', 'loading')136- if (previousObjectUrl) URL.revokeObjectURL(previousObjectUrl)178+ this.displayCanonical(canonicalSvg, presentation, appearance)137179 } catch (error) {138180 if (error.name === 'AbortError') return139- this.setStatus(error.message || 'Could not render this diagram.', 'error')181+ this.handleRenderError(error.message || 'Could not render this diagram.')140182 } finally {141183 if (this.abortController === abortController) this.abortController = null142184 }143185 }144186 187+ setAutoTheme(theme) {188+ if (!['light', 'dark'].includes(theme) || this.autoTheme === theme) return189+ this.autoTheme = theme190+ if (this.latestCanonicalSvg && this.latestAppearance?.startsWith('auto-')) {191+ this.displayCanonical(this.latestCanonicalSvg, this.latestPresentation, this.latestAppearance)192+ }193+ }194+195+ displayCanonical(canonicalSvg, presentation, appearance) {196+ const displayAppearance = appearance.startsWith('auto-')197+ ? `${this.autoTheme}-${appearance.slice('auto-'.length)}`198+ : appearance199+ const displayedSvg = displayAppearance === 'raw'200+ ? materializePresentation(canonicalSvg, presentation)201+ : materializeSvg(canonicalSvg, displayAppearance)202+ const displayed = this.normalizedSvgBlob(displayedSvg)203+ this.previousImageSize = !this.fitOnNextLoad && this.image.naturalWidth && this.image.naturalHeight204+ ? { width: this.image.naturalWidth, height: this.image.naturalHeight }205+ : null206+ this.imageFallbackUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(displayedSvg)}`207+ this.stage.dataset.previewTheme = previewThemeForAppearance(displayAppearance)208+ this.stage.style.setProperty(209+ '--render-background',210+ previewBackgroundForAppearance(displayAppearance, displayed.background),211+ )212+ const nextObjectUrl = URL.createObjectURL(displayed.blob)213+ const previousObjectUrl = this.objectUrl214+ this.objectUrl = nextObjectUrl215+ this.activeImageUrl = nextObjectUrl216+ this.image.src = nextObjectUrl217+ this.minimapImage.src = nextObjectUrl218+ this.image.hidden = false219+ this.setStatus('Loading image…', 'loading')220+ if (previousObjectUrl) URL.revokeObjectURL(previousObjectUrl)221+ }222+145223 async renderThroughUnit({ type, source, options, meta, presentation }, signal) {146224 const unitEndpoint = httpRendererUnitFor(type)147225 if (!unitEndpoint) throw new Error(`No renderer is registered for ${type}.`)@@ -200,7 +278,7 @@ export class PreviewController {
200278 this.minimapImage.src = fallback201279 return202280 }203- this.setStatus('The rendered image was blocked or invalid.', 'error')281+ this.handleRenderError('The rendered image was blocked or invalid.')204282 }205283 206284 svgBlobFor(state) {@@ -252,11 +330,16 @@ export class PreviewController {
252330 setStatus(message, state) {253331 const label = STATUS_LABELS[state] ?? 'Ready'254332 this.status.textContent = label255- this.status.title = message === label ? '' : message256- this.status.setAttribute('aria-label', message === label ? label : `${label}: ${message}`)333+ this.status.title = ''334+ this.status.setAttribute('aria-label', label)257335 this.status.dataset.state = state258336 }259337 338+ handleRenderError(message) {339+ this.setStatus('Ready', 'idle')340+ this.onError(message)341+ }342+260343 zoom(factor) {261344 if (this.image.hidden) return262345 const bounds = this.stage.getBoundingClientRect()@@ -7,20 +7,24 @@
77 --muted: #666666;88 --line: #dddddd;99 --soft: #f3f3f3;10- --action-bg: #000000;11- --action-ink: #ffffff;10+ --accent-surface: #80ff80;11+ --accent-ink: #213f21;1212 --unsaved: #b45309;1313 --danger: #333333;1414 --editor-active-line: #0000000b;15- --editor-selection: #00000038;15+ --editor-selection: #213f2138;1616 --syntax-keyword: #111111;1717 --syntax-string: #333333;1818 --syntax-comment: #858585;1919 --syntax-number: #444444;2020 --syntax-operator: #555555;2121 --syntax-variable: #222222;22- --dot: rgba(0, 0, 0, .11);2322 --preview-bg: #ffffff;23+ --preview-light-bg: #f8fafc;24+ --preview-dark-bg: #0f172a;25+ --preview-auto-bg: var(--preview-light-bg);26+ --preview-dot-light: rgba(15, 23, 42, .16);27+ --preview-dot-dark: rgba(203, 213, 225, .32);2428 --shadow: 0 22px 70px rgba(0, 0, 0, .12);2529 --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;2630 --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;@@ -29,6 +33,35 @@
2933 text-rendering: optimizeLegibility;3034 }3135 36+:root[data-theme="dark"] {37+ color-scheme: dark;38+ --page: #18191b;39+ --surface: #1d1e20;40+ --surface-raised: #242528;41+ --ink: #f4f4f5;42+ --muted: #a5a7aa;43+ --line: #45484e;44+ --soft: #2a2b2e;45+ --accent-surface: #213f21;46+ --accent-ink: #80ff80;47+ --unsaved: #f59e0b;48+ --danger: #fca5a5;49+ --editor-active-line: #ffffff0b;50+ --editor-selection: #80ff8032;51+ --syntax-keyword: #f4f4f5;52+ --syntax-string: #d4d4d8;53+ --syntax-comment: #92949a;54+ --syntax-number: #c5c6ca;55+ --syntax-operator: #b4b5b9;56+ --syntax-variable: #e4e4e7;57+ --preview-bg: #18191b;58+ --shadow: 0 22px 70px rgba(0, 0, 0, .4);59+}60+61+@media (prefers-color-scheme: dark) {62+ :root { --preview-auto-bg: var(--preview-dark-bg); }63+}64+3265 * { box-sizing: border-box; }3366 3467 html, body, #app { height: 100%; }@@ -40,8 +73,11 @@ body {
4073 color: var(--ink);4174 }4275 43-.app-shell,44-dialog,76+.brand,77+button,78+.preview-stage,79+.mobile-tabs,80+.editor-tabs,4581 .toast {4682 user-select: none;4783 -webkit-user-select: none;@@ -54,8 +90,7 @@ dialog,
5490 .share-options input,5591 .details-fields input,5692 .details-fields textarea,57-.details-fields select,58-.render-status[data-state="error"] {93+.details-fields select {5994 user-select: text;6095 -webkit-user-select: text;6196 }@@ -117,25 +152,17 @@ button { cursor: pointer; }
117152 }118153 119154 .document-title {155+ display: block;120156 width: min(38vw, 420px);121157 min-width: 90px;122158 padding: 4px 7px;123- border: 1px solid transparent;124- border-radius: 6px;125- background: transparent;126159 color: var(--ink);127160 font-size: 22px;128161 font-weight: 440;129162 letter-spacing: -.025em;130- outline: none;163+ overflow: hidden;131164 text-overflow: ellipsis;132-}133-134-.document-title:hover { border-color: var(--line); }135-.document-title:focus {136- border-color: var(--ink);137- background: var(--surface);138- box-shadow: 0 0 0 1px var(--ink);165+ white-space: nowrap;139166 }140167 141168 .header-meta {@@ -161,6 +188,27 @@ button { cursor: pointer; }
161188 .draft-bar p { margin: 0; font-size: 13px; }162189 .draft-bar div { display: flex; flex: 0 0 auto; gap: 7px; }163190 191+.draft-reset-icon {192+ display: none;193+ width: 18px;194+ height: 18px;195+ fill: none;196+ stroke: currentColor;197+ stroke-linecap: round;198+ stroke-linejoin: round;199+ stroke-width: 1.75;200+}201+202+.draft-bar[data-anonymous="true"] #restore-saved {203+ display: inline-grid;204+ width: 36px;205+ min-width: 36px;206+ padding: 0;207+ place-items: center;208+}209+210+.draft-bar[data-anonymous="true"] .draft-reset-icon { display: block; }211+164212 .workspace { grid-row: 3; }165213 166214 .render-status {@@ -170,8 +218,6 @@ button { cursor: pointer; }
170218 white-space: nowrap;171219 }172220 173-.render-status[data-state="error"] { color: var(--danger); }174-175221 .type-picker select,176222 .primary-action,177223 .secondary-action,@@ -195,9 +241,9 @@ button { cursor: pointer; }
195241 196242 .primary-action {197243 padding: 7px 15px;198- border-color: var(--action-bg);199- background: var(--action-bg);200- color: var(--action-ink);244+ border-color: var(--accent-surface);245+ background: var(--accent-surface);246+ color: var(--accent-ink);201247 font-weight: 720;202248 }203249 @@ -256,7 +302,6 @@ button { cursor: pointer; }
256302 #save[data-save-state="saved"] [data-save-icon="saved"],257303 #save[data-save-state="copy"] [data-save-icon="copy"] { display: block; }258304 #save[data-save-state="saving"] .save-action-icon { animation: save-spin 900ms linear infinite; }259-260305 @keyframes save-spin { to { transform: rotate(360deg); } }261306 262307 .workspace {@@ -270,12 +315,100 @@ button { cursor: pointer; }
270315 .preview-panel { min-width: 0; min-height: 0; }271316 272317 .editor-panel {318+ display: grid;319+ grid-template-rows: 40px minmax(0, 1fr) 30px;273320 overflow: hidden;274321 border-right: 1px solid var(--line);275322 background: var(--surface);276323 }277324 278-#editor { position: relative; height: 100%; }325+.editor-tabs {326+ display: grid;327+ grid-template-columns: repeat(2, minmax(0, 1fr));328+ min-width: 0;329+ padding: 0;330+ border-bottom: 1px solid var(--line);331+ background: var(--page);332+}333+334+.editor-tabs button {335+ min-width: 0;336+ min-height: 39px;337+ padding: 6px 12px;338+ border: 0;339+ border-right: 1px solid var(--line);340+ border-radius: 0;341+ background: transparent;342+ color: var(--muted);343+ font-size: 12px;344+ font-weight: 720;345+}346+347+.editor-tabs button:last-child { border-right: 0; }348+349+.editor-tabs button[aria-selected="true"] {350+ background: var(--soft);351+ box-shadow: inset 0 -2px 0 var(--accent-ink);352+ color: var(--ink);353+}354+355+.editor-tabs button[data-invalid="true"] { color: var(--danger); }356+357+.syntax-dock {358+ display: flex;359+ align-items: center;360+ gap: 10px;361+ min-width: 0;362+ padding: 0 10px;363+ border-top: 1px solid var(--line);364+ background: var(--page);365+ color: var(--muted);366+ font-size: 11px;367+}368+369+.syntax-dock-summary {370+ display: inline-flex;371+ flex: 0 0 auto;372+ align-items: center;373+ gap: 4px;374+ color: var(--ink);375+ font-weight: 720;376+}377+378+.diagnostic-icon {379+ width: 14px;380+ height: 14px;381+ overflow: visible;382+ fill: none;383+ stroke: currentColor;384+ stroke-linecap: round;385+ stroke-linejoin: round;386+ stroke-width: 1.8;387+}388+389+.diagnostic-icon [data-diagnostic-icon] { display: none; }390+.syntax-dock[data-severity="none"] [data-diagnostic-icon="none"],391+.syntax-dock[data-severity="error"] [data-diagnostic-icon="error"],392+.syntax-dock[data-severity="warning"] [data-diagnostic-icon="warning"],393+.syntax-dock[data-severity="info"] [data-diagnostic-icon="info"] { display: block; }394+395+.syntax-dock[data-severity="error"] .syntax-dock-summary,396+.syntax-dock[data-severity="error"] .details-validation { color: var(--danger); }397+.syntax-dock[data-severity="warning"] .syntax-dock-summary { color: var(--unsaved); }398+.syntax-dock[data-severity="info"] .syntax-dock-summary { color: var(--accent-ink); }399+400+.details-validation {401+ min-width: 0;402+ overflow: hidden;403+ text-overflow: ellipsis;404+ white-space: nowrap;405+}406+407+.editor-surfaces { position: relative; min-width: 0; min-height: 0; }408+#editor,409+#details-editor { position: relative; width: 100%; height: 100%; }410+#editor[hidden],411+#details-editor[hidden] { display: none; }279412 280413 .monaco-editor { font-size: 14px; }281414 @@ -297,24 +430,34 @@ button { cursor: pointer; }
297430 }298431 299432 .source-editor-textarea:focus-visible {300- box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--ink), transparent 72%);433+ box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-ink), transparent 42%);301434 }302435 436+.source-editor-textarea::selection { background: var(--editor-selection); }437+303438 .preview-panel { background: var(--preview-bg); }304439 305440 .preview-stage {441+ --preview-dot: var(--preview-dot-light);306442 position: relative;307443 width: 100%;308444 height: 100%;309445 min-height: 320px;310446 overflow: hidden;311447 touch-action: none;312- background-image: radial-gradient(var(--dot) 1px, transparent 1px);448+ background-image: radial-gradient(var(--preview-dot) 1px, transparent 1px);313449 background-size: 20px 20px;314450 cursor: grab;315451 background-color: var(--render-background, var(--preview-bg));316452 }317453 454+.preview-stage[data-preview-theme="dark"] { --preview-dot: var(--preview-dot-dark); }455+.preview-stage[data-preview-theme="light"] { --preview-dot: var(--preview-dot-light); }456+457+@media (prefers-color-scheme: dark) {458+ .preview-stage[data-preview-theme="auto"] { --preview-dot: var(--preview-dot-dark); }459+}460+318461 .preview-stage[data-dragging="true"] { cursor: grabbing; }319462 320463 #preview-image {@@ -342,6 +485,9 @@ button { cursor: pointer; }
342485 left: 14px;343486 bottom: 14px;344487 display: flex;488+ max-width: calc(100% - 28px);489+ flex-wrap: wrap;490+ align-items: center;345491 gap: 6px;346492 padding: 5px;347493 border: 1px solid var(--line);@@ -351,6 +497,19 @@ button { cursor: pointer; }
351497 backdrop-filter: blur(12px);352498 }353499 500+.preview-zoom-controls,501+.preview-appearance-controls {502+ display: flex;503+ gap: 6px;504+}505+506+.preview-toolbar-divider {507+ align-self: stretch;508+ width: 1px;509+ min-height: 28px;510+ background: var(--line);511+}512+354513 .preview-toolbar button {355514 min-width: 36px;356515 padding: 6px 9px;@@ -358,6 +517,27 @@ button { cursor: pointer; }
358517 }359518 360519 .preview-toolbar button:hover { background: var(--soft); }520+.preview-toolbar button[aria-pressed="true"] {521+ border-color: var(--accent-surface);522+ background: var(--accent-surface);523+ color: var(--accent-ink);524+}525+.preview-toolbar button:disabled { cursor: not-allowed; opacity: .42; }526+527+.preview-control-icon {528+ display: block;529+ width: 18px;530+ height: 18px;531+ fill: none;532+ stroke: currentColor;533+ stroke-width: 1.5;534+}535+536+.preview-theme-fill,537+.preview-transparency-fill {538+ fill: currentColor;539+ stroke: none;540+}361541 362542 .minimap {363543 position: absolute;@@ -385,8 +565,8 @@ button { cursor: pointer; }
385565 position: absolute;386566 min-width: 4px;387567 min-height: 4px;388- border: 2px solid var(--ink);389- background: color-mix(in srgb, var(--ink), transparent 82%);568+ border: 2px solid var(--accent-ink);569+ background: color-mix(in srgb, var(--accent-ink), transparent 82%);390570 pointer-events: none;391571 }392572 @@ -476,43 +656,6 @@ dialog::backdrop {
476656 .details-fields input { height: 42px; }477657 .details-fields textarea { min-height: 108px; line-height: 1.5; }478658 479-.presentation-fields {480- display: grid;481- grid-template-columns: minmax(0, 1fr) 120px auto;482- align-items: end;483- gap: 12px;484- margin: 2px 0 0;485- padding: 16px;486- border: 1px solid var(--line);487- border-radius: 12px;488-}489-.presentation-fields legend { padding: 0 6px; font-weight: 750; }490-.presentation-fields .appearance-field { grid-column: 1 / -1; }491-.presentation-fields .appearance-field small { display: block; margin-top: 7px; color: var(--muted); font-size: 12px; line-height: 1.4; }492-.presentation-fields label > span { display: block; margin-bottom: 7px; font-weight: 700; }493-.presentation-fields select,494-.presentation-fields input[type="number"] {495- width: 100%;496- height: 40px;497- padding: 8px 10px;498- border: 1px solid var(--line);499- border-radius: 9px;500- background: var(--page);501-}502-.number-field { position: relative; }503-.number-field input { padding-right: 34px ; }504-.number-field span { position: absolute; right: 10px; top: 10px; color: var(--muted); font-size: 13px; }505-.presentation-fields .checkbox-field {506- display: flex;507- align-items: center;508- gap: 8px;509- min-height: 40px;510- padding: 0 4px;511-}512-.presentation-fields .checkbox-field input { width: 18px; height: 18px; accent-color: var(--ink); }513-.presentation-fields .checkbox-field span { display: inline; margin: 0; }514-.presentation-fields :disabled { cursor: not-allowed; opacity: .5; }515-516659 .privacy-fields {517660 display: flex;518661 align-items: center;@@ -527,6 +670,7 @@ dialog::backdrop {
527670 .privacy-fields strong { display: block; margin-bottom: 4px; }528671 .privacy-fields p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }529672 .privacy-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 7px; justify-content: flex-end; }673+.share-privacy-fields { margin: 0 0 20px; }530674 .form-error { min-height: 20px; margin: 12px 0 -4px; color: var(--danger); font-size: 13px; }531675 532676 .dialog-actions {@@ -541,24 +685,30 @@ dialog::backdrop {
541685 542686 .toast {543687 position: fixed;544- top: 72px;545- left: 50%;688+ inset: auto 14px 14px auto;689+ right: 14px;690+ bottom: 14px;546691 z-index: 30;692+ margin: 0;693+ max-width: min(420px, calc(100vw - 28px));547694 padding: 9px 15px;548- border-radius: 999px;695+ border: 0;696+ border-radius: 11px;549697 background: var(--ink);550698 color: var(--page);551699 font-size: 13px;552700 font-weight: 740;701+ line-height: 1.4;702+ overflow-wrap: anywhere;553703 opacity: 0;554- transform: translate(-50%, -10px);704+ transform: translateY(10px);555705 transition: opacity 140ms ease, transform 140ms ease;556706 pointer-events: none;557707 }558708 559-.toast[data-visible="true"] { opacity: 1; transform: translate(-50%, 0); }709+.toast[data-visible="true"] { opacity: 1; transform: translateY(0); }560710 561-:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }711+:focus-visible { outline: 2px solid var(--accent-ink); outline-offset: 2px; }562712 563713 @media (max-width: 760px) {564714 .app-shell { grid-template-rows: 64px auto 42px minmax(0, 1fr); }@@ -574,6 +724,7 @@ dialog::backdrop {
574724 .header-action-icon { width: 18px; height: 18px; }575725 .draft-bar { align-items: stretch; flex-direction: column; gap: 7px; padding: 9px 14px; }576726 .draft-bar div { display: grid; grid-template-columns: 1fr 1fr; }727+ .draft-bar[data-anonymous="true"] div { display: flex; justify-content: flex-end; }577728 .mobile-tabs { grid-row: 3; }578729 .workspace { grid-row: 4; }579730 .type-picker { flex: 0 1 142px; min-width: 0; }@@ -598,6 +749,7 @@ dialog::backdrop {
598749 599750 .mobile-tabs button[aria-selected="true"] {600751 background: var(--soft);752+ box-shadow: inset 0 -2px 0 var(--accent-ink);601753 color: var(--ink);602754 }603755 @@ -606,8 +758,6 @@ dialog::backdrop {
606758 .workspace[data-mobile-panel="preview"] .editor-panel { display: none; }607759 .editor-panel, .preview-panel { width: 100%; height: 100%; border-right: 0; }608760 .minimap { width: 132px; height: 88px; }609- .presentation-fields { grid-template-columns: 1fr 100px; }610- .presentation-fields .checkbox-field { grid-column: 1 / -1; }611761 .privacy-fields { align-items: stretch; flex-direction: column; }612762 .privacy-actions { justify-content: flex-start; }613763 }@@ -0,0 +1,23 @@
1+import { normalizeMetadata, normalizePresentation } from './state.js'2+3+export function workingStateSnapshot(state, mode = 'open') {4+ return JSON.stringify({5+ mode,6+ type: state.type,7+ source: state.source,8+ options: state.options ?? {},9+ meta: normalizeMetadata(state.meta),10+ presentation: normalizePresentation(state.presentation),11+ })12+}13+14+export function workingStateIsDirty(state, {15+ mode = 'open',16+ keyEnvelopeDirty = false,17+ savedSnapshot = null,18+ defaultStateFor,19+} = {}) {20+ if (keyEnvelopeDirty) return true21+ const baseline = savedSnapshot ?? workingStateSnapshot(defaultStateFor(state.type), 'open')22+ return workingStateSnapshot(state, mode) !== baseline23+}@@ -0,0 +1,44 @@
1+import assert from 'node:assert/strict'2+import test from 'node:test'3+import {4+ detailsDocumentFor,5+ parseDetailsDocument,6+ serializeDetailsDocument,7+} from '../src/details-document.js'8+9+const state = {10+ meta: { title: 'Connections', description: 'A small D2 example.' },11+ presentation: { appearance: 'dark-transparent', background: '', padding: 0, frame: false },12+}13+14+test('round trips the schema-backed details document', () => {15+ assert.deepEqual(parseDetailsDocument(serializeDetailsDocument(state)), {16+ meta: state.meta,17+ presentation: state.presentation,18+ })19+ assert.deepEqual(detailsDocumentFor(state), {20+ title: 'Connections',21+ description: 'A small D2 example.',22+ presentation: state.presentation,23+ })24+})25+26+test('rejects malformed, incomplete, and extended details documents', () => {27+ assert.throws(() => parseDetailsDocument('{'), /valid JSON/)28+ assert.throws(() => parseDetailsDocument('{}'), /missing required property "title"/)29+ assert.throws(() => parseDetailsDocument(JSON.stringify({30+ ...detailsDocumentFor(state),31+ extra: true,32+ })), /unknown property "extra"/)33+})34+35+test('communicates metadata and presentation restrictions', () => {36+ assert.throws(() => parseDetailsDocument(JSON.stringify({37+ ...detailsDocumentFor(state),38+ title: 'x'.repeat(201),39+ })), /at most 200/)40+ assert.throws(() => parseDetailsDocument(JSON.stringify({41+ ...detailsDocumentFor(state),42+ presentation: { ...state.presentation, padding: 257 },43+ })), /invalid appearance/)44+})@@ -1,11 +1,17 @@
11 import assert from 'node:assert/strict'22 import test from 'node:test'33 import {4+ defaultExampleAppearance,45 exampleVariant,56 grayCode,67 refreshMatchingExampleMetadata,78 } from '../src/example-variants.js'89 10+test('uses adaptive normalized appearances for catalog examples', () => {11+ assert.equal(defaultExampleAppearance('d2'), 'auto-transparent')12+ assert.equal(defaultExampleAppearance('diagramsnet'), 'auto-framed')13+})14+915 test('changes one example axis at a time across the catalog', () => {1016 for (let index = 1; index < 30; index++) {1117 const changedBits = grayCode(index) ^ grayCode(index - 1)@@ -1,6 +1,11 @@
11 import assert from 'node:assert/strict'22 import test from 'node:test'3-import { PreviewController } from '../src/preview.js'3+import {4+ PreviewController,5+ previewBackgroundForAppearance,6+ previewThemeForAppearance,7+ previewViewKey,8+} from '../src/preview.js'49 510 const request = {611 source: 'blockdiag { A -> B; }',@@ -64,10 +69,13 @@ test('surfaces a GraphViz-family renderer failure', async () => {
6469 test('does not send a failed client renderer to an HTTP unit', async () => {6570 let unitCalls = 06671 const statuses = []72+ const errors = []6773 const controller = {6874 abortController: null,6975 stage: { style: { setProperty() {} } },7076 setStatus(message, state) { statuses.push({ message, state }) },77+ handleRenderError: PreviewController.prototype.handleRenderError,78+ onError(message) { errors.push(message) },7179 async renderThroughUnit() { unitCalls += 1 },7280 }7381 @@ -82,16 +90,21 @@ test('does not send a failed client renderer to an HTTP unit', async () => {
8290 })8391 8492 assert.equal(unitCalls, 0)85- assert.equal(statuses.at(-1).state, 'error')93+ assert.equal(statuses.at(-1).state, 'idle')94+ assert.equal(errors.length, 1)95+ assert.equal(typeof errors[0], 'string')8696 })8797 8898 test('does not send a failed diagrams.net renderer to an HTTP unit', async () => {8999 let unitCalls = 090100 const statuses = []101+ const errors = []91102 const controller = {92103 abortController: null,93104 stage: { style: { setProperty() {} } },94105 setStatus(message, state) { statuses.push({ message, state }) },106+ handleRenderError: PreviewController.prototype.handleRenderError,107+ onError(message) { errors.push(message) },95108 async renderThroughUnit() { unitCalls += 1 },96109 }97110 @@ -106,10 +119,12 @@ test('does not send a failed diagrams.net renderer to an HTTP unit', async () =>
106119 })107120 108121 assert.equal(unitCalls, 0)109- assert.equal(statuses.at(-1).state, 'error')122+ assert.equal(statuses.at(-1).state, 'idle')123+ assert.equal(errors.length, 1)124+ assert.equal(typeof errors[0], 'string')110125 })111126 112-test('keeps render status text to a single state word', () => {127+test('keeps render details out of the status hint', () => {113128 const attributes = new Map()114129 const status = {115130 dataset: {},@@ -117,10 +132,106 @@ test('keeps render status text to a single state word', () => {
117132 }118133 const controller = { status }119134 120- PreviewController.prototype.setStatus.call(controller, 'The renderer rejected this source.', 'error')135+ PreviewController.prototype.setStatus.call(controller, 'The renderer is still working.', 'loading')121136 122- assert.equal(status.textContent, 'Error')123- assert.equal(status.title, 'The renderer rejected this source.')124- assert.equal(status.dataset.state, 'error')125- assert.equal(attributes.get('aria-label'), 'Error: The renderer rejected this source.')137+ assert.equal(status.textContent, 'Rendering')138+ assert.equal(status.title, '')139+ assert.equal(status.dataset.state, 'loading')140+ assert.equal(attributes.get('aria-label'), 'Rendering')141+})142+143+test('routes render errors to the error callback and resets the status hint', () => {144+ const statuses = []145+ const errors = []146+ const controller = {147+ setStatus(message, state) { statuses.push({ message, state }) },148+ onError(message) { errors.push(message) },149+ }150+151+ PreviewController.prototype.handleRenderError.call(controller, 'The renderer rejected this source.')152+153+ assert.deepEqual(statuses, [{ message: 'Ready', state: 'idle' }])154+ assert.deepEqual(errors, ['The renderer rejected this source.'])155+})156+157+test('matches the preview substrate to explicit and automatic SVG appearances', () => {158+ assert.equal(previewThemeForAppearance('light-transparent'), 'light')159+ assert.equal(previewThemeForAppearance('dark-framed'), 'dark')160+ assert.equal(previewThemeForAppearance('auto-transparent'), 'auto')161+ assert.equal(previewBackgroundForAppearance('light-transparent', '#fff'), 'var(--preview-light-bg)')162+ assert.equal(previewBackgroundForAppearance('dark-transparent', '#fff'), 'var(--preview-dark-bg)')163+ assert.equal(previewBackgroundForAppearance('auto-framed', '#fff'), 'var(--preview-auto-bg)')164+ assert.equal(previewBackgroundForAppearance('raw', '#f4f4f4'), '#f4f4f4')165+})166+167+test('rematerializes automatic appearances when the preview system theme changes', () => {168+ const displays = []169+ const controller = {170+ autoTheme: 'light',171+ latestCanonicalSvg: '<svg/>',172+ latestPresentation: { appearance: 'auto-transparent' },173+ latestAppearance: 'auto-transparent',174+ displayCanonical(...args) { displays.push(args) },175+ }176+177+ PreviewController.prototype.setAutoTheme.call(controller, 'dark')178+179+ assert.equal(controller.autoTheme, 'dark')180+ assert.deepEqual(displays, [[controller.latestCanonicalSvg, controller.latestPresentation, controller.latestAppearance]])181+})182+183+test('keeps one viewport identity across presentation-only changes', () => {184+ const diagram = { type: 'd2', source: 'a -> b', options: {} }185+ assert.equal(186+ previewViewKey({ ...diagram, presentation: { appearance: 'light-transparent' } }),187+ previewViewKey({ ...diagram, presentation: { appearance: 'dark-framed' } }),188+ )189+ assert.notEqual(190+ previewViewKey(diagram),191+ previewViewKey({ ...diagram, source: 'a -> c' }),192+ )193+})194+195+test('preserves the current transform when a presentation-only image loads', () => {196+ const calls = []197+ const controller = {198+ image: { src: 'blob:current', naturalWidth: 120, naturalHeight: 100 },199+ activeImageUrl: 'blob:current',200+ imageFallbackUrl: 'fallback',201+ fitOnNextLoad: false,202+ previousImageSize: { width: 100, height: 80 },203+ scale: 2,204+ x: 10,205+ y: 20,206+ setStatus() {},207+ fit() { calls.push('fit') },208+ updateTransform() { calls.push('preserve') },209+ }210+211+ PreviewController.prototype.handleImageLoad.call(controller)212+213+ assert.deepEqual(calls, ['preserve'])214+ assert.equal(controller.imageFallbackUrl, null)215+ assert.equal(controller.x, -10)216+ assert.equal(controller.y, 0)217+ assert.equal(controller.previousImageSize, null)218+})219+220+test('fits once when a different diagram identity loads', () => {221+ const calls = []222+ const controller = {223+ image: { src: 'blob:current', naturalWidth: 120, naturalHeight: 100 },224+ activeImageUrl: 'blob:current',225+ imageFallbackUrl: null,226+ fitOnNextLoad: true,227+ previousImageSize: { width: 100, height: 80 },228+ setStatus() {},229+ fit() { calls.push('fit') },230+ updateTransform() { calls.push('preserve') },231+ }232+233+ PreviewController.prototype.handleImageLoad.call(controller)234+235+ assert.deepEqual(calls, ['fit'])236+ assert.equal(controller.fitOnNextLoad, false)126237 })@@ -0,0 +1,30 @@
1+import assert from 'node:assert/strict'2+import test from 'node:test'3+import { workingStateIsDirty, workingStateSnapshot } from '../src/working-state.js'4+5+const exampleStateFor = type => ({6+ type,7+ source: `${type} example`,8+ options: {},9+ meta: { title: `${type} title`, description: '' },10+ presentation: { appearance: 'auto-transparent' },11+})12+13+test('treats a pristine anonymous example as a clean baseline', () => {14+ assert.equal(workingStateIsDirty(exampleStateFor('d2'), { defaultStateFor: exampleStateFor }), false)15+ assert.equal(workingStateIsDirty({ ...exampleStateFor('d2'), source: 'edited' }, { defaultStateFor: exampleStateFor }), true)16+})17+18+test('compares aliases to their saved snapshot instead of the catalog example', () => {19+ const saved = { ...exampleStateFor('d2'), source: 'saved source' }20+ const options = { defaultStateFor: exampleStateFor, savedSnapshot: workingStateSnapshot(saved) }21+ assert.equal(workingStateIsDirty(saved, options), false)22+ assert.equal(workingStateIsDirty({ ...saved, source: 'local edit' }, options), true)23+})24+25+test('keeps encryption envelope changes dirty independently of content', () => {26+ assert.equal(workingStateIsDirty(exampleStateFor('d2'), {27+ defaultStateFor: exampleStateFor,28+ keyEnvelopeDirty: true,29+ }), true)30+})