feat: introduce local-first SVG workbench

This commit is contained in:
2026-08-02 16:31:49 +02:00
commit 39d9802daa
97 changed files with 20702 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Accessibility audit
The audit is evidence-based and intentionally narrower than a browser or
assistive-technology accessible-name computation.
Implemented checks include:
- root accessible name evidence from direct `title`, `aria-label` or a uniquely
resolved `aria-labelledby` target;
- missing root description (`desc`/`aria-describedby` evidence);
- missing, duplicate or ambiguous ARIA ID references;
- focusable graphical/link elements without local naming evidence;
- selected fill/stroke contrast evidence where simple literal colours can be
evaluated;
- reference diagnostics that can invalidate accessible relationships.
Findings identify severity, rule, evidence, limitation and a suggested fix.
Safe automatic fixes insert a root `title` or `desc` through an exact source
patch, preserve surrounding source and are undoable.
Limitations: the audit does not compute CSS cascade, rendered visibility,
language, reading order, browser/AT mappings, text contrast over arbitrary
backgrounds, keyboard interaction quality or author intent. It does not certify
WCAG conformance. Generated placeholder title/description text must be replaced
with an author-appropriate description and tested with target browsers and
assistive technology.
+28
View File
@@ -0,0 +1,28 @@
# Animation
Native SVG animation elements are discovered/countable in source but removed
from every rendered editing projection, so imported SMIL and event-triggered
animation cannot execute automatically. Imported scripts are never supported.
Version 0.1.0 implements a deliberately small app-owned animation slice:
- add an opacity animation to the selected element;
- enable/disable/remove definitions;
- preview generated CSS only in the opaque-origin canvas;
- persist definitions in an explicit `.svgtools.json` project;
- apply validated CSS plus stable target IDs to source in one transaction.
One shared validator is used for project import, preview and Apply. It bounds
definition/keyframe counts and strings, requires finite ordered offsets and
bounded timing/iteration values, allowlists property/kind/direction/fill mode,
accepts only safe easing grammar and parses every CSS value with css-tree. URLs,
resource functions, structural CSS/XML characters and parser raw nodes are
rejected even for disabled definitions. Selectors and keyframe identifiers are
escaped/generated by the app.
The preview has play/pause as an enable toggle but no timeline, seek or native
SMIL editing. Duration/keyframes are not yet exposed as a general UI. Transform,
colour, motion-path and compatible path-morph authoring, animation discovery
panels and conversion between CSS/SMIL remain future milestones. Applying CSS
changes the SVG source intentionally and may affect downstream consumers that
do not support the same CSS animation features.
+53
View File
@@ -0,0 +1,53 @@
# Architecture
## Boundaries
SVG Tools is a static Vite application. React owns controls and projections,
not the document. Canonical SVG source is the only persistent document
representation. Normal sessions stay in memory; explicit downloads are the
only persistence boundary.
```text
canonical source
├─ ranged XML scan + DOM parse → semantic document → tree/inspectors
├─ security policy + DOMPurify → editing projection → opaque iframe
└─ source patches/transactions ← visual commands and explicit workflows
```
The last valid semantic document and projection remain available while current
source is invalid. Revision checks reject stale visual, worker or dialog results.
## Representations
1. **Source document:** exact user text, including comments, quotes and
whitespace.
2. **Semantic document:** SVG elements, attributes, source ranges, in-memory
keys, relationships, preferences, metrics and diagnostics.
3. **Editing projection:** cloned, sanitized SVG with temporary mapping
attributes. It is never exported as current SVG.
4. **Preview:** the projection in a sandboxed opaque-origin iframe, plus a
parent-owned overlay for bounds and path controls.
Imported source is never rendered through React or inserted into the main DOM.
The iframe controller is a fixed packaged asset, accepts only its parent and a
random per-instance channel, and removes its script element after startup.
## Commands and workers
Every accepted edit is a transaction containing exact before/after source,
patches, selection and revision. Undo validates the current source before
restoring exact text. Fast consecutive source/key nudge edits may merge under a
bounded merge key.
SVGO executes in a dedicated module worker. Jobs have IDs, timeout,
supersession, AbortSignal cancellation and immediate deterministic rejection.
Parsing currently uses bounded synchronous browser XML facilities because its
`XMLDocument` projection is not transferable; large inputs are throttled and
hard-limited. Rasterization uses a sanitized Blob URL and Canvas 2D.
## Release boundary
`base: './'` keeps every asset relocatable. The independent ZIP is built and
checked here. Toolbox Portal consumes the immutable ZIP and checksum through a
release lock; it never compiles SVG Tools source. AppShell reads optional
Toolbox context but standalone operation is the default fallback.
+45
View File
@@ -0,0 +1,45 @@
# Document model
## Source ranges
The lexical scan records exact half-open offsets for each opening/full element,
name and quoted attribute. CodeMirror's Lezer XML parser supplies ranged syntax
errors; a separate stack scan rejects structures browser DOM parsers might
repair. The DOM parse supplies namespace and decoded semantic values. Element
order must match the lexical tokens or parsing fails with a source-map mismatch.
DOCTYPE text remains in canonical source but is removed before DOM parsing.
Entity declarations are rejected for the semantic model. External entities are
never resolved.
## Stable keys
A unique explicit SVG `id` becomes `id:<id>`. Otherwise a key hashes structural
path, source offset, local name and optional ID; collision suffixes are
in-memory. Reparsing retains explicit-ID identity and best-effort structural
identity. Keys appear only as temporary `data-svg-tools-node` attributes in the
editing projection and are stripped from exports.
## Semantic nodes
Nodes retain namespace/name, parent and children, depth, ID/classes, decoded
attributes, exact attribute ranges, render status and leaf text. The document
also records indentation/newline/quote preference, ordered keys, diagnostics
and bounded metrics.
Unknown well-formed source remains canonical even when there is no inspector.
A targeted command patches only its attribute/tag range. Whole-source
serialization occurs only for explicit format, sanitize or optimize workflows,
each with a source diff.
## Invalid state
Every source update gets a monotonically increasing revision. If current source
is invalid, diagnostics refer to that text while the canvas/tree use the last
valid snapshot and all visual controls are disabled. Once the same current
revision parses and sanitizes successfully it atomically replaces the snapshot.
Resource limits can make otherwise well-formed source invalid for this app.
Limits cover bytes, structure, path/CSS/reference/animation/filter counts,
embedded data and total text. This prevents unbounded model construction but is
not a promise that every file below a limit is inexpensive on every device.
+34
View File
@@ -0,0 +1,34 @@
# Optimization
SVG Tools ships SVGO 4.0.2 (`gitHead`
`b2309cf541aee11634eb653157b0ff86ab326e98`) inside a module worker. The exact
version and active plugin list are displayed with every result.
## Profiles
- **Conservative:** cleanup and safe numeric/style normalization while retaining
IDs, metadata, viewBox, dimensions and accessibility content.
- **Balanced:** additional structural cleanup with protected identifiers and
accessibility-sensitive content.
- **Aggressive:** explicitly lossy/structural choices for users who accept the
disclosed risk.
Optional plugins are selected explicitly in the dialog; plugin names are mapped
to an application allowlist rather than passed through from imported data. See
`src/optimization/profiles.ts` for the authoritative ordered lists.
The worker returns source, exact before/after UTF-8 byte counts, version and
plugins. The main thread rejects stale job IDs. Closing/canceling, a superseding
request or AbortSignal immediately rejects the old promise, removes listeners,
clears its timer and terminates its worker. A 30-second safety timeout is the
final boundary.
Optimization never mutates source automatically. A result remains a candidate
with size summary, source diff and side-by-side isolated visual comparison until
Apply. Applying checks the source revision and produces one undoable transaction.
Even conservative SVGO passes can change insignificant lexical form and browser
edge behavior. Aggressive plugins can change IDs, metadata, editorability or
rendering. Visual comparison cannot prove semantic equivalence, especially for
fonts, filters, animation and external consumer behavior. Keep original source
and use project/domain fixtures for important artwork.
+42
View File
@@ -0,0 +1,42 @@
# Path editor
The project-authored path core accepts every SVG path command: `M/m`, `L/l`,
`H/h`, `V/v`, `C/c`, `S/s`, `Q/q`, `T/t`, `A/a` and `Z/z`, including repeated
parameter groups, implicit lines after move, packed arc flags, exponents,
relative forms and multiple subpaths. It rejects non-finite values, malformed
arity/flags and paths beyond the command limit.
Segments resolve to absolute geometry while retaining source command/form and
fragment offsets for the command table. Serialization intentionally normalizes
edited geometry; no editor metadata is emitted.
## Controls
- Every segment endpoint is an anchor.
- Cubics show both controls. `S` reflects the previous cubic control and marks
the resulting handle as derived/dashed until edited.
- Quadratics show their one control. `T` reflects the previous quadratic
control and marks it derived.
- Arcs expose endpoint, radii and derived center controls and show rotation,
large-arc and sweep flags in the command panel.
- Handle radii and strokes use screen-oriented overlay styling so they remain
usable across zoom levels.
The full selected-element/ancestor transform chain maps local path geometry into
root space. Pointer coordinates use its inverse, including an active preview
matrix; non-invertible chains disable handles. Dragging commits one transaction
on release; arrow keys use 1, Shift+10 or Alt+0.1 units and merge safely.
Segment splitting uses the mathematical midpoint: linear interpolation for
lines, de Casteljau for quadratic/cubic curves, and arc subdivision preserving
the ellipse. Path reversal reverses every supported segment/subpath and adjusts
controls/sweep where required.
## Current limits
The 0.1.0 UI supplies drag/nudge, split at 50%, reverse and a read-only numeric
command table. It does not yet supply node modes, multi-selection, deletion,
conversion, open/close, join/break/combine, subpath-start changes, arbitrary
split parameter, pen creation, snapping, simplification or boolean operations.
Visual editing expands shorthand/relative commands to normalized absolute data;
the dialog/help discloses that source-form change.
+79
View File
@@ -0,0 +1,79 @@
# Security model
SVG is active content. Threats include script/event execution, navigation,
external fetches and tracking, CSS resource resolution, `foreignObject`, native
animation triggers, namespace confusion, entity/DOCTYPE processing, parser
differentials, oversized compressed/data content and algorithmic complexity.
## Source and projection
Canonical source is inert text and remains available even when unsafe. A
separate projection is built from a cloned semantic document. DOMPurify's SVG
profiles are followed by an application policy that:
- removes `script`, `foreignObject`, `animate`, `animateMotion`,
`animateTransform`, `set`, foreign namespaces and all `on*` attributes;
- disables links/navigation and executable or external URL attributes;
- allows only unique local fragment references and bounded base64 raster image
data (`png`, `jpeg`, `gif`, `webp`, `avif`);
- parses style declarations/stylesheets with css-tree and rejects `@import`,
unsafe legacy properties, parse failures, non-local URLs, `expression()` and
resource-producing functions such as `image-set()` and `paint()`;
- reports findings with source ranges wherever a semantic node/range exists.
DOCTYPE is excluded before DOM parsing; entity declarations invalidate the
semantic projection. Sanitized export removes temporary mapping attributes.
Sanitize is never applied to canonical source without preview and acceptance.
## Isolated preview
The projection is an iframe `srcdoc` with `sandbox="allow-scripts"` and no
`allow-same-origin`, giving it an opaque origin. Its child CSP is:
```text
default-src 'none'; connect-src 'none'; object-src 'none'; frame-src 'none';
base-uri 'none'; form-action 'none'; img-src data: blob:;
style-src 'unsafe-inline'; script-src <controller-origin>
```
The sole script is the packaged `canvas-frame-controller.js`. It is loaded with
anonymous CORS, accepts messages only from `parent` with a random channel,
validates message fields and removes its script element. The parent validates
both `event.source` and channel.
Because a sandbox without same-origin treats the script request as cross-origin,
serve this one app-relative asset with:
```text
Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin
```
Keep `Cross-Origin-Resource-Policy: same-origin` for other files. Use an Nginx
`map`/header value, not a nested `location` that would drop inherited security
headers. Toolbox Portal 0.10.0 implements and tests this exception.
## Limits and tests
Hard limits are defined in `src/app/limits.ts`. SVGZ uses streaming
decompression and stops once output crosses the source limit. Workers are
revisioned, timed out and cancelable. Tree/diagnostic rendering is capped.
Project-authored tests cover script, handlers, links, external image/use/filter,
CSS imports/URLs/parser failures/resource functions, native animation,
namespaces, duplicate IDs/cycles, malformed entities/XML, depth/attribute/path
and data limits. Browser tests assert no script callback and no request to the
hostile domain.
## Limitations and reporting
This policy protects this application's projections and exports; it is not a
general guarantee for arbitrary downstream embedding. Browser SVG/CSS parsers
and DOMPurify remain dependency/security boundaries. Bounded data URLs may still
decode expensive images within the raster-pixel limits. CSP header regressions
can break the fixed controller even when the content remains inert.
Report vulnerabilities privately through the repository owner/contact before
opening a public issue when disclosure could expose users. Include the SVG,
browser, deployment headers and observed network/execution behavior without
sensitive user files.
+41
View File
@@ -0,0 +1,41 @@
# Transform model
SVG transform functions are parsed without changing order: `matrix`,
`translate`, `scale`, `rotate` (with optional center), `skewX` and `skewY`.
The inspector shows each selected/ancestor source transform, accumulated matrix,
determinant/invertibility diagnostics and the candidate matrix.
A draft combines translate, origin, rotate, scale and skew. It affects only the
iframe/overlay until Apply. The proposed source and rendering are shown before
one revision-checked transaction; undo restores the exact pre-apply text.
## Bake support
| Element | Supported result |
| --------------------- | ----------------------------------------------------------------------------------------------------------- |
| `path` | all segment points transformed; arcs retained for safe conformal cases or converted/disclosed when required |
| `line` | transformed endpoints, remains `line` |
| `polyline`, `polygon` | transformed point list, same element |
| `rect` | remains `rect` for axis-aligned safe cases; otherwise candidate path |
| `circle` | remains circle under conformal scale; otherwise explicit ellipse/path as required |
| `ellipse` | remains ellipse for safe axis-aligned cases; otherwise path |
Numbers remain full precision internally and are serialized with stable bounded
precision only at the explicit operation boundary. Invalid/non-finite geometry,
negative radii and unsupported element types fail before patch creation.
## Policies and limits
- Non-conformal transforms with a visible stroke produce a warning; stroke
width/vector-effect is not silently rewritten.
- General arc conversion is disclosed in the preview.
- Shared gradients, patterns, markers, filters, masks and clips are indexed but
not cloned or remapped during bake.
- Text transforms remain attributes; text-to-path is not implemented.
- Group flattening and partial multi-child baking are not implemented.
- Consolidation is available through an appended matrix candidate; a full
editable transform-list UI/decomposition is future work.
Browser rendering, particularly text and filters, is the visual oracle; the
unit suite checks matrix and geometry invariants but does not claim pixel-perfect
equivalence for every renderer.