@@ -268,8 +268,40 @@ Keyboard hint inside command palette / tooltips.
268268
269269### ` <.relative_time at={dt}> `
270270
271- Humanized "2m ago" with absolute ISO in ` title ` . Always use this; never
272- hand-format times.
271+ Humanized "2m ago" (past) or "in 2h" (future) for durations. Always use this;
272+ never hand-format times. It is sign-aware — a future timestamp (e.g. a
273+ schedule's next run) reads "in …", never "just now". The hover tooltip is
274+ localized to the viewer's timezone by the same ` local_time.ts ` hook (it carries
275+ ` data-rel ` so only the title is rewritten; the relative text stays). Use
276+ ` local_time ` instead when the user needs the exact moment shown inline.
277+
278+ ### ` <.local_time at={dt} format="time|datetime|date"> `
279+
280+ For ** absolute** timestamps the user reads. The server only knows UTC, so a
281+ raw ISO string is ambiguous; this emits a ` <time data-ts data-format> ` with
282+ a UTC fallback as its text, and the client rewrites it to the viewer's
283+ timezone (` assets/src/hooks/local_time.ts ` , wired via LiveSocket's ` dom `
284+ callbacks — no per-element id needed). Never print a bare UTC timestamp;
285+ use ` relative_time ` for "ago" durations and ` local_time ` for the moment.
286+
287+ ### ` <.label> ` and ` <.field key="…"> `
288+
289+ The canonical "this is a label, not a value" treatment: small mono,
290+ uppercase, ` tracking-[0.14em] ` , ` text-muted-foreground/70 ` . Use ` <.label> `
291+ for ** every** field-name / section-header / metadata-key — never hand-roll
292+ the ` font-mono … uppercase tracking-… ` string again (it was previously
293+ duplicated across the inspector, logs, and IO panels).
294+
295+ ``` heex
296+ <.label>level</.label>
297+ <.label class="text-destructive">error</.label>
298+ ```
299+
300+ ` <.field key="level">info</.field> ` is the key/value detail row built on
301+ ` <.label> ` : a fixed-width label column beside a monospace value. Use inside
302+ a ` <dl> ` for aligned metadata tables (step inspector, log detail, etc.).
303+ Labels read as labels and values as values — keys are never lowercase plain
304+ text indistinguishable from their values.
273305
274306### ` <.skeleton> ` , ` <.empty_state> ` , ` <.error_state> `
275307
@@ -317,10 +349,29 @@ its own `Phoenix.LiveComponent` module under
317349 </main>
318350```
319351
320- - Sidebar: collapsible, lucide icons, fixed width when expanded.
321- - Topbar: 28 h, breadcrumbs left, theme toggle + ⌘K hint right.
322- - Main: ` max-w-screen-2xl ` to keep line lengths readable on ultrawide
323- monitors.
352+ - Sidebar: fixed 220 px, ` bg-card/40 ` + hairline right border, heroicons.
353+ Nav is split into two intent groups under quiet mono eyebrows —
354+ ** Observe** (Overview, Workflows, Executions) and ** Operate** (Inputs,
355+ Schedules, Settings) — so the destinations read as an IA, not a flat list.
356+ Brand lockup is the ** monitor tile + heartbeat** (a pulsing ` led-dot ` in a
357+ primary-tinted square): the recurring "the engine is alive and durable"
358+ mark, echoed by the ` connected ` heartbeat in the footer.
359+ - Topbar: 56 h, breadcrumbs left; the command-palette trigger ("Jump to…",
360+ ⌘K) + theme toggle right.
361+ - Main: ` max-w-[1400px] ` to keep line lengths readable on ultrawide monitors.
362+
363+ #### The live rail (signature)
364+
365+ The accent (` --primary ` indigo) carries ** one** meaning across the whole
366+ console: * now / live / you are here* . It is not generic chrome. It appears as:
367+
368+ - the ** active nav rail** — a short ` h-4 w-0.5 ` indigo bar on the left edge
369+ of the current nav item;
370+ - the ** running edge** in the workflow graph (` .flow-edge-running ` );
371+ - the ** "now" line** / in-flight bars in the Timeline.
372+
373+ Same hue, same idea, everywhere. Don't spend primary on decoration — if
374+ something is tinted indigo it should mean "live / current."
324375
325376### Page header
326377
@@ -343,12 +394,63 @@ Subtitle = relative time, scope context, or counts. Never marketing copy.
343394- Hover: ` bg-accent/50 ` .
344395- Click anywhere on the row → ` live_patch ` to detail.
345396- Right-aligned secondary actions reveal on hover.
397+ - ** Column headers** use the canonical ` <.label> ` idiom — `font-mono
398+ text-[ 10px] uppercase tracking-[ 0.14em] text-muted-foreground/70` — so a
399+ table header reads as a label, identical to field-name labels in the detail
400+ panels. This is baked into the ` DataTable ` LiveComponent; hand-rolled tables
401+ (e.g. Overview's "recent executions") apply the same classes. Sortable
402+ headers add ` hover:text-foreground ` . Never reach for sans ` tracking-wider ` .
403+
404+ ### Status readout (instrument cluster)
405+
406+ Aggregate counts (Overview's status KPIs, a workflow card's runs/failed/
407+ waiting) render as a ** segmented readout** , not a row of free-floating cards:
408+ one panel with hairline-divided cells, so it reads like an instrument cluster.
409+
410+ - Draw the dividers with the ** ` gap-px ` + ` bg-border ` trick** when the grid
411+ wraps responsively (container ` bg-border ` , cells ` bg-card ` , ` gap-px ` );
412+ use ` divide-x divide-border ` only for a fixed single row that never wraps.
413+ - Numbers are ** mono** (` text-numeric ` ) — they're machine facts. Labels use
414+ ` <.label> ` .
415+ - ** Restraint of color:** the dot carries status; only the counts that signal
416+ trouble light up (failed → destructive, waiting → warning, when ` > 0 ` ).
417+ Running pulses (` led-dot ` ). Everything else stays neutral, so the eye lands
418+ on what's wrong instead of a rainbow. No gradient glows.
346419
347420### Detail sheet / drawer
348421
349422` data-slot="sheet-content" ` is styled centrally in ` index.css ` . ** Don't**
350423override its background or backdrop-blur in the sheet itself.
351424
425+ ### Step detail panel (` Components.Workflow.StepDetail ` )
426+
427+ The in-place inspector for a single step execution — ** one** component
428+ shared by the Timeline (click a bar's row) and History (click a trace
429+ event), so a step reads identically wherever it's opened. Composition:
430+
431+ - ** Stat strip** — a compact horizontal header (`started · completed ·
432+ duration · attempt`), never a tall field column that leaves dead space.
433+ - ** Input / Output** — ` <.json> ` boxes sized ` w-fit max-w-full ` so a small
434+ payload doesn't render a giant empty box.
435+ - ** Error** — destructive-tinted ` <.json> ` , only when the step failed.
436+ - ** Logs** — the shared ` <LogLine.row> ` list; empty falls back to a
437+ "No logs captured" note rather than a blank box.
438+
439+ Pass ` class ` for the caller's margins, ` duration_ms ` to override (Timeline
440+ sums it across resume segments).
441+
442+ ### Trace spine (execution history)
443+
444+ A chronological list where order is itself information (a failure and its
445+ retry are two adjacent events) renders as a ** vertical trace** , not a flat
446+ table: a hairline spine down the left with a status-colored node bead per
447+ event (` ring-4 ring-card ` masks the connector behind the bead; the
448+ connector is trimmed at the first/last events). Uniform node beads — never
449+ variable-width status pills lined up in a column. Each event is a native
450+ ` <details> ` ; the row is the ` <summary> ` , expanding ` StepDetail ` inline.
451+ Reserve this for genuinely sequential data — for unordered lists use the
452+ plain List/table row above.
453+
352454### Empty / loading / error shells
353455
354456For every list LV:
@@ -423,12 +525,13 @@ groups. Background is the dot-grid surface ReactFlow ships.
423525
424526### Node types
425527
426- | Type | Component | Lucide icon | Purpose |
427- | ----------- | --------------- | ----------- | ---------------------------------- |
428- | ` start ` | ` StartNode ` | ` Play ` | Boundary marker (success tone) |
429- | ` end ` | ` EndNode ` | ` Flag ` | Boundary marker |
430- | ` step ` | ` StepNode ` | ` Box ` | Regular step (clickable) |
431- | ` decision ` | ` GatewayNode ` | ` GitBranch ` | Conditional branching point |
528+ | Type | Component | Lucide icon | Purpose |
529+ | ---------------- | -------------------- | ----------- | -------------------------------------------- |
530+ | ` start ` | ` StartNode ` | ` Play ` | Boundary marker (success tone) |
531+ | ` end ` | ` EndNode ` | ` Flag ` | Boundary marker |
532+ | ` step ` | ` StepNode ` | ` Box ` | Regular step (clickable) |
533+ | ` decision ` | ` GatewayNode ` | ` GitBranch ` | Conditional branching point |
534+ | ` child_workflow ` | ` ChildWorkflowNode ` | ` GitFork ` | Step that spawned its own child execution |
432535
433536There are ** no marker nodes** for parallel fork/join or branch
434537fork/join. Fan-out and fan-in are expressed purely through edge
@@ -447,19 +550,34 @@ A single custom edge component (`AnimatedFlowEdge`) — a flowing bezier
447550path. Status-driven stroke is set by classes the server attaches via
448551` graph_builder.overlay_status/3 ` :
449552
450- - ` .flow-edge-completed ` — ` --success ` solid, 1.5 px.
553+ - ` .flow-edge-completed ` — ` --success ` solid, 1.5 px. Static by
554+ default in linear flow (animating every completed edge in a long
555+ workflow is too noisy).
451556- ` .flow-edge-running ` — ` --primary ` with ` dash-flow ` animation.
452557- ` .flow-edge-pending ` — ` --border ` 1 px dashed.
453558- ` .flow-edge-conditional ` — ` --primary/55 ` dashed. Used for
454559 decision-step ` {:goto, :target, _} ` paths extracted at compile time
455- by ` Durable.DSL.Step.build_decision/3 ` . Stays dashed regardless of
456- runtime state because it represents a * possible* path, not a
457- * taken* one.
560+ by ` Durable.DSL.Step.build_decision/3 ` . ** Repainted at runtime** when
561+ the goto target actually executed: takes on ` flow-edge-completed `
562+ styling ** and the dash-flow animation** for the taken path; only the
563+ * un-taken* branches keep the dashed conditional look. The animation
564+ is the deliberate signal — at a decision node it's the only visual
565+ cue distinguishing the executed branch from the still-dashed
566+ alternatives. See ` graph_builder.apply_edge_status/3 ` .
567+
568+ Hover and selected states layer on top of those resting strokes:
569+
570+ - ` :hover ` bumps stroke to 2 px and adds a subtle ` --primary 35% `
571+ drop-shadow filter so the edge "lifts" under the cursor.
572+ - ` .selected ` / ` :focus-visible ` re-paints the stroke with ` --primary `
573+ at 2.25 px so the active edge is unambiguous when the user
574+ click-selects via ReactFlow.
458575
459576Edge labels (branch clause names like ` low ` / ` high ` / ` default ` , and
460577decision goto targets like ` goto :auto_remove ` ) render as
461578` <.badge kind="muted"> ` -style chips at the bezier midpoint via
462- ` <EdgeLabelRenderer> ` .
579+ ` <EdgeLabelRenderer> ` . Their background tints toward ` --primary ` on the
580+ parent edge's hover/selected state.
463581
464582### "Current execution" emphasis
465583
@@ -468,10 +586,40 @@ step's icon box receives
468586` ring-2 ring-primary/60 ring-offset-2 ring-offset-background ` . This is
469587the only place 2 px borders are sanctioned (§2.5).
470588
589+ ### Child workflow node (` ChildWorkflowNode ` )
590+
591+ Steps that spawned their own ` WorkflowExecution ` (parallel children
592+ materialised as separate runs, plus ` call_workflow ` / ` start_workflow `
593+ sub-workflows) render with the ** exact same 200 × 56 card as ` StepNode ` **
594+ — same footprint, same status palette, same row rhythm — so they never
595+ break the timeline. The ** only** difference is the drill-in signature:
596+
597+ - ** Stacked sheet** — a second, neutral card peeking ~ 6 px behind the top
598+ edge (` absolute -top-1.5 left-1.5 ` , ` border-border/55 bg-card/70 ` ). This
599+ is the one motif reserved for "a whole workflow is nested inside here";
600+ nothing else in the graph stacks. It lifts 2 px on hover. It stays
601+ neutral so status color is carried by the front card alone. The sheet
602+ only peeks a few px beyond the dagre box, so the node keeps the cell
603+ footprint (no special-casing in ` graph-layout.ts ` ).
604+ - ** Fork icon** (` GitFork ` ) in the status-tinted tile instead of ` Box ` .
605+ - ** Meta line** leads with ` sub-workflow ` , then ` status · duration ` — same
606+ grammar as ` StepNode ` 's meta line.
607+ - ** Drill-in chevron** (` ChevronRight text-primary/70 ` ) before the status
608+ dot.
609+
610+ No input-preview line and no footer: a 56 px card can't carry them
611+ legibly, and the parent step's input/output already live one click away in
612+ the inspector. The whole card is the click target — it fires the same
613+ ` durable:step-clicked ` event as ` StepNode ` ; the FlowGraph LC's
614+ ` handle_event/3 ` routes it (parallel children → navigate to the child's
615+ flow page; in-process children → open the inspector with the Child tab).
616+
471617### Drill-in arrow + child preview
472618
473- A step with an associated child workflow gets a small chevron badge at
474- the top-right corner of the icon box. Two cases:
619+ ` StepNode ` (the regular 88 × 96 cell) keeps the small chevron badge at
620+ the top-right of the icon box for the rare case a regular step carries
621+ a ` child_workflow_id ` but isn't a child-workflow node — fallback only;
622+ prefer the ` ChildWorkflowNode ` variant. Two routing cases:
475623
4766241 . ** Parallel-child step** — the child runs in its own
477625 ` WorkflowExecution ` with its own steps. Clicking the step navigates
@@ -498,7 +646,106 @@ The step→child mapping is sourced from the parent's context:
498646
499647---
500648
501- ## 12. Updating this doc
649+ ## 12. Information architecture
650+
651+ The dashboard distinguishes ** workflow definitions** (registered
652+ modules) from ** executions** (individual runs). Routes follow the
653+ nouns:
654+
655+ | URL | LiveView | Purpose |
656+ | -------------------------------- | ---------------- | ---------------------------------------- |
657+ | ` / ` | ` OverviewLive ` | Instance KPIs + recent executions |
658+ | ` /workflows ` | ` WorkflowsLive ` | Catalog of distinct workflow definitions |
659+ | ` /workflows/:name ` | ` ExecutionsLive ` | Executions filtered to that workflow |
660+ | ` /executions ` | ` ExecutionsLive ` | All executions on the instance |
661+ | ` /executions/:id[/:tab] ` | ` WorkflowLive ` | Single execution detail (six tabs) |
662+ | ` /inputs ` , ` /schedules ` , ` /settings ` | (own LV) | Operator surfaces |
663+
664+ The ` /workflows ` page is ** derived from execution history** —
665+ ` Durable.Query.list_workflows/1 ` runs a single GROUP BY against
666+ ` durable.workflow_executions ` , since the engine has no compile-time
667+ registry of definitions. Each row is a card showing total runs, a
668+ live-pulsing ** running chip** (only rendered when ` running_count > 0 ` ),
669+ the last run's relative time, and the last status pill. Clicking a
670+ card patches to the per-workflow ` ExecutionsLive ` .
671+
672+ ### Detail tabs
673+
674+ ` WorkflowLive ` renders six tabs: ** Summary, Flow, Timeline, Logs, I/O,
675+ History** (plus a conditional ** Family** when the execution has a
676+ parent or descendants).
677+
678+ ### Timeline tab
679+
680+ An ** absolute-time Gantt** spanning the workflow's wall-clock window —
681+ modelled on Temporal's Event History timeline. Pure HEEx + CSS, no
682+ SVG, no React island. Composition:
683+
684+ - ** 200-pixel left gutter** per row: status pill (canonical palette
685+ per §4), display name (parallel/branch macro qualifier stripped to
686+ match the Flow tab labels), ` ×N ` retry chip, right-aligned mono
687+ total active duration for the row.
688+ - ** Track** (flex-1): one absolutely-positioned segment per
689+ ` step_execution ` , at ` left: <pct>%; width: max(6px, <pct>%) ` of
690+ the workflow window. The 6 px floor keeps 1ms bars readable even
691+ inside a 28s window — Temporal does the same.
692+ - ** Multiple segments per row** mean the step suspended and resumed
693+ (durable's wait/resume model writes one ` step_exec ` for the
694+ suspension marker and another for the resumed body). Suspensions
695+ read as the literal gap between segments — no separate annotation.
696+ Retries appear the same way.
697+ - ** Tick axis** uses adaptive intervals from a curated nice list
698+ (1 ms → 24 h). Aim ~ 6 ticks; pick the smallest interval whose
699+ tick count fits the window. The 0% tick suppresses its left
700+ border so it doesn't draw outside the card.
701+
702+ #### Window calculation
703+
704+ - ` window_start ` = earliest ` started_at ` across all step_execs.
705+ - ` window_end ` :
706+ - ` now ` when ` workflow_live?(workflow) ` is true (live workflow).
707+ Driven off the * workflow's* status, not step statuses, because
708+ durable's wait/resume leaves stale ` :waiting ` rows around
709+ forever and they would otherwise pull the window forward
710+ indefinitely.
711+ - Otherwise ` max(completed_at || updated_at || started_at) ` per
712+ step_exec. A finished workflow viewed an hour later still
713+ shows its original wall-clock duration.
714+
715+ #### ` :waiting ` step_exec end time
716+
717+ The suspension marker durable writes before a ` wait_for_event ` /
718+ ` sleep ` body throws has ` status = :waiting, completed_at = nil ` and
719+ sticks around forever after the workflow resumes (a * new* step_exec
720+ is written for the resumed body). The Timeline ends those segments
721+ at ` step_exec.updated_at ` (the moment the row was flipped to
722+ ` :waiting ` ), never ` now ` — so they collapse to ~ 6 px instead of
723+ stretching across the window.
724+
725+ #### Realtime
726+
727+ ` WorkflowLive ` ticks ` assigns.now ` every 500 ms while
728+ ` workflow_in_flight?/1 ` is true. Each tick re-renders the Timeline
729+ tab; in-flight bars (` :running ` segments without ` completed_at ` )
730+ recompute their width against ` now ` , transition over 150 ms for
731+ smooth growth, pulse via ` animate-pulse ` , and carry a led-dot at
732+ their right edge. The clock stops ticking when nothing is in
733+ flight, so idle dashboards don't burn cycles.
734+
735+ #### Why an absolute-time Gantt rather than left-stacked bars
736+
737+ A left-stacked layout (each row's bar starting at ` x = 0 ` ) makes
738+ parallel children indistinguishable from sequential children — three
739+ parallel scans starting at the same wall-clock time would look like
740+ they ran one after another. The absolute-time positioning is what
741+ makes parallel-vs-sequential read at a glance.
742+
743+ The Timeline is the analytical view; the History tab remains the
744+ chronological list with expandable input/output details. They are
745+ not duplicates — Timeline is for * durations + ordering* , History is
746+ for * per-step inspection* .
747+
748+ ## 13. Updating this doc
502749
503750When you ship a UI change that establishes new precedent (a new
504751component, a new color use, a new animation), update this doc in the same
0 commit comments