ExplanationStyle and props tools — architecture

Style and props tools — architecture

The Style tab covers roughly a hundred CSS properties and the Props tab well over a hundred HTML attributes. The first two tools (ColorTool, BackgroundColorTool) were standalone components that each took nine props and repeated the same payload/undo code; scaling that pattern would have meant a hundred near-identical components. The current design separates three concerns so each one is written once.

Three layers

  1. Inputs (Components/Inputs/) know nothing about CSS or HTML ownership. They take a string and report a string, plus a live flag during drags. A MeasureInput doesn’t know whether it’s editing padding-top inline or width in .card.
  2. Rows and writers (PropertyRow + useStyleWriter, AttrRow + useAttrWriter) know how to turn “set this property to this value” into actions with correct undo, and add the controls every row shares (remove, CSS-wide keyword, source dot, mixed marker).
  3. Registries (TOOL_GROUPS, ATTR_TOOL_GROUPS) are data: which tools exist, which properties/attributes they cover, which input each uses, and — for HTML — which tags they apply to.

Most tools are pure data in the registry. Code is only written for tools whose properties are coupled: layered values (shadows, backgrounds, transitions, function lists), per-side targeting (border), or behavior beyond one attribute (link destinations, embed presets, head tags). Adding a property is usually one line, and a new input type benefits every field that adopts it.

Context instead of props

Both tabs provide their environment through React context (StyleToolContext, AttrToolContext). The style context’s main job is to hide where an edit goes: InlineStylePanel, MatchedRulePanel and GeneralStylePanel render the same StyleToolsPanel with a different payload/getPayload, and tools never branch on which one they’re in. The attribute context’s main job is to precompute selection facts (shared tag, shared/mixed attributes, page index, id list) once per render instead of in every row.

Routing an edit (Style tab)

GeneralStylePanel.getPayload(property) answers “who controls this property right now?”: inline if set inline, otherwise the cascade winner from matched-rules (resolveDeclarations), with CSS-file rules mapped to classSource: "CSSFile" and <style>-tag rules to "StyleTag" via MatchedRule.origin. Editing the winner keeps the canvas truthful — the declaration you change is the one that renders.

resolveSource/getSource use the same lookup, so the source dot always names the destination of the next edit. onMoveSource is the escape hatch: it removes the declaration from its current source and writes it to the chosen one in a single action, refusing to half-apply if any payload can’t be built.

The forced element state participates in routing: GeneralStylePanel receives forcedState and passes it as activePseudoClasses, so while :hover is forced the cascade winner can be a :hover rule. In neutral it never is — normal edits can’t land in a state rule.

One gesture, one undo step

Drags emit dozens of values per second. ActionManager already merged consecutive inline STYLE-UPDATEs on the same property, but not class-rule edits or attribute updates. Both writers keep the action from the first live call (which is why ActionManager.execute now returns its Action) and update() it until the gesture’s final non-live call. The reverse payload is captured at the start, so undo returns to the pre-drag value. Inputs only have to report live honestly.

Longhands over shorthands

Box editors, the border tool and the corner editor write longhands (margin-top, border-left-color, border-top-left-radius). A shorthand write resets every side it covers, and JCSS keeps declaration order per rule, so appending a longhand after an existing shorthand overrides exactly one side. The cost is more declarations in the stylesheet; the benefit is that a tool never silently changes a value the user didn’t touch.

Where each tab reads from

The Style tab reads computed values from the iframe (getSpecifiedStyle + getComputedStyle) for the Style target — it has to, because “what does this element look like” is only answerable by the browser — and declared values for inline and rule targets.

The Props tab reads the JHTML tree, not the DOM. Attributes are what we serialize, and the DOM can carry things the page doesn’t (data-eid, the browser’s own normalization). Reading JHTML also makes page-wide work cheap and testable: buildPageContext indexes the tree once per change for id lookups, label targets and heading order, and the validation engine is pure functions over that index.

Multi-selection

Both tabs show the intersection: a value is displayed only if every selected element has it. Style shows nothing for disagreeing properties; Props additionally marks them mixed, because an attribute present-but-different is a distinct, useful state (for example, ids). Writes apply to every selected element in one action.

Page head as structure

Page settings are not attributes of the selection; they’re <meta>/<link>/<base> elements that may not exist yet. HeadField treats “set a value” as create-or-update and “clear” as remove-the-element (only when the element exists solely to hold that value), using STRUCTURE-UPDATE ADD/REMOVE with the same keepEID undo pattern as element deletion. The <title> and JSON-LD blocks use TEXT-UPDATE on their text child.

Known gaps

  • Element insertion, reordering, tag changes and raw HTML editing are not part of these tabs; they need structure operations with selection management.
  • Page checks can’t select the element they point at; RightPanel doesn’t receive the selection setter.
  • GroupSourceBtn inside LayoutTool is hidden because the context has no class-list getter.