Skip to content

[graph/themes] plugin-owned tokens, product-land presets #712

Description

@Yonava

Vision

The theme system is currently monolithic: GraphTheme is a single hardcoded type defined in plugins/canvas, presets are defined against it, and plugins like focus work around its rigidity via hacks (see The Hack and Why It Failed below). This issue proposes a ground-up redesign around three principles:

  1. Plugins own their tokens. No token can exist without a plugin declaring it.
  2. createGraph assembles the token universe. The set of valid tokens is a function of which plugins are active.
  3. Presets live in product-land. A preset is a complete concrete value satisfying the assembled token type and therefore cannot be defined before that type exists.

Naming Boundaries

StyleValue

The raw, resolved value that gets painted on the canvas (e.g. string or number). This is the leaf type that the renderer ultimately consumes.

ThemeValue

A StyleValue wrapped for the override system. Either a static StyleValue or a getter that may return void to defer ("fall through") to the next override layer.

ThemeToken

A dot-notation string that identifies a single styleable property (e.g. "node.default.color", "node.focus.color", "nodeAnchor.default.radius").

Tokens are determined by which plugins are active, for instance node.focus.color does not exist if the focus plugin is not loaded.

{Plugin}Theme (FocusTheme, AnchorsTheme, CanvasTheme etc)

The shape a plugin declares as its theme contribution. The canvas plugin declares node.default.*, edge.default.*, canvas.*. The focus plugin declares node.focus.*, edge.focus.*. The anchors plugin declares nodeAnchor.*.

ComposedTheme

The intersection of all {Plugin}Theme types for the active plugins. This is the type createGraph assembles and what resolveToken's token parameter is constrained to.

Preset

A complete concrete value satisfying ComposedTheme<ActivePlugins>. Presets live in product-land or product-adjacent packages and cannot be authored until the composed theme type is known. A preset that includes nodeAnchor.* fields implicitly declares a dependency on the anchors plugin, enforced by the type system.

TokenResolver

The function resolveToken(token, ...args) parameterized on ComposedTheme. Calling resolveToken('node.focus.color', node) is a type error if the focus plugin is not in the active plugin list.


The Hack and Why It Failed

Currently the focus plugin works by registering overrides that redirect every node.default.* token to its node.focus.* counterpart for focused nodes:

// focus/index.ts (current)
const redirectMap = {
  'node.default.color': 'node.focus.color',
  'node.default.borderColor': 'node.focus.borderColor',
  // ...every node and edge token
}

This let nodeCircle (the shape defined in the preset) call resolveNodeStyles against node.default.* tokens and silently get focus styles when a node was focused all without nodeCircle needing to know about focus.

Why it broke: the redirect ran as a theme override. When a consumer added their own override on node.default.color, findLast picked the consumer's override instead and the focus redirect never ran. Focus tokens stopped working for any node that had a consumer override, with no indication of why. The consumer had no way to know they were breaking the focus system because the dependency was invisible.

The root cause: focus is not a default-layer concern. It is a separate state dimension. Hiding it in the override stack created a fragile implicit ordering dependency that consumers couldn't reason about.

What Goes Away

  • The node.focus.* / node.default.* redirect map in the focus plugin
  • createThemeOverrides as a hardcoded function in plugins/canvas
  • FocusGraphTheme, NodeAnchorGraphTheme, MarqueeGraphTheme defined in plugins/canvas/themes/types.ts — these move to their respective plugins
  • Presets defined under plugins/canvas/themes/presets/ move to product-land
  • The _resolveToken tech debt tracked in [graph] proposal for centralized dynamic theme resolution API #574 (this redesign supersedes it)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    beast 😈Expect a challenge. This is a beast!high priority 🐯This needs to be addressed urgentmagic graphs 📊Pertaining to packages/graph in the Magic Graphs mono-reporefactor 🧱This is about refactoring existing features

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions