Home: one sparkline style with a left fade, SI values, a full-width activity table, roomier panels

The trend curve was two components: a rich one in the node panel and on edges,
and a bare line in the flow table. It is one `Sparkline` now, taking the colour
token, the height and whether the live dot and the readout show. The flow table
draws its rollup in the chart ramp with no dot — the rollups are polled, so the
right edge is the last completed slice rather than this instant — and keeps its
"nothing yet" state, as the panel keeps its three distinct silences.

All of them fade out to the left, through an SVG mask over the curve and its
area. The dot sits outside the mask: the newest reading is the one thing that
must stay solid.

`si` replaces `compact` and the ad-hoc "k" the uPlot axis carried. It prefixes
k/M/G and m/µ, but only outside 0.01–1000, where the plain number is already
the shortest thing to read and a written unit ("0.4 ms") stays honest. The
sparkline readout asks for four digits, so two neighbouring readings never
collapse into one string. Exact counts and anything the user acts on — a
payload, a form field, the edge inspector's value — are left unrounded.
`src/lib/utils.check.ts` asserts the rounding cases.

The activity table now spans its card: the flow name anchors the left, the four
numbers read down their own centre, and the trend takes the slack on the right.
Panel rhythm steps up one notch, gap-5 to gap-6 outside and gap-2 to gap-3
within a section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
2026-08-17 11:27:54 +02:00
co-authored by Claude Opus 5
parent 5483de13c0
commit 78c605bd37
10 changed files with 320 additions and 188 deletions
+4 -4
View File
@@ -75,8 +75,8 @@ export function FlowPanel({
</Button>
}
>
<div className="grid gap-5 p-4">
<div className="grid gap-2">
<div className="grid gap-6 p-4">
<div className="grid gap-3">
<span className={PANEL_SECTION}>Running</span>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
@@ -94,7 +94,7 @@ export function FlowPanel({
</div>
</div>
<div className="grid gap-2">
<div className="grid gap-3">
<span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground">
<span className="font-mono">{definition.name}</span> namespaces
@@ -106,7 +106,7 @@ export function FlowPanel({
</div>
{hasDraft ? (
<div className="grid gap-2">
<div className="grid gap-3">
<span className={PANEL_SECTION}>Unpublished changes</span>
<p className="text-sm text-muted-foreground">
The engine is still running the last published version of this
@@ -1,7 +1,8 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useId, useState } from "react"
import { useEffect, useState } from "react"
import type { HistoryPoint } from "@/client"
import { Sparkline } from "@/components/Common/Sparkline"
import { qualify } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
import { messageHistoryQueryOptions } from "./queries"
@@ -9,20 +10,6 @@ import { messageHistoryQueryOptions } from "./queries"
/** The same bound the server keeps, so the live tail cannot outgrow the window. */
const WINDOW = 120
/** Room above and below the curve for the stroke, in viewBox units. */
const PAD = 8
/** Above this ratio a linear series is all baseline and one spike. */
const LOG_RATIO = 100
/** Short enough for a label, precise enough to tell two of them apart. */
function compact(value: number): string {
const size = Math.abs(value)
if (size === 0) return "0"
if (size >= 1e6 || size < 1e-2) return value.toExponential(1)
return String(Number(value.toPrecision(4)))
}
/** How a value that cannot be plotted still reads. */
function describe(value: unknown): string {
if (typeof value === "string") return value
@@ -42,46 +29,6 @@ function useSettled(value: string): string {
return settled
}
/**
* Curve and area for a series, in a 0100 box.
*
* The awkward series are the point: one reading has no line to draw, a series
* that never moved has no span to divide by, and one spanning decades is only
* legible once the exponent is what varies.
*/
export function shape(points: HistoryPoint[]) {
const values = points.map((point) => point.value)
const low = Math.min(...values)
const high = Math.max(...values)
// Logs need every reading on the same side of zero.
const logged = low > 0 && high / low >= LOG_RATIO
const project = (value: number) => (logged ? Math.log10(value) : value)
const floor = project(low)
const span = project(high) - floor
const y = (value: number) =>
span === 0
? 50
: 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD)
const x = (index: number) =>
points.length === 1 ? 100 : (index / (points.length - 1)) * 100
const line = points
.map(
(point, index) =>
`${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`,
)
.join(" ")
return {
low,
high,
line,
area: `${line} L100,100 L0,100 Z`,
end: y(values[values.length - 1]),
}
}
/**
* How one message has been moving, as a sparkline with a live end.
*
@@ -100,7 +47,6 @@ export function MessageSparkline({
const live = useLiveValue(qualify(flow, message))
const { data } = useQuery(messageHistoryQueryOptions(flow, message))
const [tail, setTail] = useState<HistoryPoint[]>([])
const gradient = useId()
// Pointing the field at another message makes the collected tail meaningless.
// biome-ignore lint/correctness/useExhaustiveDependencies: the name is what invalidates the tail, not anything the effect reads.
@@ -147,64 +93,6 @@ export function MessageSparkline({
)
}
const { low, high, line, area, end } = shape(points)
return (
// The gap leaves the live dot room to sit on the last reading without
// touching the labels.
<div className="flex items-center gap-3">
<div className="relative h-8 min-w-0 flex-1">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="h-full w-full overflow-visible"
aria-hidden="true"
>
<defs>
<linearGradient id={gradient} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.28" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
</linearGradient>
</defs>
{points.length > 1 ? (
<>
<path d={area} fill={`url(#${gradient})`} />
<path
d={line}
fill="none"
stroke="var(--primary)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
// Keeps the stroke even, though the box is far wider than tall.
vectorEffect="non-scaling-stroke"
/>
</>
) : null}
</svg>
{/* The newest reading is always the right edge, so the dot only needs to
know how high it sits. */}
<span
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20"
style={{ left: "100%", top: `${end}%` }}
/>
<span
className="pointer-events-none absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"
style={{ left: "100%", top: `${end}%` }}
/>
</div>
{/* A shared minimum width, so the curves all end on the same line. */}
<div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight">
<div className="font-medium">
{compact(points[points.length - 1].value)}
</div>
{/* A series that never moved has no range worth repeating. */}
{low === high ? null : (
<div className="text-muted-foreground">
{compact(low)}{compact(high)}
</div>
)}
</div>
</div>
)
// The value is live here, so the dot on the newest reading is earned.
return <Sparkline points={points} />
}
+5 -5
View File
@@ -223,7 +223,7 @@ function PortList({
}
return (
<div className="grid gap-2">
<div className="grid gap-3">
<div className="flex items-center justify-between">
<span className={SECTION}>{title}</span>
<Button
@@ -366,7 +366,7 @@ function FreeParamsForm({
}
return (
<div className="grid gap-2">
<div className="grid gap-3">
<div className="flex items-center justify-between">
<span className={SECTION}>Settings</span>
<Button
@@ -704,7 +704,7 @@ function SharingSection({
if (shared) {
return (
<div className="grid gap-2">
<div className="grid gap-3">
<span className={SECTION}>Shared</span>
<p className="text-sm text-muted-foreground">
Runs <span className="font-mono">{shared}</span> from the library
@@ -730,7 +730,7 @@ function SharingSection({
}
return (
<div className="grid gap-2">
<div className="grid gap-3">
<span className={SECTION}>Reuse</span>
<p className="text-sm text-muted-foreground">
Move this node's code to the library so other flows can run it too.
@@ -901,7 +901,7 @@ function PanelBody({
return (
<>
<div className={cn("grid gap-5 p-4", expanded && "max-w-2xl")}>
<div className={cn("grid gap-6 p-4", expanded && "max-w-2xl")}>
<PortList
title="Consumes"
specs={node.requires ?? []}