Add styling import/export with a shared file envelope#1879
Open
kmcginnes wants to merge 36 commits into
Open
Conversation
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'.
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).
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.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
createDisplayErrorhelper, 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 checkspasses (types, lint, format).pnpm testpasses — 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
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.
Loading
Atomic rejection. A file with any invalid value loads nothing and lists every offending type and field so it can be fixed and reloaded.
Resetting
The two layers reset independently; each confirms before the irreversible change.
Related Issues
typedEntrieshelper across existingObject.entries()call sites)Check List
pnpm checkspasses with no errors.pnpm testpasses with no failures.