Add s.svg() schema with themeable color variables - #480
Conversation
Adds a new schema that stores an svg as a json node tree, so custom icons
can be content rather than an opaque binary. Until now an svg could only be
an s.image() / s.file() reference to a blob: not recolorable, not validatable
against the design system, not diffable.
Colors are declared as *variables* rather than baked hexes:
s.svg({
width: 24,
height: 24,
variables: { brand: "#0055ff", line: "currentColor" },
})
The color on a variable is an example. It is what the editor previews, what
svgVarsCss() writes into the stylesheet, and what a pasted literal color is
matched against on import. What actually renders resolves from
--val-svg-<name>, so one icon supports currentColor, dark mode and per-usage
overrides:
<style>{svgVarsCss(iconSchema)}</style>
<ValSvg src={icons.bell} size={32} />
<ValSvg src={icons.bell} size={32} vars={{ brand: "var(--danger)" }} />
[data-theme="dark"] { --val-svg-brand: #6699ff }
How permissive to be about raw colors is up to the schema: `literals` is
"forbid" (the default), "allow", or an allowlist. This is enforced at the type
level as well as by the validator, so a raw hex in a .val.ts is a compile
error, not only a validation error.
Notable decisions:
- Svg sources are excluded from stega encoding entirely. Every string in an
svg (d, viewBox, points, transform) is machine parsed, so injecting
invisible characters would corrupt the icon. The source path is attached as
an ordinary serializable field (SVG_VAL_PATH) instead, which ValSvg turns
into data-val-path - a symbol would not survive RSC serialization.
- ValSvg builds React elements tag by tag; there is no innerHTML anywhere.
Safety is therefore entirely the allowlist, and it is a strict per-tag
allowlist of exact attribute names rather than an on* denylist: React
renders unknown attributes on host elements verbatim, and onload does fire
on svg elements. script, style, foreignObject, a, use, image, animation and
filter elements are rejected, as are style/id/class/href/xlink/data-*.
d, points, transform and stroke-dasharray are the only free-form strings
left, and each is regex constrained and length capped.
- The svg parser is hand rolled and dependency free. @valbuild/shared ships
into every user's server bundle, and svg-as-xml is a small grammar. Entity
declarations and doctypes with an internal subset are rejected outright
(XXE / billion laughs). Parser output is filtered through the allowlist
before anything else sees it - the parser is not a security boundary.
- Import matches literal colors onto variables by exact normalized value, or
by a variable's declared match aliases. Nothing is snapped to a nearby
variable unless that variable opted in with `tolerance`; anything left over
is reported so the editor can ask. Quietly rewriting a brand color is worse
than asking about it.
- No new ValidationFix code: because the palette lives only in the schema and
is never mirrored into the source, there is nothing that can drift and
nothing to repair.
Gradients are deliberately out of scope for now. Adding them later needs a
third attribute value kind for url(#...) plus per-instance id namespacing, and
is additive rather than breaking.
Also adds the editor field (paste markup or drop a .svg, with a prompt for
colors that are not in the palette), storybook stories for it, and an icons
module in examples/next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Wbr5HZDGfdRzAnZ5aH7n
Storybook and example-app screenshots referenced from the pull request body. Isolated in its own commit so it can be dropped before merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Wbr5HZDGfdRzAnZ5aH7n
🦋 Changeset detectedLatest commit: 3e313bb The changes in this PR will be included in the next version bump. This PR includes changesets to release 8 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Follow-up on review.
ValSvg's `vars` now works exactly like ValRichText's `theme`: optional, but
exhaustive if given. Every variable the schema declares must be mapped to a
color, so adding one to `s.svg({ variables })` is a compile error at every call
site until someone revisits it - which is the point. A value is any css color,
`null` means "leave it to css" and emits var(--val-svg-<name>, currentColor).
Reworked the editor field around how it is actually used. Icons are read far
more often than they are replaced, so the tile *is* the control and it is the
only thing on screen by default: drop an svg on it, paste markup into it, or
click it to pick a file. The textarea and the button row are gone, the markup
is behind an accordion, and the mapping controls appear only when an import
needs a decision. While mapping, the tile previews the icon being imported in
its original colors, so you can see what you dropped and watch it move onto the
palette as you pick - it is not committed until every color has somewhere to go.
Docs: a Svg section in packages/next/README.md following the RichText section's
shape (schema, initializing, rendering, the vars note, editing, the type, full
custom), and a "Working with Svg" section in .agent/rules.md - shared by
CLAUDE.md, the cursor rules and the copilot instructions, which all symlink to
it - covering the invariants that are easy to break: never stega encode an svg,
the allowlist is the whole security boundary, the parser is not, vars is
exhaustive on purpose, and the palette lives only in the schema.
Also adds ValSvg render tests (variable resolution, the css fallback, size
precedence, title/aria, data-val-path) plus a type-level check that vars stays
exhaustive. That needed @types/react-dom in packages/react; it resolves to the
18.2.17 already used by next/ui/examples, so there is still exactly one copy of
@types/react and the duplicate-copy JSX hazard is not reintroduced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Wbr5HZDGfdRzAnZ5aH7n
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Wbr5HZDGfdRzAnZ5aH7n
There was a problem hiding this comment.
Pull request overview
This PR introduces first-class s.svg() support across Val’s core/schema system, shared SVG parsing/serialization utilities, React/Next rendering (ValSvg), and the editor UI field (SvgField), enabling themeable, schema-validated SVG icons stored as a JSON node tree instead of opaque files.
Changes:
- Add
SvgSource/SvgSchemato core type unions, schema serialization/deserialization, validation, and selector mapping. - Add shared SVG XML parsing + color normalization +
svgToStringround-tripping utilities with tests. - Add UI editor support (field, previews, search/index behaviors) and React/Next rendering APIs + docs + example usage.
Reviewed changes
Copilot reviewed 57 out of 69 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Locks new dev dependency resolution (@types/react-dom) and updated platform metadata entries. |
| packages/ui/spa/ValSyncEngine.ts | Treats svg as non-interdependent for sync behavior. |
| packages/ui/spa/utils/traverseSchemaSource.ts | Adds svg leaf handling for schema/source traversal. |
| packages/ui/spa/utils/schemaTypesOfPath.ts | Allows traversal into svg internal structure for path typing. |
| packages/ui/spa/utils/getDependentModuleFiles.ts | Excludes svg from dependent module file enumeration. |
| packages/ui/spa/utils/findRequiredRemoteFiles.ts | Declares svg as never requiring remote files. |
| packages/ui/spa/search/createSearchIndex.ts | Indexes svg fields by path only (no tokenizing icon internals). |
| packages/ui/spa/resolvePatchPath.ts | Treats svg similarly to richtext for patch path resolution. |
| packages/ui/spa/components/ValProvider.tsx | Adds svg to shallow source typing + mapping logic. |
| packages/ui/spa/components/ValFieldProvider.tsx | Adds svg to shallow source typing + mapping logic. |
| packages/ui/spa/components/Preview.tsx | Adds svg preview rendering entrypoint. |
| packages/ui/spa/components/NodeIcon.tsx | Adds an icon for svg nodes in the schema tree UI. |
| packages/ui/spa/components/getReferencedFiles.ts | Excludes svg from referenced-file scanning. |
| packages/ui/spa/components/getKeysOf.ts | Excludes svg from key discovery logic. |
| packages/ui/spa/components/fields/SvgField.tsx | New SVG editor field (drop/paste/import mapping) + preview renderer. |
| packages/ui/spa/components/fields/SvgField.stories.tsx | Storybook stories for svg field states and rendering. |
| packages/ui/spa/components/fields/emptyOf.ts | Defines default empty svg JSON shape. |
| packages/ui/spa/components/AnyField.tsx | Wires SvgField into the generic field renderer. |
| packages/shared/src/internal/zod/SerializedSchema.ts | Extends shared Zod schema validation to include serialized svg schema/options. |
| packages/shared/src/internal/svg/xml.ts | Adds minimal XML reader/encoder helpers used by svg parsing. |
| packages/shared/src/internal/svg/svgToString.ts | Adds svg serialization + JSON patch-friendly conversion utilities. |
| packages/shared/src/internal/svg/parseSvg.ts | Adds svg markup parser with allowlist filtering and variable/literal color mapping. |
| packages/shared/src/internal/svg/parseSvg.test.ts | Tests parsing, allowlist behavior, rejection cases, and round-tripping. |
| packages/shared/src/internal/svg/index.ts | Exposes shared svg internal API surface. |
| packages/shared/src/internal/svg/colors.ts | Adds color parsing/normalization and tolerance matching utilities. |
| packages/shared/src/internal/index.ts | Re-exports svg internal utilities from shared internal barrel. |
| packages/server/src/hasRemoteFileSchema.ts | Marks svg as non-remote-file schema for server logic. |
| packages/react/src/stega/stegaEncode.ts | Exempts svg from stega encoding and attaches _valPath instead. |
| packages/react/src/stega/stegaEncode.test.ts | Adds tests ensuring svg strings remain byte-identical and path tagging works. |
| packages/react/src/stega/index.ts | Exposes svg stega type surface. |
| packages/react/src/internal/ValSvg.tsx | Adds ValSvg renderer with variable resolution and accessibility behavior. |
| packages/react/src/internal/ValSvg.test.tsx | Tests variable resolution, sizing precedence, aria/title behavior, and data-val-path. |
| packages/react/src/internal/index.ts | Exports ValSvg + types from react internal entrypoint. |
| packages/react/package.json | Adds @types/react-dom dev dependency for new render-to-string tests. |
| packages/next/src/external_exempt_from_val_quickjs.ts | Exposes svg core + react exports through Next’s QuickJS exemption surface. |
| packages/next/README.md | Documents s.svg() usage, rendering (ValSvg), vars contract, and editor behavior. |
| packages/core/src/source/svg.ts | Introduces the Svg source/types model and _valPath constant. |
| packages/core/src/source/index.ts | Adds svg to the core Source union. |
| packages/core/src/selector/svg.ts | Adds SvgSelector type for source-to-selector parity. |
| packages/core/src/selector/index.ts | Adds svg to selector conditional mapping and SelectorSource union. |
| packages/core/src/schema/validation.test.ts | Adds svg validation cases into generic schema validation suite. |
| packages/core/src/schema/svg/allowlist.ts | Defines svg tag/attr allowlist and constraints used as security boundary. |
| packages/core/src/schema/svg.ts | Implements SvgSchema, validation rules, and svgVarsCss helper. |
| packages/core/src/schema/svg.test.ts | Tests schema serialization, validation, builder methods, and svgVarsCss. |
| packages/core/src/schema/readonly.test.ts | Ensures svg schema respects .readonly() serialization. |
| packages/core/src/schema/index.ts | Adds svg schema to the serialized schema union / assert typing. |
| packages/core/src/schema/hidden.test.ts | Ensures svg schema respects .hidden() serialization. |
| packages/core/src/schema/deserialize.ts | Adds svg support to schema deserialization. |
| packages/core/src/schema/describe.test.ts | Ensures svg schema .describe() survives serialize/deserialize round-trip. |
| packages/core/src/module.ts | Allows path resolution to traverse into svg internals while keeping schema pinned. |
| packages/core/src/initSchema.ts | Exposes s.svg() in the schema initializer surface and docs. |
| packages/core/src/index.ts | Exports svg types, schema, and allowlist helpers from core public API. |
| examples/next/val.modules.ts | Registers the new icons.val module in the example app. |
| examples/next/content/icons.val.ts | Adds example icon schema/content demonstrating variables and usage. |
| examples/next/app/page.tsx | Demonstrates ValSvg rendering variants in the example app. |
| .github/pr-assets/svg-schema/README.md | Adds PR review asset notes for screenshots. |
| .agent/rules.md | Documents svg invariants and adds svg to the type hierarchy docs. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- `decodeXmlEntities` called `String.fromCodePoint` on any FINITE code point.
`�` and `�` are finite and out of range, so parsing svg
markup that contains one threw a RangeError instead of reporting the markup.
Out-of-range entities are now left as written. The `#X` (uppercase) branch was
dead — the regex only matches lowercase `x`, which is also the only spelling
the XML CharRef production allows — so it is gone and its case is covered.
- `SerializedSvgSchema` used `SvgOptions.optional() as any`. The mismatch was
one field: `aspectRatio` is `number | \`${number}:${number}\`` in core, and
`z.string()` parses to `string`. Narrowing that field with a `z.custom` lets
the options object be typed as `z.ZodType<SvgOptions>` and the `as any` go,
so the other seven fields are checked against core again.
- The two svg field-provider messages reported an array and a `null` as
"object", since `typeof` cannot tell them apart. They now report the runtime
kind. The neighbouring pre-existing messages are left alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFs5x1hQ9MiaDn4aQyANTp
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 59 out of 71 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
packages/ui/spa/components/fields/SvgField.tsx:210
SvgColorMapperusesstyle={{ background: color.raw }}for unmatched colors.color.rawcomes directly from imported SVG markup (and can be an arbitrary string such asurl(...)when the color is unparseable), so this can unintentionally apply non-color CSS values / trigger external loads in the editor. UsebackgroundColorand only set it for parsed/normalized colors.
…ors-tmi9jk Six conflicts, all from #453 landing on main. Source / Selector: both sides add a source type to the same unions - main's JsonSource and this branch's SvgSource. Both are kept, and the Selector<T> chain gains a rung for each. search: main extracted the index building out of search.worker.ts into searchIndex.ts and deleted createSearchIndex.ts. This branch's only change there was to index an svg by its path (nothing inside an icon is searchable text), which is carried over to searchIndex.ts. In the new shape `index.add` already prefixes the cleaned path and skips an entry with no searchText at all, so the svg case sets one - "svg", which doubles as a way to list every icon, mirroring richtext's fallback label. ValFieldProvider: adjacent imports, both kept. Two follow-ons the textual merge could not see: - Schema gained an abstract executeCustomValidateAt, so SvgSchema has to implement it (same shape as DateSchema's). - jsonValuesLoadRequirements has a never-guarded switch over every schema type; an svg holds no reference to another module, so it joins the ones that return false. Without it the guard would fall to its conservative default and demand a full entry load for any record containing an icon.
…rthand `color.raw` in SvgColorMapper is arbitrary text lifted out of imported svg markup - anything that appeared as a `fill` or `stroke` and did not parse as a color. `background` is a shorthand that also accepts `url(...)` and gradients, so importing a hostile icon rendered an external request from inside the editor. The swatch now paints `backgroundColor` from `normalized`, which is always a plain `#rrggbb[aa]` out of our own parser; a color that did not parse gets an empty swatch, with its text right beside it. The variable swatch in the dropdown gets the same treatment - that value is the schema author's own, so it was never the same exposure, but there is no reason for the shorthand there either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFs5x1hQ9MiaDn4aQyANTp
It described behaviour this version does not have: `id` is not an allowed attribute at all, so there is nothing to prefix and no `url(#…)` reference to rewrite. Gradients, masks and clip paths are out of scope for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFs5x1hQ9MiaDn4aQyANTp
…ors-tmi9jk Five conflicts, all where #477's s.color() landed on main in the same places this branch adds s.svg(). Every one is two independent additions at one insertion point, so both sides are kept: - jsonValuesLoadRequirements: "color" and "svg" both fall through to the no-referrer branch. - stegaEncode: adjacent type imports. - describe.test.ts: a serialize test and a round-trip test each. - examples/next: the theme module and the icons module are both registered, and the home page renders both sections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFs5x1hQ9MiaDn4aQyANTp
Adds a new schema that stores an svg as a json node tree, so custom icons can be content rather than an opaque binary. Until now an svg could only be an
s.image()/s.file()reference to a blob: not recolorable, not validatable against the design system, not diffable.The point of the feature is that colors are declared as variables rather than baked hexes, so one icon supports
currentColor, dark mode, and per-usage overrides.From
examples/next: the bookmark on the schema's example colors, the bell inside red text (itslinevariable maps tocurrentColor, so the clapper follows), and the check mapped to css custom properties this app owns.Short form
Longer form
varsis exhaustive, likeValRichText'sthemevarsmaps each declared variable to the color that actually renders. It is optional, but if given it must cover every variable — so adding one to the schema is a compile error at every call site until someone revisits it. Same contract, and the same reason, astheme:nullmeans "leave it to css" — that attribute becomesvar(--val-svg-<name>, currentColor). Omittingvarsdoes the same for every variable, andsvgVarsCss(iconSchema)writes the schema's example colors into those properties, so a[data-theme="dark"]block retimes every icon with no React involved.The
valueon a variable is an example: it drives the editor preview and the import match. Nothing is mirrored into the source — the palette lives only in the schema — so there is nothing that can drift and no newValidationFix.Raw colors are type-checked as well as validated. With the default
literals: "forbid",fill: "#ff0000"is a TypeScript error, and the validator reports it with a source location:Editor
Icons are read far more often than they are replaced, so the field is read-optimized: the tile is the control, and it is the only thing on screen. Drop an svg on it, paste markup into it, or click it to pick a file. No textarea, no button row; the markup sits behind an accordion.
Controls appear only when an import needs a decision. Colors are matched onto the palette by exact value or by a variable's
matchaliases;toleranceopts a variable into fuzzy matching. Nothing is snapped to a nearby variable otherwise — a brand color that is quietly rewritten is worse than one you are asked about.While mapping, the tile previews the icon in its original colors, so you can see what you dropped and watch it move onto the palette as you pick. It is not committed until every color has somewhere to go — a half-mapped icon would silently lose fills.
Empty state, and the mapper on its own:
One source, three palettes, five sizes:
Stories:
packages/ui/spa/components/fields/SvgField.stories.tsx(Fields/SvgField).Docs
packages/next/README.md— aSvgsection following theRichTextsection's shape: schema, initializing content, rendering, thevarsnote, editing, the type, and a full-custom renderer..agent/rules.md(shared byCLAUDE.md, the cursor rules and the copilot instructions, which all symlink to it) — a "Working with Svg" section covering the invariants that are easy to break, pluss.svg()in the type-hierarchy tables.Decisions worth reviewing
Svg is excluded from stega encoding entirely. Every string in an svg (
d,viewBox,points,transform) is machine parsed, so injecting invisible characters would corrupt the icon. That leavesattrs()with nothing to find, so the source path is attached as an ordinary serializable field (SVG_VAL_PATH) whichValSvgturns intodata-val-path. A symbol would be cleaner json but would not survive RSC serialization. Tests assert every string round-trips byte-identical.ValSvgbuilds React elements tag by tag — nodangerouslySetInnerHTMLanywhere. Safety is therefore entirely the allowlist, and it is a strict per-tag allowlist of exact attribute names, not anon*denylist: React renders unknown attributes on host elements verbatim, andonloaddoes fire on svg elements. Rejected:script,style(svg<style>is not scoped — it leaks to the whole document),foreignObject,a,use,image, all animation and filter elements; and thestyle/id/class/href/xlink:*/data-*attributes. After the enum-typed attributes,d,points,transformandstroke-dasharrayare the only free-form strings left, and each is regex constrained and length capped.The parser is hand rolled and dependency free (
packages/shared/src/internal/svg/).@valbuild/sharedships into every user's server bundle, and svg-as-xml is a small grammar. Entity declarations and doctypes with an internal subset are rejected outright (XXE / billion laughs). Parser output is filtered through the allowlist before anything else sees it; the parser is not treated as a security boundary.DOMParseris avoided so the same code runs in jest, the CLI andnode:vm.Gradients are out of scope for now. Adding them later needs a third attribute value kind for
url(#…)and per-instanceidnamespacing viauseId()— without that, two icons on one page that both containid="a"silently corrupt each other. Additive, not breaking.@types/react-domadded topackages/reactfor the render tests. It resolves to the 18.2.17 already used bynext/ui/examples, so there is still exactly one copy of@types/reactand the duplicate-copy JSX hazard is not reintroduced..github/pr-assets/is a separate commit and can be dropped before merge; it exists only so GitHub can render the screenshots above.Verification
pnpm run lint✅pnpm -w run format✅pnpm run -r typecheck✅ — this is what proves the ~18never-checked dispatch sites are coveredpnpm test✅ — 1165 tests, 99 suitespnpm run build✅cd examples/next && pnpm run build✅pnpm exec tsx src/cli.ts validate --root ../../examples/nextandnode bin.js validate …→content/icons.val.ts valid. This is the only path that exercisescreateService→loadValModules, i.e. evaluating*.val.tsin thenode:vmsandbox. The 2 remaining errors in the example app are the pre-existing missing-image / stale-metadata ones.New tests:
packages/core/src/schema/svg.test.ts; 20 rows invalidation.test.ts(includingonloadrejected rather than silently dropped); cases indescribe/hidden/readonly; 36 inpackages/shared/src/internal/svg/parseSvg.test.ts(round-trip, color matching, and a rejection table for XXE, mismatched tags,<script>,onload=,xlink:href, oversizedd); 13 inpackages/react/src/internal/ValSvg.test.tsx(variable resolution, css fallback, size precedence, title/aria,data-val-path, plus a type-level check thatvarsstays exhaustive); and 5 instegaEncode.test.ts.One bug the round-trip test caught during development:
parseSvgcould not read back thevar(--val-svg-*)form thatsvgToStringemits, so "copy as svg" then re-paste in the editor would have dropped every variable. Fixed and covered.