ExplanationStyle controller — class editing

Style controller — class editing

The Classes component (apps/frontend/src/app/(dashboard)/editor/[projectId]/[pageId]/Components/StyleController/Classes.tsx) is one of the leaves of the style panel. It looks small but has a few subtleties worth recording, because each one was a deliberate choice and “fixing” it without context will likely regress something.

Selection and DOM access

The component takes selectedEID: string[] and an iframeDoc: Document. Every read and write resolves elements with a data-eid attribute selector against the iframe document:

iframeDoc.querySelector(`[data-eid="${eid}"]`)

There is no React state for the markup itself — the iframe document is the source of truth, and renders are driven from props. This is consistent with the rest of the StyleController and means that anything else mutating the iframe (a peer’s edit arriving over SSE, an undo, the Action panel) will be reflected here on the next render without us doing anything explicit.

Intersection-by-occurrence for multi-select

sharedClasses is computed as the multiset intersection of each element’s class list. We don’t just take “classes present on every element” — we count occurrences and keep the minimum across the selection. This means if element A has btn btn and element B has btn, the panel shows one btn badge: removing it removes one occurrence from each element, leaving A with one and B with zero.

The reason for that rule is that the alternative semantics are worse:

  • Set intersection (no occurrence count) would silently drop information about duplicates and lead to confusing remove behavior on multi-select.
  • Set union would let users “edit” classes that not every element has, which violates the panel’s invariant that what you see is what you’re editing across the whole selection.

The duplicate detection (duplicateClasses) is a second pass over sharedClasses and exists to render the amber border / tooltip.

Why replace, not replaceAll, in removeClass

removeClass uses currentClasses.replace(className, "") — single replacement. There is a comment at the call site explaining this; do not “modernize” it to replaceAll. If a class is duplicated on an element, the user gets two badges for it; clicking the × on either one should remove a single occurrence so the other badge stays. replaceAll would wipe both at once and the second click would be a no-op against an already-clean attribute.

updateClass (rename) deliberately uses replaceAll for the opposite reason: a rename should affect every occurrence the user is looking at.

ActionManager integration

Every mutation builds parallel doPayloads and reversePayloads arrays of AttributeUpdateActionPayload and submits them in one ActionManager.execute(do, reverse) call. That gives us:

  • One undo step per user gesture, regardless of how many elements were selected.
  • Symmetric reverse payloads — the reverse always restores the exact pre-edit class string (not “remove the class we added”), which is robust against the class having been touched by another concurrent edit between the do and the undo.

The reverse payload’s value is currentClasses before any modification, captured per-element. Don’t refactor this to compute the inverse from the do payload; the asymmetry is the point.

Validation

The class-name regex (/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/) is the same for add and rename. Failing validation on commit silently discards the input — we don’t surface an error toast because the red border on the input is enough feedback for a panel-level interaction. If we ever extract a generic “tag chip” component, the validator should be passed in as a prop, not baked in.

Things that are not concerns of this component

  • Whether the class is defined anywhere — adding does-not-exist-yet is fine; the corresponding stylesheet is the user’s problem.
  • CSS specificity, cascade, or computed style. Other parts of the StyleController handle inspected style; this one is purely an attribute editor.
  • Persisting to the backing JSON. ActionManager.execute is the boundary; once the payload is submitted, this component is done.