Skip to content

Add styling import/export with a shared file envelope#1879

Merged
kmcginnes merged 42 commits into
mainfrom
styles-import-export
Jul 7, 2026
Merged

Add styling import/export with a shared file envelope#1879
kmcginnes merged 42 commits into
mainfrom
styles-import-export

Conversation

@kmcginnes

@kmcginnes kmcginnes commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds styling load/save to Settings → Styles: users can save their current styling configuration to a file and load one on another machine or browser to reproduce their visual setup.

The feature introduces a three-layer styling cascade — Custom → Shared → App Defaults — where loading writes only the shared styles layer, non-destructively to the user's own customizations. Along the way it extracts a reusable shared file envelope ({ meta, data } with kind + version guarding) that the graph file feature now also consumes.

Key design decisions:

  • Atomic load. A file loads in full or not at all. Any invalid value rejects the whole file and reports every offending location; nothing is half-applied. A valid file that carries no recognized styles reports "No Styles Found" rather than a false success.
  • Forward compatible. New entries and new optional fields from a future build load cleanly (unknown fields are stripped, not rejected); the format version is a single integer generation that bumps only on a breaking change.
  • Secure against untrusted files. Loaded icons are allowlisted to Lucide references or base64 data URIs — remote URLs are rejected so loading never triggers an outbound request — and unknown fields are stripped so a file can't smuggle a raw icon URL past the allowlist.
  • Legible failures. Load and save failures route through the shared createDisplayError helper, so a wrong-kind or too-new file reports a specific reason ("Invalid file", with the mismatch) rather than a generic error; a dismissed save picker is treated as a cancel, not a failure.

Validation

  • pnpm checks passes (types, lint, format).
  • pnpm test passes — including a golden-file corpus proving every shipped file generation (integer and legacy "1.0" string) still loads, round-trip save→load tests, atomic-rejection cases, and component tests for the load dialog's every phase.

How to read

  1. docs/features/settings.md — start here; the user-facing behavior in prose (cascade, atomic load, reset actions).
  2. core/fileEnvelope/fileEnvelope.ts — the shared envelope: version schema, read/write meta contract split, kind + generation guard.
  3. core/styling/stylingParser.ts — the styling payload contract: whole-file validation, icon allowlist, discriminated load issues.
  4. core/styling/useStylingImportExport.ts — the save merge and non-destructive load into the shared-styles layer.
  5. routes/Settings/LoadStylesButton.tsx — the load dialog phase machine (conflicts / invalid / empty / complete / failed).
  6. core/StateProvider/userPreferences.ts — supporting change: the layered storage model the cascade reads from.

The docs/adr/ entries capture the file-format and envelope decisions in depth if you want the rationale.

Screenshots

Settings → Styles. The new page: save/load for style sharing, plus a danger zone for the two independent resets. The indicator shows how many types currently carry shared styles.

Styles settings page

Loading

Conflict prompt Success
Loading over existing shared styles lists the types that will be replaced and warns it can't be undone. New types are added alongside. On success, the dialog reports exactly what was applied.
Replace existing shared styles confirmation Styles loaded with counts

Atomic rejection. A file with any invalid value loads nothing and lists every offending type and field so it can be fixed and reloaded.

Load failed, invalid values listed

Resetting

The two layers reset independently; each confirms before the irreversible change.

Reset custom styles Reset shared styles
Reset custom styles confirmation Reset shared styles confirmation

Related Issues

Check List

  • I confirm that my contribution is made under the terms of the Apache 2.0 license.
  • I have verified pnpm checks passes with no errors.
  • I have verified pnpm test passes with no failures.
  • I have covered new added functionality with unit tests if necessary.
  • I have updated documentation if necessary.

kmcginnes added 13 commits June 30, 2026 16:11
Add a Styles settings page that exports vertex/edge styles to a file and
imports default styles from a file. Introduces:

- A versioned file envelope primitive (core/fileEnvelope).
- A salvaging styling parser that keeps valid fields and reports per-field
  issues rather than rejecting the whole file (core/styling).
- importedVertexStylesAtom / importedEdgeStylesAtom and the styles cascade
  (app defaults < imported < user) in userPreferences.
- DOMPurify SVG sanitization at the icon render sinks.

Imported icons are restricted to Lucide references and base64 data URIs;
remote URLs are rejected so importing never triggers outbound requests.
Import merges into the imported layer and confirms before overwriting
existing imported defaults. Export and import surface success/failure and
per-field warnings through dialogs.

Includes ADRs (file envelope, styling format + salvaging contract) and
user docs for the Styles page.
…nd graph export

parseFileEnvelope now takes an expected kind and supported major version,
rejecting wrong-kind and too-new files with a clear error instead of letting
them fall through to a confusing partial parse. Styling and graph export both
consume the guard; graph export drops its duplicate inline envelope schema and
parses straight from the file Blob.
ConfirmDialogProvider exposes a promise-returning useConfirm() so a confirm
step reads inline in an async flow. SettingsStyles uses it for reset and
import-merge confirmations, collapsing the pending-import state and a separate
handler into one top-to-bottom flow, and models the operation lifecycle as a
single state union.
…-entry validation

The envelope version is now one integer that bumps only on a breaking change;
additive changes ship as optional fields and are ignored by older builds, so
there is no minor concept. The styling parser validates each entry as a whole
Zod object — an invalid field drops the entry, unknown fields are stripped
(which also blocks an iconUrl allowlist bypass) — replacing the per-field
salvaging loop.
Validate the entire styling payload in a single safeParse instead of
per-entry: a file imports in full or not at all. Entry schemas plug into
z.record so a Zod issue path carries entity/type/field directly.

ImportIssue becomes a discriminated union (general vs entry) so file-level
structural problems group separately from per-entry field issues. The
import dialog drops the 'imported with warnings' state in favor of an
'invalid values' report; nothing persists on failure.
meta is read only to gate kind/version and is never written back to
disk, so z.looseObject preservation bought nothing. Switch to z.object
so meta matches the payload's strip-unknown default.
Code quality:
- Compute import conflicts once in the parse mutation; derivePhase is now pure
- Add toEdgeFileEntry alongside toVertexFileEntry so export is symmetric
- Extract typedEntries into utils with a test; drop the local one-off

UX:
- Report imported type counts on completion
- Show a distinct 'No Styles Found' state when a valid file has no styles
- Warn that overwriting imported defaults cannot be undone, on the conflict
  prompt and the Reset Imported Defaults dialog

Docs:
- settings.md: describe atomic all-or-nothing import (was stale salvaging copy)
- ADR: name EdgeStyleFileEntry, integer version
- CONTEXT.md: correct reset-button labels, drop the unshipped server layer

Tests:
- Add ImportStylesButton phase-machine coverage

Also reconciles the consumers of the integer-version envelope API across
graph export and the envelope/round-trip test fixtures.
parseStylingFile and parseExportedGraph now route the envelope's
validated integer version through a per-generation switch
(parseStylingPayloadForVersion / parseGraphExportPayloadForVersion).
A supported-but-unhandled generation throws FileEnvelopeError instead of
being mis-parsed by the current schema, which would silently strip
renamed or retyped fields. Today only case 1 exists; a breaking v2 adds
its case beside it.
…ports

Commits real exported files per kind and per on-disk version encoding
(legacy "1.0" string + integer 1) and imports each through the real
entry point. The version integer is meaningless without artifacts that
exercise it; these catch a backward-compat regression that a unit test
over a hand-built object would miss.
…ntracts

Move version format-validation into the envelope parse schema as a strict
union of the integer generation or the one legacy "1.0" string, normalized
to an integer. A malformed version ('1.5', '2.0', 'banana', 0) now fails
structural parsing instead of a later guard; parseVersion is gone.

Split the meta contract: exports still write the full metadata, but the read
schema keeps only the load-bearing kind + version. The diagnostic
timestamp/source/sourceVersion are stripped, not validated, so a file that
omits or malforms them still imports — they exist for humans, not the reader.

Fix stale version test fixtures: a newer generation is integer 2 (was the
string '2.0'); drop the 'minor version' test (no such concept); assert
non-integer and sub-1 versions are rejected as malformed structure.
Drop the redundant top-level version from ParsedEnvelope — it duplicated
meta.version. ParsedEnvelope is now inferred straight from fileEnvelopeSchema,
and consumers read the generation from envelope.meta.version.
Give the import dialog a distinct 'empty' phase for a valid file that carries
no recognized styles, instead of reinterpreting a zero-count 'complete' in the
view. derivePhase now decides the terminal outcome (empty vs complete) straight
from the parse result rather than a separate applied flag, so the no-conflict
success no longer passes through a transient null that relied on render batching.

Completion copy omits a zero side ('2 vertex styles', not '2 vertex styles and
0 edge styles') and says 'styles' rather than 'types'. Reset Imported Defaults
gains 'consider exporting first' for parity with Reset Custom Styles.
The format version collapsed to a single integer generation; the comment still
said 'major version'.
@kmcginnes kmcginnes marked this pull request as ready for review July 1, 2026 14:59
kmcginnes added 16 commits July 1, 2026 11:32
The import flow is local file parsing, not a server round-trip, so
TanStack Query's cache/retry/dedup were unused — it served only as an
isPending source feeding a derive-from-three-inputs step plus a
confirmed boolean. React 19's useActionState collapses that: the async
action is the reducer, React owns isPending across the await, and the
DialogState machine (with an explicit closed state replacing the null
sentinel) is the single source of truth. Parse errors are caught inside
the action and returned as invalid/failed states.
…d/save

The styling cascade's middle layer was 'Imported Default Styles' with
'Import'/'Export' file actions. Stakeholders prefer 'Save'/'Load' (the
house style already used by the graph and configuration file features),
and 'Shared Styles' names the layer's purpose better than a bare
adjective-noun. Renamed end-to-end — UI copy, the sharedVertexStylesAtom
/ sharedEdgeStylesAtom atoms, the shared-*-styles storage keys, the
Load/Save/ResetSharedStyles button components, and the CONTEXT.md
glossary. No migration: the feature had not shipped, so no persisted
data uses the old keys.

'Default' now lives only in the cascade generally and the per-type
'Reset to Default' button, not the layer name. The core-styling
plumbing keeps import-flavored names (parseStylingFile,
useApplyStylingImport, getStylingConflicts) as an internal detail of
the load action.
'Save' / 'Save styles' read flat. The button becomes 'Save to File'
(echoing the graph feature's 'Save graph to file'), and the setting
title becomes 'Save styles to share' so it orbits the same purpose as
'Load shared styles' and the 'Style Sharing' section.
The save payload merges both layers, so a saved file captures custom
edits plus any shared styles the user has loaded. Say so, so users don't
assume it's only their own edits.
Mirror 'Save to File' with 'Load from File'. Fix the description's
replace-vs-merge overclaim: loading merges into the shared layer (new
types added, matching ones replaced) rather than becoming the new shared
styles wholesale — which is why the conflict-confirm prompt exists.
An OS open-file dialog shows every GE export as a generic .json, so
users can't tell styles, config-backup, connection, and graph files
apart. Adopt the `<name>.<type>.json` convention the graph export
already uses (*.graph.json):

- styles:     graph-explorer-styles.json  -> graph-explorer.styles.json
- backup:     graph-explorer-config.json  -> graph-explorer.config.json
- connection: <label>.json                -> <label>.connection.json

Also let saveFile take a picker description (default "JSON"), and pass
"Graph Explorer styles" from the styles save so the native save dialog
labels the file type. Only affects picker-based saves; the file-saver
fallback and raw saveAs callers just get the better filename.

Forward-only: loads validate by envelope kind / backup structure, never
by filename, so files saved before this still load. Documented the
convention on createFileEnvelope.
Graph exports have ended in .graph.json since before this branch, so
narrowing the open dialog's default filter surfaces them without hiding
any existing files. Browsers that don't honor a compound-extension
accept fall back to showing more, and the All Files escape hatch plus
content validation still cover a force-picked file.

Styles, backup, and connection keep the wider filter: their .<type>.json
names are new in this branch, so a narrow filter would hide files users
already saved under the old names.
Styles is new in this branch and never shipped, so no styles file was
ever saved under an older name — a .styles.json filter hides nothing.
(Connection and backup keep the wider filter: they are pre-existing
features whose files exist in the wild under legacy names.)

Names the test fixtures like real styles files so they clear the
filter, and makes the wrong-kind test a genuine 'passes the filename
filter but fails content validation' case.
Teach createDisplayError about FileEnvelopeError so wrong-kind and
too-new files surface their specific message under an "Invalid file"
title instead of the generic fallback. Both the load and save dialogs
now render a DisplayError (title + message) and log real failures with
logger.error; cancellation (a dismissed save picker) stays silent.

Convert SaveStylesButton from useMutation to a useActionState reducer,
matching LoadStylesButton so both buttons share one dialog-state model.
Pass reportInput to safeParse so each issue carries its own input value,
and read the human message straight from Zod (with a schema-level error
on the icon allowlist). This drops the hand-rolled readValueAtPath and
describeZodIssue helpers in favor of Zod's own path-anchored input and
default messages, which enumerate valid enum options and name expected
vs received types.
The file schema narrowed iconImageType to a five-value MIME enum while
storage types it as a plain string, forcing a cast in toVertexFileEntry.
The upload seam fills the field from the browser's file.type (any
image/*), and no consumer switches on its specific value, so the enum
was redundant narrowing that duplicated the icon allowlist's MIME set.
Widen it to a string to match the owned storage contract and drop the
cast.
Revert the widening of iconImageType to a plain string. The MIME enum
is a defense-in-depth control from a security review (documented in the
styling-file-format ADR): iconImageType gates the inline-SVG render sink,
so the format refuses an unknown type from an untrusted file rather than
trust it. The export cast is now scoped to just this one field, with an
honest comment, instead of a whole-object assertion.
Widen iconImageType back to a plain string and correct the ADR that
framed the enum as a security control. Tracing every reader of the field
(VertexIcon, renderNode) shows it only gates the inline-SVG render path
via an exact match on "image/svg+xml"; any other value takes the safer
<img>/raster path. The actual XSS controls are safeIconValue (the icon
data allowlist) and DOMPurify at the SVG sinks — neither of which the
enum affects.

The enum also broke round-trips: the upload seam stores iconImageType
from the browser's file.type (any image/*), so a fixed five-value enum
rejected valid uploads on re-import. A round-trip test now covers a
non-enum MIME, and the export cast is gone.
The styling load/save feature has not shipped, so no released build ever
wrote a styling file — the 3.1.0 sourceVersion and legacy "1.0" version
encoding on the styling fixtures and round-trip tests were fiction. Set
them to the current 3.2.0 integer-version form so they represent what the
code actually produces.

Delete the styling-export-v1-legacy-string.json golden fixture: styling
has no legacy generation to prove backward compatibility against, and the
envelope's "1.0"->1 normalization is already covered generically by
fileEnvelope.test.ts and the graph-export legacy fixture (graph export
did ship, so its legacy fixture stays).
kmcginnes added 7 commits July 1, 2026 18:51
The allowlist regex only anchored the start, so the lucide: alternative
matched on prefix and let trailing junk (lucide:x"><script>) pass
validation and persist to iconUrl. Not an XSS — render-time
isValidLucideIconName rejects it and VertexIcon/renderNode fall back to
null — but it stored malformed data and contradicted the ADR's
alphanumeric-and-hyphens-only claim. Anchor the lucide branch to the end;
the data: branch stays a prefix match since the base64 payload follows.
The styling ADR still named the cascade's middle layer imported* after
the end-to-end rename to shared*; align it with the code and CONTEXT.md.
Add a File Envelope glossary term to CONTEXT.md for the new {meta,data}
primitive, and correct the envelope ADR's known-kinds line to include
graph-export alongside styling-export.
useActionState's dispatch, called outside a transition, let the pending
async action reveal the nearest ancestor Suspense boundary — flashing the
whole Settings page loader on load and save. Wrap dispatch once per button
so every call site is transition-safe, and add regression tests that mount
each button under a Suspense boundary and assert its fallback never shows.
Swap the em-dash in the overwrite warning for a comma clause, and the
type/field separator in the invalid-value list for an arrow.
Reframe the reset section by source ('Reset your styles' rather than
'custom'), and use 'node' consistently for user-facing copy instead of
mixing 'node' and the code-level 'vertex' term.
Add an AlertDialogBody component that owns the scroll region so a dialog's
header and footer stay pinned when its content overflows. AlertDialogContent
becomes a fixed-height flex column instead of scrolling as a whole, and the
styling load conflict/invalid lists adopt the new body.

@noel-improv noel-improv left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall the work is comprehensive and looks good except a few potentially blocking items and few non-blockers.

<FileButton
onChange={payload => payload && importGraph.mutate(payload)}
accept="application/json"
accept=".graph.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking]:.graph.json filters the picker by filename suffix, so a renamed or older export greys out in the OS dialog. Maybe keep it application/json and let the save side own the naming? Similar to styles (LoadStylesButton.tsx:165.styles.json) while connections deviated unless this was intentional

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The suffix accept is only a hint browsers can ignore, so it filtered nothing while greying out renamed or older exports. Switched both the graph and styles loaders to application/json (matching connections). d800fcd

logger.debug("Fetching icon", url);
const response = await fetch(url);
return await response.text();
const text = await response.text();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking]: Only touches custom SVG icons, lucide/default and raster paths return before this. Same legacy tension as the icon allowlist: an old custom icon leaning on <foreignObject>/<style>/external <image href> would render blank now (also VertexIcon.tsx:53). Might be worth testing against a real one or safe by assumption?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests instead of leaving it to assumption (27f071a). The behavior is gentler than a blank render. The sanitizer strips only the executable constructs (<script>, event-handler attributes, and <foreignObject>) and leaves the rest of the drawing intact, including inline <style> and <image> elements. So a legacy custom icon loses only the stripped pieces rather than going blank.

* generation this build can read; files on disk from before the integer switch
* carry the legacy `"1.0"` string, which the envelope normalizes to `1`.
*/
export const GRAPH_EXPORT_VERSION = 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[potentially-blocking]: The doc above covers the read direction, a new build normalizes legacy "1.0"1. The gap is the reverse: this writes integer 1, and an older build's version: z.literal("1.0") has no matching normalize, so it rejects files this build exports (new→old fails). Is emitting "1.0" on write still on the table or is dropping old-build read-compat intended?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

Kept old-build read-compat. Graph export still writes "1.0" to disk, so older builds that validate version as the literal "1.0" can still import our files. On read, the envelope normalizes both "1.0" and 1 to the integer generation. Styling export is a new format with no older readers, so it writes the integer directly. d3daa01

const safeIconValue = z
.string()
.regex(
/^(lucide:[a-z0-9-]+$|data:image\/(svg\+xml|png|jpeg|gif|webp);base64,)/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[potentially-blocking] Thanks for clarifying, keeping this allowlist narrow is right, the concern is that it's paired with atomic reject. A legacy user who uploaded, say, a bmp icon back when the uploader took any image/* now has one value outside this set and the whole styles file fails to import, all their other styles included. Could a disallowed icon drop to default and salvage the rest instead of throwing? Worth confirming behavior.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed the cause. The allowlist was narrower than the uploader: the uploader accepts any image/* and stores whatever subtype the browser reports, but the allowlist only permitted svg/png/jpeg/gif/webp, so a legacy bmp or ico would export fine and then fail to import. The allowlist now accepts any image/* base64 data URI (still rejecting remote URLs and non-image schemes), so real legacy icons pass. I also extracted isAllowedIconValue as one gate and applied it at the upload seam, so what you can upload and what import accepts can't drift again. 322c0da

On atomic vs salvage: kept it atomic on purpose. A styling file is one coherent snapshot, so a clear all-or-nothing failure that reports every offending value beats silently half-applying it. Widening the allowlist removes the realistic case that would have tripped it. Documented the rationale in the ADR. 7e73065

kmcginnes added 6 commits July 7, 2026 15:55
Builds predating the shared file envelope validate meta.version as the
literal '1.0'. Writing the integer 1 made new graph exports fail to
import on those older builds. Graph export now keeps writing the '1.0'
decimal string at generation 1 while styling export writes the integer;
the reader normalizes both to the integer generation.

Splits the graph-export version into GRAPH_EXPORT_WIRE_VERSION (on disk)
and GRAPH_EXPORT_VERSION (generation), derives EnvelopeVersion from the
version schema's input, and drops the never-shipped integer golden
fixture.
The icon allowlist only permitted svg/png/jpeg/gif/webp data URIs, but the
uploader stores whatever image/* the browser reports (accept="image/*"), so
icons like BMP or ICO round-tripped out of the app and then failed to import,
rejecting the whole styling file. The allowlist now admits any image subtype;
SVG is still DOMPurify-sanitized at the render sink and non-image data URIs and
remote URLs are still rejected.

Extracts isAllowedIconValue as the single gate and applies it at the upload
seam so what a user can upload and what import accepts cannot drift.
The styling import parser is atomic (whole-file, all-or-nothing), the
opposite of salvaging, so the old filename and its evolution note were
misleading. Renames the ADR to styling-file-format, folds the note's
durable point (why atomic over salvage) into the parser section as
present-tense rationale, and updates the cross-reference.
The .graph.json / .styles.json accept hints filter the OS picker by
filename suffix, greying out renamed or older exports. Browsers treat it
as an unreliable hint anyway, so it bought no real filtering while
blocking legitimate files. Both loaders now accept application/json,
matching the connections loader; the atomic parser still rejects
wrong-kind files by content with a clear message.
Documents how a legacy custom SVG icon degrades at the render sink:
<script>, event-handler attributes, and <foreignObject> are stripped
while the surrounding drawing, inline <style>, and external <image href>
survive, and sanitizing never fetches the external href. Makes the
'safe by assumption' behavior safe by test.
The conflict test seeded sharedVertexStylesAtom after render, so the
load action closed over the empty initial map until an async re-render
flushed. On a loaded runner the upload dispatch could win that race,
see no conflicts, and skip straight to the success dialog, timing out
the conflict-prompt assertion. renderButton now seeds before render so
the first render already reflects the existing style.
@kmcginnes kmcginnes merged commit a74fca4 into main Jul 7, 2026
6 checks passed
@kmcginnes kmcginnes deleted the styles-import-export branch July 7, 2026 22:34
@kmcginnes kmcginnes mentioned this pull request Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Customization import / export or other customization sharing method

2 participants