aws / aws/graph-explorer

Default Styles: type-less global styling

Open
#1,881 0 comments 0 reactions 0 assignees View on GitHub
customization enhancement internal ready-for-agent sharing
Dominant language
TypeScript
Stars
481
Forks
108
Avg merge
8d 9h
Merged PRs (30d)
7

Description

## Problem Statement

Today a user can only style vertices and edges **per type** — every Vertex Type and Edge Type must be customized individually. There is no way to say "make _all_ my nodes hexagons with this palette" as a baseline, and no way to adopt a colleague's global look as a starting point. Users with large or unfamiliar schemas face a wall of per-type work just to get a coherent appearance, and a styles file can only carry per-type styles, so a team's house style cannot include a global default.

## Solution

Introduce **Default Styles**: a type-less global style, one for vertices and one for edges, that applies to every type as a baseline. A more specific per-type style still wins where present. Defaults are editable in a dedicated dialog reached from the styles sidebar, travel inside the existing styles file (so a saved file can carry a global look), and participate in the same save/load and clear flows as per-type styles.

## Style Vocabulary

The user-facing and internal terms for this feature, settled by domain modeling:

- **Default styles** — the UI name for the user's type-less global style (sidebar row and its dialog). The user's tier is the only one addressable in the UI, so the term needs no qualifier there.
- **Clear Customization** — the destructive reset in both the per-type dialog and the Default styles dialog. Names the action rather than the destination: per-type clears fall through to the user default, user-default clears fall through to the app default. Deliberately silent on where the user lands.
- **Clear All Style Customizations** / **Clear All** — the Settings bulk clear (previously "Reset to Defaults", which became ambiguous once "default styles" named the user's tier). Prose in the confirmation names both tiers and the app defaults.
- **Internal only:** `appDefault*` and `userDefault*` are always qualified at their use sites. The "app" qualifier stays out of UI copy, per the existing **Styles** glossary entry.

The word "default" now appears in the UI only as the *subject* of the user's tier, never on a destructive control — which is what removes the ambiguity.

## User Stories

1. As an explorer of a new database, I want to set one global node style, so that every type looks coherent before I touch any individual type.
2. As an explorer, I want to set one global edge style, so that all my relationships share a baseline appearance.
3. As a user who set a global default, I want a specific type I customize to override that default, so that I can make exceptions without abandoning the baseline.
4. As a user, I want only the fields a per-type style specifies to override the default, so that unspecified fields still inherit my global baseline (partial-patch merge).
5. As a team lead, I want to save a styles file that includes my global default, so that teammates who load it get the same baseline look, not just my per-type tweaks.
6. As a user loading a file that carries a global default, I want that default to appear in the selective import modal as its own before-and-after card, so that I can see what it would change and choose whether to take it.
7. As a user, I want to leave the incoming default unselected in the import modal, so that loading a file never replaces my own baseline unless I ask it to.
8. As a user, I want an incoming default identical to my current one to be left out of the import modal, so that I am not asked to decide about a no-op.
9. As a user, I want to clear my global default from its dialog, so that appearance falls back to the app default.
10. As a user, I want "Clear All Style Customizations" in Settings to clear both my per-type edits and my global default, so that I can start over from a clean slate.
11. As a user, I want the styles sidebar to show a dedicated "Default styles" entry with a live preview of the current baseline, so that I can see and edit the global look in context.
12. As a user, I want the per-type style dialog to show a live preview that matches how the node actually renders on the canvas, so that I can trust what I'm editing.
13. As a user editing a default, I want the dialog to offer only visual fields (shape, color, icon, borders / line, arrows), not per-type attribute or label selectors, so that the global default stays type-agnostic.
14. As a user, I want a per-type "Clear Customization" to fall through to my global default (then the app default), so that clearing lands on my chosen baseline rather than the app's blue.
15. As a user loading a file with an invalid default value, I want the whole load rejected with a clear report, so that I never end up in a half-styled state I didn't author (atomic load).
16. As a user, I want an old styles file without a defaults section to still load, so that older files keep working.
17. As a maintainer, I want the styling vocabulary in code to distinguish the app default from the user default unambiguously, so that the layers are clear to reason about.

## Implementation Decisions

**Model — three-layer cascade.** Following the single-layer collapse (#1974 / #1981), there is exactly one non-app style Source: the user. Styles resolve as:

```
{ ...appDefault, ...userDefault, ...userCustom.get(type) }
```

Because the type-less default spreads before the per-type entry, any per-type value beats any type-less value — ordinary specificity, falling out of the structure. Each layer is a partial patch: only specified fields override.

**No ADR.** The earlier version of this Epic promised an ADR to defend a "specificity beats source" rule, where a *shared* per-type style could override the user's own global default. The shared Source no longer exists, so that surprise is gone and the remaining cascade is unremarkable. The cascade is documented in the **Styles** glossary entry instead; the choice to store defaults as a single object rather than a type-keyed Map is a short amendment to the existing type-keyed-map-atoms ADR.

**Type partition (from a prototype-informed decision).** The style types split into a visual base and a type-only extension:

```ts
type VertexVisualStyle = { color; iconUrl; iconImageType; shape; backgroundOpacity;
borderWidth; borderColor; borderStyle } // all required — the resolved baseline shape
type VertexTypeStyle = { displayLabel?; displayNameAttribute; longDisplayNameAttribute }
// per-type storage = Partial & { type }
// default storage = Partial // visual only, one object (not a Map)
```

Edge mirrors this (`EdgeVisualStyle` = label/line/arrow fields; `EdgeTypeStyle` = `displayLabel?` + `displayNameAttribute`). The default layer stores a single object per entity, not a type-keyed Map. App Default additionally seeds the attribute fallbacks, so it is not a bare visual style.

**New atoms.** Two new storage atoms, one object each: `userDefaultVertexStyleAtom` and `userDefaultEdgeStyleAtom` (keys `user-default-vertex-style` / `user-default-edge-style`). Storage-key convention: `user--styles` (Map) for per-type, `user-default--style` (object) for the default. No existing keys renamed.

**Resolve function** owns the whole cascade — it takes the type plus an options object of layers (`{ userDefault, user }`, the last being a Map) and does its own per-type lookup, keeping merge logic out of the atom getter.

**File format.** The styles file grows an optional `defaults: { vertex?, edge? }` section, sibling to `vertices`/`edges`. Optional, so old files still load and old readers ignore it. Save writes the user default. The parser stays whole-file atomic (one parse, reject the whole file on any invalid value); default entries reuse the per-type visual schema (attribute/label fields omitted) so the icon allowlist and `iconImageType` handling apply unchanged. Default-tier issues are classified with a `"(default)"` sentinel in the issue's `typeName`.

**Loading is opt-in, not layered.** With one Source, a loaded default writes the user default — there is no separate layer to park it in. Non-destructiveness comes from the selective import modal rather than from the cascade: the incoming default appears as its own before-and-after card the user can leave unselected. The modal's item model gains two type-less kinds alongside the existing vertex and edge kinds, so a default's different write target (a single object, not a Map entry) stays visible in the discriminated union rather than being smuggled through a sentinel type. Default cards sit first in the grid, mirroring the sidebar's Default styles row, and identical-style exclusion applies to them unchanged.

**Dialog & preview.** The per-type style dialog moves onto a schema-driven form module (Zod as source of truth) with a shared visual-fields component, and shifts from live per-field commit to buffered save/cancel/reset — enabled by the live preview, which now provides the visual feedback the graph mutation used to. The Default styles dialog is a second instantiation of the same module with a visual-only schema and a different write target. A live node/edge preview renders the resolved style faithfully. A spike proved the preview approach: an SVG renderer using cytoscape's own shape point geometry, with the icon clipped to the shape outline (matching cytoscape's `background-clip`). The dialog's **visual redesign** is deferred to a separate spike — this Epic lands the form foundation and edit-model shift, keeping the existing layout.

**Clear semantics.** The Default styles dialog's **Clear Customization** clears just the user default. Settings' **Clear All Style Customizations** clears per-type styles and the user default together.

## Testing Decisions

Good tests here assert **external cascade behavior**, not the internal merge mechanics — given seeded layers, the resolved style for a type is what the three-layer model predicts.

- **Cascade resolution** (in the styles atom tests): the specificity case (user default red + per-type `{Person: blue}` → Person blue, others red), the partial-patch case (a per-type style setting only `color` still inherits `shape` from the baseline), and the full three-layer stack resolving field-by-field in order.
- **Clear semantics**: the Default dialog's clear and Settings' Clear All each clear the right layers.
- **File round-trip** (round-trip and parser tests): a file with `defaults` presents a default card and loads into the user default atoms when selected; save→load reproduces the default; a file without `defaults` still loads; an invalid default field triggers atomic rejection with a `"(default)"`-scoped issue; an injected `iconUrl` in `defaults` is stripped and a bad `icon` fails the allowlist.
- **Selective import**: the import-plan build emits default items with resolved before/after styles and drops an identical incoming default; the apply step writes default items to the single-object atoms and leaves unselected defaults untouched.
- **Test seams**: extend the existing `DbState` builder with the two new default atoms. Prior art: the existing styling cascade tests, the round-trip/parser suites, and the style-import plan and apply tests.

## Out of Scope

- **Server Default Styles** (a Source fetched from server config) — the model leaves room for it but it is not built.
- Reintroducing any second non-app Source. #1974 retired the shared layer deliberately; the Default tier does not bring it back.
- Rendering-side changes to how the graph canvas insets or scales icons (the preview faithfully reproduces current canvas behavior, warts and all).
- Curating the shape catalog (whether to drop rarely-used `round-*` shapes) — a follow-up decision surfaced by the spike.
- The broader "default config file for new users" (#1265), which also covers connections and prefixes; this Epic delivers only the styling half.

## Further Notes

**Rewritten after the single-layer collapse.** This Epic was originally specified around a five-layer **Source × Scope** cascade (App/Shared/User × Default/Custom). #1974 "Collapse to single style layer and retire shared-styles terminology" and its PR #1981 deleted the shared Source, so the Source axis has collapsed to App and User and the matrix framing has been dropped. Concretely: four new atoms became two, five cascade layers became three, the promised ADR was dropped, the "reset by Source" semantics became one clear-all, and non-destructive loading moved from a dedicated shared layer to opt-in selection in the import modal shipped by #1972.

Delivered as four vertical slices: (1) rename & reshape the style vocabulary, (2) faithful node/edge preview, (3) form-schema foundation + buffered save/cancel/reset, (4) the Default tier itself. Slices 1 and 2 are complete; 3 is in progress; 4 needs 1 and 3. The dialog **visual redesign** is a separate spike, depending on slice 3. Each is a sub-issue of this Epic.

## Related Issues

- Part of #689
- Related to #1265
- Related to #1188
- Related to #97
- Model context: #1974 and #1981 (single-layer collapse), #1972 (selective import modal)

> [!IMPORTANT]
> Internal only — this issue is maintained by the core team and is not accepting external contributions.

Contributor guide

Open the contributing guide

Research direction

Start with the existing styling cascade tests, round-trip/parser suites, style-import plan and apply tests, and the DbState builder mentioned in the issue. Trace the styles sidebar and dialog entry points, then verify that visual defaults resolve beneath per-type styles, save and load atomically, appear in selective imports, and clear through the specified flows.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.