You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Plugins own their tokens. No token can exist without a plugin declaring it.
createGraph assembles the token universe. The set of valid tokens is a function of which plugins are active.
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.
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)constredirectMap={'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
Vision
The theme system is currently monolithic:
GraphThemeis a single hardcoded type defined inplugins/canvas, presets are defined against it, and plugins likefocuswork around its rigidity via hacks (see The Hack and Why It Failed below). This issue proposes a ground-up redesign around three principles:createGraphassembles the token universe. The set of valid tokens is a function of which plugins are active.Naming Boundaries
StyleValue
The raw, resolved value that gets painted on the canvas (e.g.
stringornumber). This is the leaf type that the renderer ultimately consumes.ThemeValue
A
StyleValuewrapped for the override system. Either a staticStyleValueor a getter that may returnvoidto 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.colordoes 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 declaresnode.focus.*,edge.focus.*. The anchors plugin declaresnodeAnchor.*.ComposedTheme
The intersection of all
{Plugin}Themetypes for the active plugins. This is the typecreateGraphassembles and whatresolveToken'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 includesnodeAnchor.*fields implicitly declares a dependency on the anchors plugin, enforced by the type system.TokenResolver
The function
resolveToken(token, ...args)parameterized onComposedTheme. CallingresolveToken('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 itsnode.focus.*counterpart for focused nodes:This let
nodeCircle(the shape defined in the preset) callresolveNodeStylesagainstnode.default.*tokens and silently get focus styles when a node was focused all withoutnodeCircleneeding 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,findLastpicked 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
node.focus.*/node.default.*redirect map in the focus plugincreateThemeOverridesas a hardcoded function inplugins/canvasFocusGraphTheme,NodeAnchorGraphTheme,MarqueeGraphThemedefined inplugins/canvas/themes/types.ts— these move to their respective pluginsplugins/canvas/themes/presets/move to product-land_resolveTokentech debt tracked in [graph] proposal for centralized dynamic theme resolution API #574 (this redesign supersedes it)Related