Skip to content

feat(platform): draw automations on a canvas that lays itself out - #4621

Open
yannickmonney wants to merge 77 commits into
mainfrom
feat/automation-canvas
Open

yannickmonney wants to merge 77 commits into
mainfrom
feat/automation-canvas

Conversation

@yannickmonney

@yannickmonney yannickmonney commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What changes

Automations get a canvas that lays itself out and reads like the run it describes: Start and End, conditions with Yes/No branches, frames for loops, every possible path and why it is taken. Every field gets a real code editor. Both are reusable @tale/ui components with ui-docs guides, demos and stories.

@tale/ui

  • CodeEditor (CodeMirror 6, loaded lazily): code, JSON, YAML and {{ … }} templates.
    • Problems inline, with F8 to step through them; completion and hover types from the inferred shapes; an expandable editor.
    • Keyboard: Tab indents; Esc then Tab leaves; a widget can claim Escape inside dialogs and sheets.
    • One AA code palette shared with Shiki highlighting in both themes.
    • Template chips in prose hold their monospace font to Inter's x-height.
  • WorkflowCanvas: ELK layout in a Web Worker (main-thread fallback), routed edges that never cross a box or label, node kinds (Start, End, step, gate), loop frames.
    • Motion: animated relayouts, a changed-node ring, a running bar, and pulses that follow the run in time order. Every animation is absent under reduced motion.
    • One tab stop with arrow-key navigation along edges, a List view as the text alternative, and 44 px touch targets.
  • Paths and playback: FlowPathList and path highlighting (point to preview, Enter to pin); FlowPlaybackBar replays a run's details, waits and dots as they happened.
  • Also new: SchemaTree; VendorIcon moved into the package.

Platform

  • The automation canvas:
    • Start shows the trigger in words, its next run and the inputs.
    • End shows the output's shape and the ways a run ends.
    • Each when gets a condition in words, and an elseOf pair gets Yes/No; nodes that may not run are dashed.
    • The run page opens a failed run on its failure.
    • Stored ui.positions are ignored: the canvas always lays itself out.
  • Code fields: every node field edits in the code editor, with completion from the analysis (P1): the scope roots per field, upstream nodes only, typed properties. A run's input is typed in the code editor too.
  • Source: the raw document as read-only YAML, lossless. An editor save keeps every key of the document.
  • Connector actions: all 121 shipped actions have titles in en/de/fr (with a guard test), and the catalog serves connector icons and translated names.
  • Docs: EN/DE/FR, the coding-agent page (its Edit with your coding agent button beside main's MCP guidance), manual boxes, glossary terms, screenshot manifest entries.

Verification

  • @tale/ui:
    • Unit 3,712 pass; one flaky file passes alone.
    • Browser (axe light/dark included) 471, plus 41 in two files that only failed to load under machine load and pass alone.
  • Platform:
    • UI (automations, settings) 2,525.
    • Browser (automations) 86.
  • Docs suite 685.
  • Typecheck, lint:manual, lint:links clean.
  • bun run build with the entry budget:
    • Cold load: 7 modules, 807 KB gzip, with neither ELK nor CodeMirror in it.
    • The lazy code editor: 202 KB gzip (budget 230 KB).
    • ELK loads through its worker; the 441 KB main-thread build loads only if the worker fails.

Notes

  • Not done: the S8 screen-reader pass (VoiceOver/NVDA), the recorded performance numbers (layout p50/p95, per-keystroke latency; the 40-node worker timing is covered by a test), and the four adversarial reviews (layout, motion/design, a11y/perf, contract/i18n). The weekly agent limit stopped them; they follow in a polish PR.
  • Screenshots are declared in the manifest and captured in the next docs-shot run.
  • The 121 connector action titles have not had an independent native-language review.

Review fixes (adversarial review, 2026-10-09)

An independent review found no stored-document corruption; four of its findings are fixed here with tests:

  • A failed CodeEditor chunk crashed the Editor tab and lost the draft (high). The field now holds the failure: the value goes on in a labelled plain text area, with a retry on a fresh lazy component; the editor preloads with the automation editor.
  • Undo after a discard replayed the discarded edit into the text the discard put back. An outside replacement while the reader is elsewhere is now a hard edge for undo.
  • a && (b || c) and (a && b) || c read the same in words. A nested group of the other kind reads in brackets.
  • The phone inspector carried half-typed text to the next node. Its fields are keyed by node, like the desktop panel's.

Still open from the review (low): condition problems in the List view rows, Start/End heights moving when the check answers, diagnostics positions in reformatted JSON, "is set" wording for truthiness, and node re-renders per keystroke.

An action declares an English title and per-locale overrides
(i18n.<locale>.title), and a connector named with ordinary words its
localized display names (i18n.<locale>.displayName), on the locale
grammar every declared text already uses.
Each of the 121 shipped connector actions gets a sentence-case title
with German and French overrides, and the seven connectors named with
ordinary words (Tasks, Documents, Conversations, …) their German and
French display names. A guard fails a shipped action without its
titles, a sloppy one, a duplicate, and a new connector that is neither
a listed brand nor translated.
GET /automations/catalog/node-types answers, per connector action, its
connector and its title with the German and French overrides, and once
per connector its display name, overrides and shipped icon as a data
URL. The registry carries the display data on the engine's connector
surface; the app parses the answer with one zod schema instead of
casting it, and the editor and run page read its nodeTypes.
The connector contract pages in English, German and French list the
action's title and its i18n overrides, and say when a connector carries
translated display names and when a brand keeps its own.
Read-only code (chat, docs, the guides) used Shiki's min-light and
min-dark themes, whose comments, parameters, constants and string
expressions fell below 4.5:1. The --code-* variables in globals.css now
hold one palette, corrected to AA on every code surface in both themes,
and Shiki reads it through its css-variables theme: one highlight serves
light and dark, so a theme switch no longer tokenizes again.

Template-aware grammars (tale-template, json-template, yaml-template,
markdown-template) highlight {{ js }} expressions as JavaScript, and
only under their own roots, so plain YAML keeps Helm or Jinja braces as
text. code-palette.test.ts measures every token on each surface.
A widget that uses Escape itself, such as a code editor closing its
completion list, sat inside dialogs, sheets and popovers whose layer
closed on the same key: Radix hears Escape first, in the capture phase.

A widget now marks itself with data-claims-escape while it needs the
key, and every @tale/ui overlay passes its onEscapeKeyDown through
respectEscapeClaims, which keeps the layer open for a claimed Escape.
A code editor's text is a contenteditable element, which <label for>
cannot name or focus. Field now gives its label an id and names a child
that declares fieldLabelling = 'labelledby' with aria-labelledby; Label
focuses a target that is not labelable when it is clicked.

A go-to request for a part of a registered anchor (/nodes/0/input/to on
/nodes/0/input) now reaches a custom target with that part: the rest of
the path and the range, which is offsets into the part, so a JSON or
YAML editor can select it. Element targets ignore it, as before.
@tale/ui/code-editor is the one control for code-ish fields, on
CodeMirror 6 behind a lazy chunk: the light module draws the frame and a
same-size placeholder, registers with IssueFocus and queues focus until
the editor arrives, so a page without a code field never loads it.

- Languages: a script, one expression, JSON, YAML, Markdown (from
  @lezer/markdown, so no HTML or CSS language comes along), templates and
  text. {{ js }} templates parse as JavaScript inside text, JSON and YAML
  strings and Markdown, drawn as one tinted chip; typing {{ opens a pair.
- Colours: the --code-* palette Shiki reads; a parity test compares the
  role of every character with the read-only highlight.
- Keyboard: Tab indents, Esc then Tab leaves, Mod-Enter submits; a
  one-line editor never takes Tab. A claimed Escape is handled before any
  overlay, so the first Escape in a sheet never closes it.
- Field and IssueFocus: named by its label, described by the field's
  problems, and a go-to lands on the exact range, also inside a JSON or
  YAML value (locate*Pointer).
- Every CodeMirror phrase is translated (en, de, fr, de-CH); guards keep
  one copy of @codemirror/state and view and refuse the language bundles.
- Problems: the host's (a check's results, placed on the text it saw
  and mapped through later edits), a host's instant lint provider, and the
  editor's own syntax marks (JSON or YAML that does not parse, an unclosed
  {{, unreadable JavaScript), merged in one state field. Underlines say the
  severity by style, the gutter by glyph; the tooltip shows the message,
  the host's detail, the code and fix buttons. F8 / Shift-F8 walk them and
  read each aloud; Mod-. applies a fix; a summary describes the field
  unless a Field already lists its problems.
- Completion from the host's provider with the member chain, a JSON or
  YAML pointer and the region the cursor is in; names that are not
  identifiers go in as ["a b"]; Tab or Enter accepts; the list keeps
  inside a sheet and Escape closes it before the sheet.
- Hover types after 200 ms, and on Mod-K Mod-I, read aloud.
- Expand opens the field in a large dialog and brings the caret back.
- Every new string in en, de, fr and de-CH.
SchemaTree lists the fields a value has the way a form reads: each
field's name, its kind in words (text, a number, list of objects, one of
“open” or “closed”), whether it is required, a host's tag (from the
trigger) and whether it may be empty; compact for a summary, comfortable
with nested fields and descriptions, and the same shape as TypeScript
behind a disclosure. schemaKindLabel gives the kind alone. French carries
the preposition in the plural kinds, so a list of objects elides.

VendorIcon moves unchanged from the platform's credential settings to
@tale/ui/vendor-icon, so automation nodes can show a connector's icon.
Shiki stops tokenizing a line after 500 ms and leaves the rest of it
plain. The first highlight in a language compiles its grammar, which on
a loaded machine took that long, so the palette test saw a comment as
plain text and failed. Both highlight tests now pay for the grammars on
other snippets first.
A page that shows no code field, or only imports locate, template-scan
or providers, must never download CodeMirror. The guard walks the
static imports from each public code-editor module through the
package's own files and refuses any CodeMirror or Lezer module, or the
editor view itself, which loads only through a dynamic import. A walk
from the view proves that it would catch one.
Read-only code now colours through the --code-* variables, so one
highlight serves light and dark. The document and skill asset previews
still asked again for the min-* theme on every switch, blanked to plain
text until the answer came back, and their tests expected the old
min-* class. They now highlight without a theme and keep it across a
switch; the tests check the one palette in both themes.
ui.tale.dev gains a Code editor guide (fields, languages, templates,
problems, completion and types, the keyboard, read-only code, go-to and
every prop) with six live demos, and a Schema tree guide with one. The
Dialog guide says how a widget claims Escape, Colors lists the --code-*
roles and the contrast they keep, and Icons shows VendorIcon with its
fallback. Stories cover the three components; the package README lists
them.
CSS reaches most animation through motion-safe and the global
reduced-motion rule; a React Flow viewport ease, a Web Animations call
or a caret's blink runs from script and must ask itself. The hook and
its one-off reader name the query once, and the code editor reads its
caret blink through it.
A FlowGraph is plain data a host builds from its document: Start,
steps, gates, End, the edges between them and a frame round each
iterating step. layoutFlowGraph hands it to ELK (layered, orthogonal,
model order, one port per edge end) and answers absolute boxes, frame
headers, axis-aligned routes, label boxes and rows. A graph already on
screen is laid out again with position hints, so no row changes order.

ELK runs in elkjs's own same-origin worker, falls back to the bundled
build with one warning when the worker cannot start, and to a single
column when ELK fails or takes five seconds. Sizes are a pure function
of the graph; fresh layouts are cached for the session.

Measured on the fixtures (shipped Triage, review and inbox automations,
a when/elseOf/else-if/repeat graph, a gate on the input, 40 nodes, a
cycle): no edge crosses a box or a frame header, no label overlaps,
five scripted edits keep every row in order. Pinning Start and End to
their own layers cost crossings (2 against 0 on 40 nodes) for no
guarantee the graph does not already give, so they are not pinned.
…inds

WorkflowCanvas renders a FlowGraph from data on its own layout: Start
with its triggers and inputs, steps with type, returns, chips and what
they read, conditions as pills with Yes and No, End with what a run
returns and how it ends, dashed frames round iterating steps. Every
edge follows its ELK route with rounded corners and a tinted arrow;
none is React Flow's handle-to-handle curve any more.

The chart is one Tab stop: arrows follow the lines and the rows, Home
and End jump, Enter opens. Each node is a real button named and
described in words (position, neighbours, conditions, reads); the
List view (FlowStepList) says the same as text, and FlowLegend explains
the marks. FlowNodeStatus is the one run-state vocabulary.

FlowCanvas loses the unused AI toggle, gains the auto fit policy, top
corners and corner actions, and eases viewport moves out (quint) over
the duration tokens, instantly under reduced motion. The No branch
takes --flow-edge-negative (amber-700 in light, 2.2:1 amber failed).
Package strings live under flow in en, de and fr, with de-CH for ss.
Knip found constants and helpers the flow modules exported but nothing
outside them reads: the box and gate size tables, the frame header id,
the row grouping, the node ring and lift classes, the chip pill and the
contrast helper's internals. They stay module-private; an unused frame
header height and end-direction helper go.
Three 40-node graphs laid out in the worker in Chromium, after one
warm-up layout, must each finish under 1.5 s (an order-of-magnitude
guard for a loaded runner; locally 45 to 84 ms). The times are logged.
React Flow turns pointer events off on an edge nothing selects; the
routed edge's wide hit path takes them back. Hit-testing the middle of
Open issues → Report finds the path whose title carries its detail.
ui.tale.dev gains a Workflow canvas guide after Flow node issues: the
graph a host builds, the routed lines and their kinds, frames, Start
and End, problem markers, the List view, the keyboard, fit and touch,
layoutFlowGraph and flowNodeSize, and every prop. Six live demos show
a laid-out workflow, a route round a frame, frames, Start and End,
problems and the List view; Colors explains the lines' tokens,
including --flow-edge-negative. Stories mirror the demos on the shared
fixtures, and the package README names the new imports.
An arrow key moved focus with focus(), whose scroll-into-view shifted
the clipped canvas frame under the chart before the canvas could pan.
Focus now moves without scrolling and the canvas pans the box in with
the least move. A browser test walks to End and back to Start through
revealId on a frame too short for the graph.
The list opens on "nodes" while the test types; under a loaded full
browser run the update after the dot landed after the test read the
options and it failed once (it passes alone). The test now waits for
the list to show what follows the dot.
A host that controls the view puts its own switch in topStart; the
List view dropped it, so the reader could not switch back. The list's
top row now holds topStart on the left and topEnd on the right.
onLayout fires once per layout, whatever the callback's identity.
A workflow's possible paths are plain data now (@tale/ui/flow/paths):
which nodes run on each and how each condition decided. Pure helpers
turn them into the highlight a canvas draws — every path, the paths
through one branch of a condition (or, when the host could not list
them, what lies below the branch), a set of nodes in the error tone,
a node's own lines — Start and End on every path, a Yes or No line
only where its condition decided that way.

FlowPathList is the list a reader explores them with: pointing at or
focusing a row previews its path, Enter, Space or a click pins it and
says so once, Escape or Show all unpins. Rows that are not paths (the
nodes that end a run when they fail) activate instead. One Tab stop
across every section, arrows and Home/End between rows, and while a
path is pinned the list claims Escape from a sheet around it.
@tale/ui/flow/playback carries a run onto a workflow chart: an overlay
(where each node ended, no time) or a playback timeline (spans of work,
values travelling the lines, wait and failure marks) at the host's
moment t. flowStateAt and flowStateFromOverlay derive the one frame a
chart draws — each node's state, items and passes, each condition's
decision, which lines were travelled or not taken, and the first
failure with the way the run took to it.

buildPlaybackTimeline compresses a recorded run piece by piece (a
step plays at least 240 ms, a gap at most 1.2 s, a long wait becomes
a mark with its real length), gives each value time to reach its
target, and maps playback time back to real time both ways.
usePlaybackClock drives it: it opens on the end of the run, plays at
0.5x to 4x, follows a live run's end, and under reduced motion steps
from event to event instead of sweeping.

FlowPlaybackBar is the replay's control row: play or pause, previous
and next event, a named scrubber that says where it is in words, event
ticks with failures in the error red and waits hatched, the time, the
speed (a menu on a phone) and Live with Follow live. Fixtures of a
failed Triage run and a branching run with a wait ship in
@tale/ui/testing/flow.
A graph that changes on screen (same layoutKey) now glides to its new
layout: what leaves shrinks out at its old place (150 ms), what stays
moves on its node wrapper's transform (300 ms, out-quint), what joins
grows in after 150 ms and new or re-routed lines fade in last at
250 ms, settled in 450 ms. The canvas draws the graph its layout was
computed for until the next one lands, so nothing vanishes before it
can leave. A live refit eases with it; the open node is brought back
into view if it moved out. Nodes changed outside this tab (changed)
ring once after their relayout. A new layoutKey, the first layout and
reduced motion play none of it.

overlay and playback put a run on the chart: each node framed and
worded by its state (a running node's top bar sweeps, a failed one
gets a red edge and its error line), conditions show how they decided,
travelled lines take the emphasis colour, lines not taken step back, a
frame counts items or passes, and a dot rides each line a value is on
(a paused Web Animation set from the host's moment; none under reduced
motion). A failed run brings the way to its first failure forward;
strips say "The run stopped here" without an error line.

paths, highlight and onHighlightChange bring parts of the chart
forward: a pointer resting on a condition or a Yes/No label lights its
paths, a node under the pointer or the keyboard its own lines, and a
host highlight wins and is announced once. Outside a highlight boxes
are dashed on a muted surface with their reason in the strip and lines
thin to 1 px; colour and width never animate, two stacked lines
crossfade. The List view says the same: state glyphs, run words first,
quiet rows dashed with their reason.
Guides for the possible-paths list and run playback, the canvas guide's overlay and relayout sections, four live demos (static overlay, possible paths, playback with its bar, live relayout) and the matching stories.
Escape only unpinned from a path row. With focus on Show all, the
list's Escape claim kept a sheet around it open and nothing unpinned,
so the key did nothing. Escape now unpins from Show all as well and
hands focus back to the row that was pinned.

Unpinning, with Escape or Show all, also ends the preview, so the chart
shows every path again. Before, focus landed back on the pinned row and
previewed its path, so Show all went on showing that one path. The row
keeps the focus without previewing; the next arrow key previews as
usual.
A replayed step showed its recorded detail, such as its 1.2 s duration,
while it was still running at the moment shown. A span's detail now
shows once the replay reaches the span's end. A span still open on a
live run shows its detail at once, because there the host's words
describe the present.

A wait's hatched band on the scrubber ran only to the next event, so
work going on during the wait cut it short. buildPlaybackTimeline now
gives a wait mark its end, and the bar draws the band to it. A wait
still open has no end, and its band runs to the end of the timeline.

Two values travelling one line at once, or two marks at the same
moment, no longer share a React key, so each keeps its own dot or tick.
The editor guide in English, German and French now reads the canvas as it ships: Start and End, node faces, conditions with Yes and No, frames and line kinds, the List view and keyboard, versions that arrive live, the Paths list, the inspector's When it runs and Fields, Shape and Last run tabs, the code editor, Start inputs and End output, the read-only Source view and the coding-agent entry. Problems in the inputs, output and tests now open Start, End and Source.

Concepts explains the paths a run can take and how the canvas draws control flow; execution logs describes the failed-run focus and the canvas's state words; the authoring page makes the coding agent the way in. German pages say die Node instead of Knoten.
app.md gains the automation canvas among the big surfaces: automatic layout, Start and End, conditions with Yes and No, frames, line kinds and dashed boxes, keyboard and List view, built from data with WorkflowCanvas. The Code Block spec colours code with the --code-* palette instead of fixed hex values.
AUTO-F70 to F91, B17 to B20 and A11 to A16 cover Start and End, routed lines, node faces with translated action titles, conditions with Yes and No, Continues on error, the paths list and its halting nodes, versions that arrive live, completion, types and fixes in code fields, the control-flow fields, Start and End inspectors, Source, List, the coding-agent entry, ignored positions, the failed-run focus, Expand, three languages, JSON fidelity, offline, a 40-node document, keyboard, phone sheets, reduced motion and screen readers. F12, F13, F14, F18, B6 and A1 now describe the canvas and inspector as they ship. Numbering starts at F70 because an unmerged branch already holds F69.

The automation reference names the specs that own each half, and the readme counts 135 boxes in the suite.
DOCS-21 to DOCS-25 judge the code editor, schema tree, vendor icon, workflow canvas, paths and playback examples in both themes, at 375 px, with reduced motion and with VoiceOver. The automation reference names the specs that own each half, and the docs suite's cost grows to about 50 minutes.
The new end-to-end spec uploads a document whose condition has an alternative, finds the condition above Escalate with Yes to Escalate and No to File, changes Score's Input in the inspector's code editor, saves v2, starts a test run and reads on the run page that the condition said No: the line to File is taken, File succeeded and Escalate was skipped. Upload and delete move into a shared helper the automations spec uses too; the e2e helper tables and the manual reference name them.
The screenshot manifest gains the Editor's paths list with Path 2 pinned, completion open in a Prompt, a node's Shape tab, Start's inspector, the Source view, the Editor at phone width, and a failed test run's failure focus. The seeder uploads a small invoice digest, transforms only, and starts one test run that fails at Totals for that last shot.

The canvas, Problems and run-input shots wait for the laid-out canvas, the settled check and the loaded code editor, so their retakes show Start, End, the condition and the code palette. These three are also the only existing shots with highlighted code; the endpoint, device and MCP snippets use the plain code block, whose colours did not change. Capture is left to a docs-shot round, which embeds each new image in the editor and execution-log guides.
Start and End take the accent tile in both themes, dashed borders keep 3:1 against the card at half zoom, every corner control keeps a 44 px target under a coarse pointer, and pulses dissolve into the box they reach. The chart, its List view and its legend share one chrome module.
A template chip in prose keeps the prose size and holds its monospace font to Inter's x-height through font-size-adjust; tooltips and the completion list draw the app's popover ring and shadow.
The coding-agent page keeps the editor's Edit with your coding agent button beside main's MCP guidance (no in-app assistant, personal API key, stale-save merge, approvals stay with people) in en, de and fr.
@yannickmonney
yannickmonney added this pull request to the merge queue Oct 9, 2026
The CodeEditor's implementation is a lazy chunk with no boundary of its own, so a failed load (a flaky connection, a deploy that replaced the chunk) reached the page's error route and took an unsaved draft with it. The field now holds the failure itself: the value goes on in a labelled plain text area that still edits through onChange, with a way to try the editor again on a fresh lazy component, since React keeps a rejected one rejected.
A value set from outside while the reader was elsewhere (a discard, another version, an agent's edit) was applied outside the undo history, which then mapped its old edits into the new text: undo after a discard put the discarded edit back and duplicated it. Such a replacement is now a hard edge for undo; a host answering the reader's own typing keeps their history.
Start loading the code editor when the automation editor opens, as the canvas design meant, so the first node opened finds its fields ready; and give the phone inspector's node fields the node's key, as the desktop panel has, so no half-typed text follows the reader to the next node.
a && (b || c) and (a && b) || c both read "A and B or C" on the canvas, in every language. A group of the other kind inside a condition now reads in brackets, so the two read apart.
@yannickmonney
yannickmonney removed this pull request from the merge queue due to a manual request Oct 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant