Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 5 additions & 13 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,14 @@ A key-value pair on a Vertex or Edge. UI label varies by query language: "Proper
_Avoid_: Attribute (legacy code term being phased out)

**Styles**:
Display customizations per Vertex Type and Edge Type — shape, color, icon, line style, and display labels. Resolved through a **Styles Cascade** (highest precedence first): User Custom Styles → Shared Styles → App Default Styles. Persisted and merged with Schema-discovered metadata to produce the final rendering. The two specific instances are **Vertex Styles** and **Edge Styles**, each stored in IndexedDB as its own type-keyed Map per layer. Storage keys follow a `<layer>-<entity>-styles` convention.
_Avoid_: User Preferences, preferences, user styling, user settings; "customization" as a standalone noun
Display customizations per Vertex Type and Edge Type — shape, color, icon, line style, and display labels. A type's style resolves as the **User Style** if the user has set one, otherwise the **App Default Style**. Persisted and merged with Schema-discovered metadata to produce the final rendering. The two specific instances are **Vertex Styles** and **Edge Styles**, each stored in IndexedDB as its own type-keyed Map. Storage keys follow a `user-<entity>-styles` convention. The verb is **customize**; the UI shorthand is **your styles**.
_Avoid_: User Preferences, preferences, user styling, user settings; "customization" as a standalone noun; Effective styles (no separate atom), style overrides

**Styles Cascade**:
The precedence stack that resolves which style value a vertex or edge type displays. From highest to lowest priority: (3) **User Custom Styles** — per-type edits made in the style dialogs, (2) **Shared Styles** — loaded from a file via Settings, (1) **App Default Styles** — hardcoded in the codebase (`appDefaultVertexStyle` / `appDefaultEdgeStyle`). The layers below User Custom Styles (1–2) collectively are **Default Styles** — what a per-type "Reset to Default" restores to (it clears the user's edit, revealing the Shared or App Default beneath). The verb is **customize**; the UI shorthand is **custom styles** / **shared styles**.
_Avoid_: Imported Default Styles, imported defaults (the layer is **Shared Styles**); Effective styles (no separate atom), style overrides

**User Custom Styles**:
The highest-precedence layer in the Styles Cascade. Per-type edits a user makes in the vertex/edge style dialogs. Stored in `userVertexStylesAtom` (`Map<VertexType, VertexStyleStorage>`, key `"user-vertex-styles"`) and `userEdgeStylesAtom` (`Map<EdgeType, EdgeStyleStorage>`, key `"user-edge-styles"`). Clearing these ("Reset Custom Styles" in Settings) reveals Shared Styles beneath.

**Shared Styles**:
The second-highest-precedence layer in the Styles Cascade (below User Custom, above App Default). Loaded from a styling file via the Settings → Styles screen. Stored in `sharedVertexStylesAtom` (key `"shared-vertex-styles"`) and `sharedEdgeStylesAtom` (key `"shared-edge-styles"`). Non-destructive to User Custom Styles — loading writes only this layer. Clearing these ("Reset Shared Styles" in Settings) falls through to App Default Styles. Named for their purpose: a user **saves** their styles to a file and others **load** it to get the same look.
_Avoid_: Imported Default Styles, imported defaults (renamed — "shared" names the purpose and reads better)
**User Styles**:
The user's own styles, taking precedence over the app defaults. Per-type edits a user makes in the vertex/edge style dialogs, plus styles loaded from a file. Stored in `userVertexStylesAtom` (`Map<VertexType, VertexStyleStorage>`, key `"user-vertex-styles"`) and `userEdgeStylesAtom` (`Map<EdgeType, EdgeStyleStorage>`, key `"user-edge-styles"`). Clearing these ("Reset to Defaults" in Settings) falls back to App Default Styles. A user **saves** their styles to a file and others **load** it to get the same look.

**App Default Styles**:
The lowest-precedence layer in the Styles Cascade. Hardcoded in the codebase as `appDefaultVertexStyle` and `appDefaultEdgeStyle`. Not persisted — always available as the final fallback.
The built-in fallback style, used for any type the user hasn't styled. Hardcoded in the codebase as `appDefaultVertexStyle` and `appDefaultEdgeStyle`. Not persisted — always available as the final fallback. In UI copy this is just the **default style** (the "app" qualifier is internal only).

**Schema Sync**:
The process that queries the database to discover vertex types, edge types, and their attributes. Required before a user can explore a new Connection.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
- **Status:** Accepted
- **Date:** 2026-06-23
- **Updated:** 2026-07-06 — the styling vocabulary was renamed to the Styles convention (#1866). The decision below is unchanged; only the identifiers drifted. Read it with these substitutions: `VertexPreferencesStorageModel`→`VertexStyleStorage`, `EdgePreferencesStorageModel`→`EdgeStyleStorage`, `vertexPreferencesAtom`→`vertexStyleAtom`, `edgePreferencesAtom`→`edgeStyleAtom`, `LegacyUserStylingStorageModel`→`LegacyUserStylingStorage`, `userPreferences.ts`→`graphStyles.ts`.
- **Updated:** 2026-07-20 — the multi-layer roadmap was abandoned (#1974). Styles resolve as a single user layer over the app defaults; the never-shipped shared layer was removed. The type-keyed Map decision below is unchanged.
- **Related:** ADR `per-key-diff-merge-cross-tab-reconciliation` — the Map-keyed shape is the prerequisite for per-type merge. ADR `storage-layer-owns-persistence-failure` — the migration's "catch and degrade to defaults on failure" posture follows that ADR's established startup-failure stance. Issue #1864 (this change). Issues #1820 / #1831 (cross-tab merge, separate follow-up).

## Context
Expand Down Expand Up @@ -37,7 +38,7 @@ userEdgeStylesAtom: Map<EdgeType, EdgePreferencesStorageModel>; // key: "user-ed

Splitting vertex styles from edge styles (rather than a single `Map<string, ...>`) keeps the types precise and avoids a heterogeneous map that mixes `VertexPreferencesStorageModel` and `EdgePreferencesStorageModel` values under untyped string keys.

The keys follow a `<layer>-<entity>-styles` convention. Only the user-defined layer (`user-`) exists today; planned later work adds imported (UI file import), server (fetched from server config), and base (hard-coded codebase defaults) layers, which merge by precedence to produce the final rendering. Naming the user layer `user-vertex-styles` now — rather than a bare `vertex-styles` — means those sibling keys slot in without renaming or re-migrating this one.
The keys are `user-vertex-styles` and `user-edge-styles`. The `user-` prefix is a historical artifact of a since-abandoned multi-layer roadmap (see the 2026-07-20 note); styles now resolve as this single user layer over the hard-coded app defaults.

Maps persist natively through localForage/IndexedDB via the structured-clone algorithm — no serialization work is needed.

Expand Down
9 changes: 5 additions & 4 deletions docs/adr/20260624-styling-file-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
- **Status:** Accepted
- **Date:** 2026-06-24
- **Updated:** 2026-07-06 — the Preferences → Styles rename (#1866) has since shipped. The decision below is unchanged; read `VertexPreferencesStorageModel`→`VertexStyleStorage` and `EdgePreferencesStorageModel`→`EdgeStyleStorage`.
- **Related:** ADR `shared-file-envelope` (the outer envelope this payload lives inside). ADR `type-keyed-map-atoms-for-user-preferences` (the Map storage shape that the shared layer mirrors). Issue #1866 (Preferences → Styles rename).
- **Updated:** 2026-07-20 — the styles cascade collapsed to a single user layer (#1974). Import and export now target the user styles (`userVertexStylesAtom` / `userEdgeStylesAtom`) directly; the never-shipped shared layer was removed with no data migration. The file format itself is unchanged.
- **Related:** ADR `shared-file-envelope` (the outer envelope this payload lives inside). ADR `type-keyed-map-atoms-for-user-preferences` (the Map storage shape the user styles mirror). Issue #1866 (Preferences → Styles rename).

## Context

Expand Down Expand Up @@ -82,13 +83,13 @@ type EntryImportIssue = {

### Export semantics

Export produces the **effective merged view**: for each type that has either user or shared styling, the output is `{ ...sharedMap.get(type), ...userMap.get(type) }` with user fields winning on conflict. Re-importing this file on a fresh machine reproduces the full visual appearance.
Export produces each type's stored **user style** verbatim (`userVertexStylesAtom` / `userEdgeStylesAtom`). Re-importing this file on a fresh machine reproduces the full visual appearance.

### Import semantics

Import writes to the **shared layer** (`sharedVertexStylesAtom` / `sharedEdgeStylesAtom`) — it is **non-destructive** to user customizations. The user layer is untouched. If the user has customized a field that the import also specifies, the user's value wins in the cascade (user layer has higher precedence).
Import writes to the **user styles** (`userVertexStylesAtom` / `userEdgeStylesAtom`).

Import **merges** into the existing shared layer rather than replacing it: each type in the file is set onto the current shared map, and types not present in the file are retained. When the file specifies a type that already has a shared style, that overlap is surfaced via `getStylingConflicts` and the user confirms before the overwrite proceeds. This lets a user assemble shared styles from several files while still being warned before any existing shared style is overwritten.
Import **merges** into the existing user styles rather than replacing them: each type in the file is set onto the current map, and types not present in the file are retained. When the file specifies a type that already has a user style, that overlap is surfaced via `getStylingConflicts` and the user confirms before the overwrite proceeds. This lets a user assemble styles from several files while still being warned before any existing style is overwritten.

## Consequences

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Persisted state in IndexedDB (via `atomWithLocalForage`) reloads in its stored s

Reshape the value **on read**, via a `transform` option on `atomWithLocalForage`: `transform: (loaded: T) => T` runs on the preloaded value before it seeds the atom. The transform lives beside its type (`transformGraphViewLayout` / `transformSchemaViewLayout`, sharing `transformLegacySidebarItem`) and is wired onto the atom in `storageAtoms.ts`.

- **Updated 2026-07-10:** `transformVertexStyles` (in `vertexStylesTransform.ts`) is a second consumer, applied to both `user-vertex-styles` and `shared-vertex-styles`. It coerces retired round-polygon shapes to their non-round counterpart (see ADR `coerce-retired-round-polygon-shapes`). Values arriving through file import are stored verbatim — the same ReadTransform coerces them on the next load, so both entry points (persisted storage and imported files) converge on the same coercion without the import path needing its own transform.
- **Updated 2026-07-10:** `transformVertexStyles` (in `vertexStylesTransform.ts`) is a second consumer, applied to `user-vertex-styles`. It coerces retired round-polygon shapes to their non-round counterpart (see ADR `coerce-retired-round-polygon-shapes`). Values arriving through file import are stored verbatim — the same ReadTransform coerces them on the next load, so both entry points (persisted storage and imported files) converge on the same coercion without the import path needing its own transform.

Two decisions here are not obvious from the code:

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/20260710-coerce-retired-round-polygon-shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

Cytoscape's round-polygon canvas renderer degenerates at 24px (our node size) for six shapes: `round-triangle`, `round-pentagon`, `round-hexagon`, `round-heptagon`, `round-octagon`, `round-tag`. The corner computation collapses, rendering each as a formless blob and producing invalid edge-endpoint coordinates that cause connected edges to disappear. `round-rectangle` and `round-diamond` are unaffected (they use a different code path). No upstream fix exists; the one related issue (cytoscape/cytoscape.js#3282) describes a crash, not this visual collapse.

The shapes became selectable in the UI via #1886, so users may have stored them in IndexedDB (user-vertex-styles, shared-vertex-styles) or in exported styling files. We must handle that existing data.
The shapes became selectable in the UI via #1886, so users may have stored them in IndexedDB (user-vertex-styles) or in exported styling files. We must handle that existing data.

## Decision

Expand Down
24 changes: 7 additions & 17 deletions docs/features/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,23 @@

## Styles

The Styles settings page lets you manage your styling configuration the colors, shapes, icons, and other visual properties applied to vertex and edge types in the graph view.
The Styles settings page lets you manage your styling configuration: the colors, shapes, icons, and other visual properties applied to vertex and edge types in the graph view.

Graph Explorer uses a three-layer cascade to determine how each type looks:

1. **Custom styles** (highest priority) — per-type edits you make in the style dialogs
2. **Shared styles** — loaded from a file via this settings page
3. **App defaults** — the built-in fallback styles

When you customize a type in the style dialog, that change only affects your custom layer. Shared styles fill in where you haven't customized.
Each type's appearance comes from your styles when you've set them, falling back to the built-in defaults otherwise. You set a type's style by editing it in the style dialog or by loading a styling file.

### Save styles to share

Saves your current effective styles (custom and shared, merged) to `graph-explorer.styles.json`. Loading this file on another machine or browser reproduces your full visual configuration.
Saves your current styles to `graph-explorer.styles.json`. Loading this file on another machine or browser reproduces your full visual configuration.

### Load shared styles
### Load styles

Loads a styling file and merges it into your shared styles. Your custom styles are not affected — they continue to take priority. If there are no conflicts the file is applied immediately; if the file contains types that already have shared styles, you'll see a confirmation listing the types that will be replaced before anything changes. Types not in the file are left unchanged. The page shows an indicator of how many types currently have shared styles.
Loads a styling file and merges it into your styles. If there are no conflicts the file is applied immediately; if the file contains types you've already styled, you'll see a confirmation listing the types that will be replaced before anything changes. Types not in the file are left unchanged.

Loading is all-or-nothing: if the file contains any invalid value, nothing is loaded and you'll see a report listing every offending type and field so you can fix the file and load it again. Icons must be Lucide references (`lucide:<name>`) or base64-encoded data URIs; remote URLs are rejected. Unrecognized or extra fields are ignored silently (this is how a file from a newer version still loads), so a file whose entries contain only unrecognized fields loads nothing and reports that no styles were found.

### Reset custom styles

Clears all your per-type style customizations. After this, types will show their shared styles (if any) or the app defaults. This cannot be undone — consider saving first.

### Reset shared styles
### Reset your styles

Removes all shared styles. Your custom styles remain unaffected.
Clears all your node and edge styles, returning every type to the defaults. This cannot be undone, so consider saving first.

## About

Expand Down
56 changes: 12 additions & 44 deletions packages/graph-explorer/src/core/StateProvider/graphStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ import DEFAULT_ICON_URL from "@/utils/defaultIconUrl";
import type { EdgeType, VertexType } from "../entities";

import { useActiveSchema } from "./schema";
import {
sharedEdgeStylesAtom,
sharedVertexStylesAtom,
userEdgeStylesAtom,
userVertexStylesAtom,
} from "./storageAtoms";
import { userEdgeStylesAtom, userVertexStylesAtom } from "./storageAtoms";

export const SHAPE_STYLES = [
"rectangle",
Expand Down Expand Up @@ -177,80 +172,53 @@ export type LegacyUserStylingStorage = {
};

/**
* The styles cascade — the single place vertex/edge appearance is assembled.
*
* Three layers, lowest precedence first:
* 1. App defaults — {@link appDefaultVertexStyle} / {@link appDefaultEdgeStyle}.
* 2. Shared styles — `sharedVertexStylesAtom` / `sharedEdgeStylesAtom`
* (storage keys `shared-vertex-styles` / `shared-edge-styles`), loaded
* from a styling file on the Settings → Styles page.
* 3. User customizations — `userVertexStylesAtom` / `userEdgeStylesAtom`
* (storage keys `user-vertex-styles` / `user-edge-styles`), edits made in the
* style dialogs.
*
* Higher layers override lower ones per field (see {@link resolveVertexStyle}
* / {@link resolveEdgeStyle}). Related modules: `core/styling` (the styling
* import parser and import/export hooks) and `core/fileEnvelope` (the file
* wrapper). The `icon`↔`iconUrl` rename happens at the file-format seam in the
* styling parser, not here.
* The single place vertex/edge appearance is assembled: a type's stored user
* style overrides the app defaults per field, falling back to the defaults for
* anything unset. User styles come from the style dialogs and from loading a
* file (`core/styling`); the `icon`↔`iconUrl` rename happens at the file-format
* seam in that parser, not here.
*/

/** Vertex styles indexed by type for O(1) lookup with cascade fallback.
* Cascade: user > shared > app defaults. */
/** Vertex styles indexed by type for O(1) lookup, resolved against defaults. */
export const vertexStyleAtom = atom(get => {
const userStyles = get(userVertexStylesAtom);
const sharedStyles = get(sharedVertexStylesAtom);
return {
get(type: VertexType) {
return resolveVertexStyle(
type,
sharedStyles.get(type),
userStyles.get(type),
);
return resolveVertexStyle(type, userStyles.get(type));
},
};
});

/** Edge styles indexed by type for O(1) lookup with cascade fallback.
* Cascade: user > shared > app defaults. */
/** Edge styles indexed by type for O(1) lookup, resolved against defaults. */
export const edgeStyleAtom = atom(get => {
const userStyles = get(userEdgeStylesAtom);
const sharedStyles = get(sharedEdgeStylesAtom);
return {
get(type: EdgeType) {
return resolveEdgeStyle(
type,
sharedStyles.get(type),
userStyles.get(type),
);
return resolveEdgeStyle(type, userStyles.get(type));
},
};
});

/** Combines the cascade layers: app defaults < shared < user. */
/** The user's vertex style overlaid on the app defaults. */
export function resolveVertexStyle(
type: VertexType,
shared?: VertexStyleStorage,
user?: VertexStyleStorage,
): VertexStyle {
return {
type,
...appDefaultVertexStyle,
...shared,
...user,
} as const;
}

/** Combines the cascade layers: app defaults < shared < user. */
/** The user's edge style overlaid on the app defaults. */
export function resolveEdgeStyle(
type: EdgeType,
shared?: EdgeStyleStorage,
user?: EdgeStyleStorage,
): EdgeStyle {
return {
type,
...appDefaultEdgeStyle,
...shared,
...user,
} as const;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ import { persistenceStatusStore } from "./persistence";
* Older versions stored all styling under a single `"user-styling"` key as a
* `LegacyUserStylingStorage` object: `{ vertices: VertexStyleStorage[], edges:
* EdgeStyleStorage[] }`. Styling is now stored as two type-keyed maps
* under `"user-vertex-styles"` and `"user-edge-styles"` — the user-defined layer of the
* planned `<layer>-<entity>-styles` set. This migration converts the old shape
* to the new one on startup.
* under `"user-vertex-styles"` and `"user-edge-styles"`. This migration converts
* the old shape to the new one on startup.
*
* The old `"user-styling"` key is intentionally left untouched as a rollback
* escape hatch; deleting it is a separate follow-up.
Expand Down
Loading
Loading