SpectreSkills
← All skills

flow-builder-inspector

v1.0.0

Build the right rail of a flow builder — one slide-out that takes turns holding the step inspector, the step palette, flow details, the test harness and history — plus per-type config forms with a field picker and a JSON escape hatch. Trigger when building the panel, palette, or node config forms of a flow builder.

Flow Buildersflow-builderformsinspectorpanel
Install
npx @spectre-apps/skills add flow-builder-inspector

Writes .claude/skills/flow-builder-inspector/SKILL.md. Add --user to install globally.

What it does

The builder's rail and its forms

One column, one width, five things it can hold. At 1100px there is no room for a canvas and two panels, so the inspector, the palette, flow details, the test harness and history take turns in the same slot — and when none of them has anything to say, the rail closes and the canvas gets the whole viewport.

type Rail = "builder" | "test" | "history";
type PanelTab = "step" | "add" | "flow";   // what the builder rail holds

Rules the rail obeys

  • One width for all of it (412px). The rail slides between contents without closing, and a width that changed with the contents made the edge jump.
  • It slides, it does not appear. Animate the outer box's width between 0 and the panel's own and clip the overflow; the panel inside holds full width throughout, so the contents ride in from beyond the right edge instead of reflowing a 412px form down to nothing and back.
  • Keep the outgoing contents mounted for the length of the slide. Rendering straight from rail unmounts the panel on the same frame the width starts animating, so only an empty gap slides.
  • Which view is showing is decided by what you did on the canvas, never by a control in the rail: selecting a step opens Step, a canvas plus opens Add step, the header menu opens Flow details. A tab strip would only restate the click that just happened, so the head names the view instead.
  • Selection moves the rail with it. Landing a step opens the inspector on it; removing one closes the rail rather than holding 412px open on "pick a step" over the gap the step used to fill.
  • Selecting a step means you are done aiming — cancel any pending placeholder on selection.

The head is the only place identity appears

One 44px line: the family swatch, the step's name, its node id, and the close button. The inspector below repeats none of it. (A category tag plus a node id plus a 22px title under a head already saying TRIGGER prints the word three times in the first 100px of the panel.)

Set a thing's name in the body font and a view's label in mono uppercase — the same rule the flow-name input in the top bar follows. Every rail shares this header component, so the close affordance sits in the same band whichever one is open: a slide-out with no visible close is a trap.

Add step: one column of described rows

┌─────────────────────────────────────────────┐
│ INSERTING AFTER RULE BRANCH · PROMO   Show all │  where it lands, and unfilter
├─────────────────────────────────────────────┤
│ ✦ INTEGRATIONS                              │
│ ▌[T] Send an SMS                            │
│      Texts the subject through Twilio.      │
│      Sends through Marketing SMS            │
│ ▌WAIT                                       │
│ ▌[⏱] Wait                                   │
│      Fixed delay. Suspends the walk durably.│
└─────────────────────────────────────────────┘
  • One column, with the description on the row. Two columns of bare labels is the wrong trade at this width: "Frequency guard" and "A/B split" are not self-explanatory, and an explanation parked in a title attribute is invisible on touch and a second-long hover away on a mouse.
  • A 34px glyph carries the verb (send, wait, split, look up); the row's 3px left rule carries the family colour — the same rule the card on the canvas has, so a row and the card it lands are visibly the same thing.
  • Filter by the family the canvas picker chose, and offer Show all to widen without losing the landing site.
  • Caption the landing site from the pending placeholder — "Inserting between Wait and Deliver", or "Appending to end of flow" when nothing is pending.
  • Presets sit in their own group with the vendor's mark, and carry their readiness as a third line: "No twilio.sms destination configured yet" (disabled), "Sends through Marketing SMS", or "3 destinations — pick one after adding". A preset that cannot deliver reads as a bug in the builder rather than as missing setup.
  • Empty state that is actually true: when nothing is pending and every outlet already leads somewhere, say so — "Use the + on a connector to put a step between two others."

Bucket the catalog by category once, at module scope, and iterate the category order — not per render.

The inspector

A registry of per-type forms, and a fallback that is always available:

const FORMS: Partial<Record<NodeType, (p: FormProps) => React.ReactNode>> = {
  trigger: TriggerForm, "conditional.rule": RuleForm, "transform.remap": RemapForm,
  deliver: DeliverForm, wait: WaitForm, wait_until: WaitUntilForm,
  "ai.generate": AiGenerateForm, fetch: FetchForm,
};

const raw = !Form || showJson;   // no dedicated form → JSON is the whole view

A missing form is never a dead end. The JSON editor is how you configure a node whose form does not exist yet and how you debug one whose form is wrong, so Edit as JSON sits at the foot of every form — an escape hatch, not a second view competing with the panel's own head. Parse on every keystroke, show the parse error inline, and only lift valid JSON into the config.

Lead the form with the block's own one-line description from the catalog — the one thing the head cannot say is what the step does.

Config edits flow through one patch function so the shape-repair rules (renameOutlets, see flow-builder-canvas) run on every change:

set: (patch) => onChange({ ...(data.config ?? {}), ...patch })

Key the inspector on the node id so switching steps remounts the fields instead of carrying one step's local state into another.

The field picker

Every template input carries an inline picker listing the vars paths available at this node — typed, attributed to the step that contributes them, and including paths from a branch that cannot be reached, marked upstream. Clicking splices {{path}} at the caret and restores the selection.

// pathsAt() runs the whole flow, and a keystroke in a config field changes the
// graph. Defer it: a picker listing one field late is not something anyone
// notices; re-simulating on every character is.
const deferredGraph = React.useDeferredValue(graph);
const paths = React.useMemo(() => pathsAt(deferredGraph, SAMPLE_EVENT, nodeId), […]);

Form primitives worth sharing

  • ListEditor — the shape behind branches, mappings, traits and fetch sources: numbered rows, a trash button per row, one Add button, and an empty line that says what happens with none ("No branches yet — every event will leave via default").
  • DurationInput — a number plus minutes/hours/days, picking the largest unit that divides the value exactly. Never make someone type milliseconds.
  • ValueSourceEditorCopy a field / Build from a template / A fixed value, sharing one valueSourceMode() with the engine so the form cannot show one mode while the engine reads another. Writing a mode clears the other two.
  • PredicateEditor — a field select from the picker's paths, the operator list from the shared schema, and a value input hidden for exists / not_exists. Coerce a numeric-looking value to a number so gt works.
  • A multi-select over a catalog with custom values for event types: the catalog is a convenience, not a constraint — a topic shipped after your list was written still goes in, and gets the neutral "custom" mark so it is visibly unattributed.
  • Reference pickers, never uuid fields. Load connections and destinations on the server and pass them down; a select that says "No shopify connection yet" is the whole reason a row is empty.

Write hints that state the engine's actual behaviour, because that is what someone is checking:

Sources run at the same time. If one fails, the others still land — its field is left empty.

Checked top to bottom; the first match wins. Anything unmatched leaves via default.

The graph is the complete picture of fan-out — there is no account-level fallback.

The step footer

Duplicate and Remove, side by side, and both absent for the trigger — one entry point, so it can be neither copied into a second trigger nor deleted. Duplicate splices the copy in after the original (splitting the edge already there, or extending a free outlet): an unwired clone beside the source is a validation warning, not the second send that was asked for. Remove routes through the shared confirmation — see destructive-action-confirmations.

The header bar and its banners

A 60px bar under the app chrome: back link, the flow-name input (the one piece of content here, so the one piece not in mono), a status select with its dot, v3 · r41, and an Unsaved chip that is the bar's only accent — and only while it means something.

Keep Save in the bar and put everything else behind one overflow menu (Test run, History, Flow details, Format layout, Flow runs, Export JSON). Use checkbox items for the three that open a rail: a menu that cannot say the rail is already open makes "Test run" read as "run the test again".

Stack the banner strip under it, in this order: unparseable graph, save error, validation errors, up to three warnings, then the transient message. Say what to do, not just what happened —

This flow's graph could not be read, so it will not match any event. Fix it here and save to recover.

Drafts, dirty state, and saving

// Both the dirty check and the draft write live INSIDE one debounce: a full
// graph compare on every drag frame is a dropped frame.
React.useEffect(() => {
  const t = setTimeout(() => {
    const current = serializeGraph(nodes, edges);
    const changed = name !== flow.name || status !== flow.status
                 || graphsDiffer(flow.graph, current);
    setDirty(changed);
    changed ? sessionStorage.setItem(draftKey, JSON.stringify({ name, status, nodes, edges }))
            : sessionStorage.removeItem(draftKey);
  }, 400);
  return () => clearTimeout(t);
}, [nodes, edges, name, status, /* … */]);
  • Restore a draft, never save it implicitly. Say so with a "Draft restored" chip, and let the person decide.
  • Wrap every storage call in try/catch — private browsing disables it, and editing must still work.
  • The dirty badge uses the same graphsDiffer as the server's write gate, so when the server no-ops the badge can actually clear. Report both cases honestly: "Saved" versus "No changes to save".
  • ⌘S / Ctrl+S saves.
  • Drop the draft on save and before a history restore — otherwise the restore effect puts the old edits straight back over the revision just restored, which reads as "restore did nothing".