Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m18s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m5s
Test Backend / test-backend (push) Successful in 2m39s
Compose Smoke Test / test-compose (push) Successful in 33s
Playwright Tests / merge-reports (push) Successful in 1m11s
Runs: a run can now be deleted (DELETE /runs/{id}, cancelling a live one
first), exported as csv from the screen's own filters, and its flow label
opens the flow. Its "Parameters" panel became "Inputs" and lists every
input the flow declares, marking the ones that took the flow's own value
rather than the run's — the comparison table resolves the same defaults
instead of printing "unset".
Home reads brain, dashboards, health, flows: the mosaic is one full-width
scrolling strip, and the flows list and the flow-activity rollups merged
into a single left-joined table so a flow's state and its numbers sit on
one row.
Charts take a drag to narrow the x window and a double click or tap to
come back out. UplotChart holds the scale and passes resetScales:false
while a window is held, which is what the old comment said made this
impossible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LrWVRguqbk33YzfEeUx5W
542 lines
22 KiB
TypeScript
542 lines
22 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
import uPlot from "uplot"
|
|
import "uplot/dist/uPlot.min.css"
|
|
|
|
// After uPlot's own sheet, and beside this component rather than in the
|
|
// dashboard chunk: a chart on Health or Home is styled without a dashboard
|
|
// having been visited first.
|
|
import "./uplot.css"
|
|
|
|
import type { HistoryPoint } from "@/client"
|
|
import { useTheme } from "@/components/theme-provider"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { si } from "@/lib/utils"
|
|
|
|
/**
|
|
* How many lines one chart carries.
|
|
*
|
|
* The bound is the palette's: `--chart-1…5` is one designed ramp, and a sixth
|
|
* line would either repeat a step or invent a colour outside the system.
|
|
*/
|
|
export const MAX_SERIES = 5
|
|
|
|
/**
|
|
* The ramp's slots, as a palette names them.
|
|
*
|
|
* The whole set one may draw from — five, for the same reason `MAX_SERIES` is
|
|
* five, because they are the same five.
|
|
*/
|
|
export const CHART_SLOTS = Array.from({ length: MAX_SERIES }, (_, index) =>
|
|
String(index + 1),
|
|
)
|
|
|
|
/** No palette named; a chart left with this spreads itself. */
|
|
export const NO_PALETTE: string[] = []
|
|
|
|
/**
|
|
* What a chart of *n* lines draws with when no palette was named: the ramp
|
|
* spread, rather than its first *n* steps.
|
|
*
|
|
* Three lines used to take slots 1, 2 and 3, which are adjacent steps of a
|
|
* ramp that carries identity by lightness alone — at a 2px stroke they are
|
|
* close to one picture. The ends and the middle are as far apart as five slots
|
|
* allow, and the same reasoning gives every other count.
|
|
*
|
|
* This moved after the palette shipped, and moving it was safe *because* of
|
|
* how the palette is stored: a named palette is honoured exactly as written,
|
|
* so no stored document means anything different than it did. Only the
|
|
* unwritten default moved, and any dashboard that dislikes where it moved to
|
|
* can now name the old one. That inertness is the property worth keeping —
|
|
* not this table, which is a design decision and may move again.
|
|
*/
|
|
const SPREADS: Record<number, string[]> = {
|
|
1: ["1"],
|
|
2: ["1", "5"],
|
|
3: ["1", "3", "5"],
|
|
4: ["1", "2", "4", "5"],
|
|
5: ["1", "2", "3", "4", "5"],
|
|
}
|
|
|
|
/** The slots a chart of `count` lines draws with, named palette or not. */
|
|
export function slotsFor(count: number, palette?: string[]): string[] {
|
|
if (palette?.length) return palette
|
|
return SPREADS[Math.min(Math.max(Math.trunc(count) || 1, 1), MAX_SERIES)]
|
|
}
|
|
|
|
/**
|
|
* What the pointer correction reads off a chart.
|
|
*
|
|
* Structural rather than `uPlot` itself, so the check beside this file can
|
|
* hand it a plain object.
|
|
*/
|
|
type Painted = {
|
|
rect: { left: number; top: number; width: number }
|
|
over: { clientWidth: number }
|
|
}
|
|
|
|
/**
|
|
* A pointer event, put back into the panel's own pixels.
|
|
*
|
|
* A dashboard canvas is drawn at its panel's own pixel size and CSS-scaled to
|
|
* fit the screen, while uPlot works in layout pixels throughout: it takes
|
|
* `clientX - rect.left` — visual pixels — and measures it against its own
|
|
* unscaled plot width. The cursor drifts further the deeper into a scaled
|
|
* chart it goes, and on a panel scaled *up* it does worse than drift. Past
|
|
* `1 / drawn` of the way across, the visual offset has passed the layout plot
|
|
* width, and uPlot's own edge snap (`cacheMouse`, uPlot.esm.js:5776) rounds it
|
|
* to that width outright: the readout stops advancing partway across and
|
|
* sticks to the last point.
|
|
*
|
|
* Correcting the event, before uPlot has done any arithmetic with it, is what
|
|
* makes that whole chain come out right — the snap included. It is also
|
|
* stateless, so it cannot be applied twice. `cursor.move`, the other seam, is
|
|
* handed its own output back and re-run on every redraw, so anything refined
|
|
* there can only stay right by recognising its own last answer.
|
|
*
|
|
* `drawn` is the ratio the element is painted at; unscaled it is 1 and this is
|
|
* a no-op. One ratio for both axes: the panel is scaled uniformly.
|
|
*/
|
|
export function inLayoutPixels<E extends { clientX: number; clientY: number }>(
|
|
self: Painted,
|
|
event: E,
|
|
): E {
|
|
const { rect } = self
|
|
const drawn = rect.width / self.over.clientWidth
|
|
if (!(drawn > 0) || drawn === 1) return event
|
|
return new Proxy(event, {
|
|
get(target, key) {
|
|
if (key === "clientX")
|
|
return rect.left + (event.clientX - rect.left) / drawn
|
|
if (key === "clientY")
|
|
return rect.top + (event.clientY - rect.top) / drawn
|
|
const value = Reflect.get(target, key)
|
|
// The event's own methods still need the event as their receiver.
|
|
return typeof value === "function" ? value.bind(target) : value
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* uPlot's own listener filters — `filtBtn0` and `filtTarg` — with the pointer
|
|
* corrected on the way through. Only the three events that carry a position
|
|
* are wrapped; the others read no coordinates.
|
|
*/
|
|
const binder =
|
|
(button: boolean) =>
|
|
(
|
|
self: Painted,
|
|
target: object,
|
|
handle: (event: MouseEvent) => void,
|
|
onlyTarget = true,
|
|
) =>
|
|
(event: MouseEvent) => {
|
|
if (button && event.button !== 0) return
|
|
if (onlyTarget && event.target !== target) return
|
|
handle(inLayoutPixels(self, event))
|
|
}
|
|
|
|
/** The cursor every chart is built with; exported so the check can drive it. */
|
|
export const CURSOR: uPlot.Cursor = {
|
|
y: false,
|
|
// uPlot's shipped types drop the binder's fourth `onlyTarg` argument, which
|
|
// it does pass — the document-wide mouseup binding depends on it.
|
|
bind: {
|
|
mousedown: binder(true),
|
|
mouseup: binder(true),
|
|
mousemove: binder(false),
|
|
} as unknown as uPlot.Cursor.Bind,
|
|
drag: {
|
|
// Drag across the plot to read a stretch of it closer. `setScale` stays
|
|
// off because the chart owns its x range itself: `setData` runs on every
|
|
// render and would re-range the scales from the data, so a held window is
|
|
// what tells it to leave them alone. Without that this only ever flashed a
|
|
// selection box over a live chart, which is why it used to be off.
|
|
x: true,
|
|
y: false,
|
|
setScale: false,
|
|
},
|
|
}
|
|
|
|
/** Below this a drag is a click that moved, not a window. In pixels. */
|
|
const DRAG_FLOOR = 4
|
|
|
|
/** How close two taps have to be to count as one gesture. */
|
|
const DOUBLE_TAP_MS = 300
|
|
|
|
/** Room for the axis ticks; uPlot measures the rest of the box itself. */
|
|
const PADDING: uPlot.Padding = [10, 12, 0, 0]
|
|
|
|
const canvasHeight = (element: HTMLElement) =>
|
|
Math.max(60, element.clientHeight || 180)
|
|
|
|
/**
|
|
* A token, resolved for the canvas — which cannot read CSS variables.
|
|
*
|
|
* Read off the chart's own element rather than the document: a dashboard
|
|
* states its colours on the canvas it is drawn on, and custom properties
|
|
* inherit, so a tile inside one has to be asked where it stands. Asking the
|
|
* root would draw the shell's axes on the dashboard's chart.
|
|
*/
|
|
function token(name: string, from: Element): string {
|
|
return getComputedStyle(from).getPropertyValue(name).trim()
|
|
}
|
|
|
|
/**
|
|
* What one line is drawn in.
|
|
*
|
|
* A palette entry is one of three things: a slot of the app's own ramp — the
|
|
* shape a dashboard stored before it could name colours — a token, for a line
|
|
* that means something the ramp has no step for, or a colour, which is what a
|
|
* dashboard palette writes today. A colour is used as it stands; the other two
|
|
* are resolved here, since the canvas cannot read a custom property.
|
|
*/
|
|
const seriesColor = (index: number, slots: string[], from: Element) => {
|
|
const slot = slots[index % slots.length]
|
|
if (CHART_SLOTS.includes(slot)) return token(`--chart-${slot}`, from)
|
|
return slot.startsWith("--") ? token(slot, from) : slot
|
|
}
|
|
|
|
/**
|
|
* Tick labels that stay distinct.
|
|
*
|
|
* `si` shortens to three significant figures, which is what a curve spanning
|
|
* decades wants and the opposite of what one wobbling inside a degree does —
|
|
* 18.9 and 19.1 both read "19", and the axis says nothing at all. When the
|
|
* short labels would repeat, the tick spacing decides the decimals instead.
|
|
*/
|
|
function tickLabels(ticks: number[]): string[] {
|
|
const short = ticks.map((value) => si(value))
|
|
if (new Set(short).size === short.length) return short
|
|
const step = Math.abs(ticks[1] - ticks[0]) || 1
|
|
const decimals = Math.min(6, Math.max(0, Math.ceil(-Math.log10(step))))
|
|
return ticks.map((value) => value.toFixed(decimals))
|
|
}
|
|
|
|
/** The series joined onto one x axis, which is what uPlot draws. */
|
|
function table(plots: HistoryPoint[][]): uPlot.AlignedData {
|
|
return uPlot.join(
|
|
plots.map(
|
|
(plot) =>
|
|
[
|
|
plot.map((point) => point.ts),
|
|
plot.map((point) => point.value),
|
|
] as uPlot.AlignedData,
|
|
),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Several series over time, drawn on one axis.
|
|
*
|
|
* uPlot rather than SVG: a chart may hold five series of hundreds of points
|
|
* each, which is more path data than React should be rebuilding on every value
|
|
* that arrives. Its own legend doubles as the hover readout, so the cursor
|
|
* tells you what each line was worth at that moment — and with more than one
|
|
* line a legend is required anyway.
|
|
*
|
|
* `onCursor` / `onSelect` report where the pointer is in the data, so a page
|
|
* can tie a list to the chart without reaching into the uPlot instance.
|
|
*/
|
|
export function UplotChart({
|
|
labels,
|
|
plots,
|
|
empty = "Nothing has come through yet.",
|
|
pending = false,
|
|
unit,
|
|
yRange,
|
|
yLabel,
|
|
palette,
|
|
smooth = false,
|
|
xTime = true,
|
|
onCursor,
|
|
onSelect,
|
|
}: {
|
|
/** One label per series; the set of them is the chart's identity. */
|
|
labels: string[]
|
|
/** The points of each series, in the same order as `labels`. */
|
|
plots: HistoryPoint[][]
|
|
empty?: string
|
|
/** No readings yet because none have arrived: a chart with nothing in it
|
|
* says "nothing has run", which is a different thing to say. */
|
|
pending?: boolean
|
|
/** Written after every reading, on the axis and in the legend. */
|
|
unit?: string
|
|
/** A y axis fixed to these bounds; unset lets it follow the data. */
|
|
yRange?: [number, number]
|
|
/** A named y axis; the unit stays on the ticks. */
|
|
yLabel?: string
|
|
/** The ramp slots this chart's lines take, in order — the dashboard's own
|
|
* palette — colours, or slots of the app's own ramp. Unset, the chart
|
|
* spreads itself across the ramp by how many lines it draws, which is what one outside a dashboard (Health, Home)
|
|
* always does. */
|
|
palette?: string[]
|
|
/** Draw the lines as a monotone cubic spline rather than straight segments.
|
|
* Monotone rather than plain cubic on purpose: a spline that overshoots
|
|
* invents readings between two the sensor actually took. */
|
|
smooth?: boolean
|
|
/** The x axis reads as time. False when x is a count rather than a moment —
|
|
* a run's metric is indexed by step, and drawn as time it would date every
|
|
* point to 1970. */
|
|
xTime?: boolean
|
|
/** The x value under the pointer, and null once it leaves the plot. */
|
|
onCursor?: (ts: number | null) => void
|
|
/** The x value clicked, or null for a click that landed on no point. */
|
|
onSelect?: (ts: number | null) => void
|
|
}) {
|
|
const host = useRef<HTMLDivElement>(null)
|
|
const legend = useRef<HTMLDivElement>(null)
|
|
const chart = useRef<uPlot | null>(null)
|
|
// A dragged x window, held so the next reading does not wash it away. The
|
|
// ref is what the data effect reads; the state is only what draws the way
|
|
// back out, and the two are set together.
|
|
const zoomed = useRef(false)
|
|
const [showReset, setShowReset] = useState(false)
|
|
const clearZoom = () => {
|
|
zoomed.current = false
|
|
setShowReset(false)
|
|
}
|
|
// The chart outlives a render, so its handlers are read through a ref
|
|
// rather than baked into the config it was built with.
|
|
const report = useRef({ onCursor, onSelect })
|
|
const { resolvedTheme } = useTheme()
|
|
|
|
useEffect(() => {
|
|
report.current = { onCursor, onSelect }
|
|
})
|
|
|
|
const points = plots.reduce((total, plot) => total + plot.length, 0)
|
|
// The identity of the series set: the chart is rebuilt when it changes,
|
|
// while a new reading only sets its data. The unit, the fixed range and the
|
|
// axis title are part of it — all are baked into the axes at build time —
|
|
// and so are the line shape and the palette, which the series close over.
|
|
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}|${xTime}`
|
|
// uPlot leaves its axes half-initialised while the scales have no range, and
|
|
// a resize in that window (a card still settling, say) draws them anyway and
|
|
// throws. Waiting for the first reading avoids the state altogether.
|
|
const ready = points > 0
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the label string is the identity of the series set.
|
|
useLayoutEffect(() => {
|
|
const element = host.current
|
|
if (!element || labels.length === 0 || !ready) return
|
|
// A different set of series is a different picture; the window that was
|
|
// held over the old one means nothing on it.
|
|
clearZoom()
|
|
|
|
const axis = {
|
|
stroke: () => token("--muted-foreground", element),
|
|
grid: { stroke: () => token("--border", element), width: 1 },
|
|
ticks: { stroke: () => token("--border", element), width: 1 },
|
|
font: `11px ${getComputedStyle(element).fontFamily}`,
|
|
}
|
|
/** The x value the page was last told about, so a move within one bucket
|
|
* does not re-render it. */
|
|
let told: number | null = null
|
|
/** Whether the click about to arrive is the end of a drag. */
|
|
let dragging = false
|
|
// Resolved once for the whole chart: how many lines there are is part of
|
|
// which slots they take, when nothing named them.
|
|
const slots = slotsFor(labels.length, palette)
|
|
// One builder for the whole chart: it is a factory, and the series only
|
|
// need the function it returns.
|
|
const spline = smooth ? { paths: uPlot.paths.spline?.() } : {}
|
|
const under = (self: uPlot) =>
|
|
self.cursor.idx == null ? null : Number(self.data[0][self.cursor.idx])
|
|
|
|
const plot = new uPlot(
|
|
{
|
|
width: element.clientWidth || 320,
|
|
height: canvasHeight(element),
|
|
padding: PADDING,
|
|
cursor: CURSOR,
|
|
legend: {
|
|
live: true,
|
|
// Mounted in its own row under the plot rather than inside it: a
|
|
// legend that wraps then takes height from the chart instead of
|
|
// spilling past the bottom of the card.
|
|
mount: (_self, element) => legend.current?.appendChild(element),
|
|
},
|
|
hooks: {
|
|
setCursor: [
|
|
(self) => {
|
|
const ts = under(self)
|
|
if (ts === told) return
|
|
told = ts
|
|
report.current.onCursor?.(ts)
|
|
},
|
|
],
|
|
setSelect: [
|
|
(self) => {
|
|
// uPlot fires this for a plain click too. A few pixels is a
|
|
// slip of the hand, not a window anybody meant to ask for.
|
|
if (self.select.width <= DRAG_FLOOR) return
|
|
const from = self.posToVal(self.select.left, "x")
|
|
const to = self.posToVal(
|
|
self.select.left + self.select.width,
|
|
"x",
|
|
)
|
|
// The box has done its job; the scale is what holds the window
|
|
// from here. `false` so this hook does not fire on itself.
|
|
self.setSelect({ left: 0, width: 0, top: 0, height: 0 }, false)
|
|
self.setScale("x", { min: from, max: to })
|
|
dragging = true
|
|
zoomed.current = true
|
|
setShowReset(true)
|
|
},
|
|
],
|
|
ready: [
|
|
(self) => {
|
|
self.over.addEventListener("click", () => {
|
|
// The mouseup that ended a drag arrives here as a click as
|
|
// well; pinning a moment is not what it was asking for.
|
|
if (dragging) {
|
|
dragging = false
|
|
return
|
|
}
|
|
report.current.onSelect?.(under(self))
|
|
})
|
|
self.over.addEventListener("dblclick", clearZoom)
|
|
// ponytail: a touch screen gets no dblclick from every browser,
|
|
// and uPlot has no dbltap of its own. Two taps in a moment is
|
|
// the whole of the gesture.
|
|
let lastTap = 0
|
|
self.over.addEventListener("pointerup", (event) => {
|
|
if (event.pointerType !== "touch") return
|
|
const now = event.timeStamp
|
|
if (now - lastTap < DOUBLE_TAP_MS) clearZoom()
|
|
lastTap = now
|
|
})
|
|
},
|
|
],
|
|
},
|
|
scales: {
|
|
x: { time: xTime },
|
|
...(yRange ? { y: { range: yRange } } : {}),
|
|
},
|
|
axes: [
|
|
{ ...axis, size: 28 },
|
|
{
|
|
...axis,
|
|
label: yLabel,
|
|
// uPlot's default label font is a hardcoded bold sans-serif, which
|
|
// would ignore the app's face.
|
|
labelFont: axis.font,
|
|
// The unit is written after every tick, so the gutter widens to
|
|
// hold it rather than clipping the number in front of it.
|
|
size: unit ? 62 : 46,
|
|
// The gutter has a fixed width, so a grouped "15,000" would be
|
|
// clipped to something that reads as a different number entirely.
|
|
values: (_self: uPlot, ticks: number[]) =>
|
|
tickLabels(ticks).map((label) =>
|
|
unit ? `${label} ${unit}` : label,
|
|
),
|
|
},
|
|
],
|
|
series: [
|
|
{},
|
|
...labels.map((label, index) => ({
|
|
label,
|
|
width: 2,
|
|
// Read at draw time, so a theme toggle is a redraw rather than a
|
|
// rebuilt chart.
|
|
stroke: () => seriesColor(index, slots, element),
|
|
// The cursor readout is what decides how wide the legend gets, so
|
|
// it is shortened here; a named unit is short enough to keep. Four
|
|
// figures rather than three: this is the number someone is pointing
|
|
// at to read, and 18.97 rounded to "19" is not an answer.
|
|
value: (_self: uPlot, raw: number) =>
|
|
unit ? `${si(raw, 4)} ${unit}` : si(raw, 4),
|
|
// Series arrive on their own clocks; a joined table is mostly
|
|
// holes, and a line with a hole per point is not a line.
|
|
spanGaps: true,
|
|
points: { show: false },
|
|
...spline,
|
|
})),
|
|
],
|
|
},
|
|
// Built with the readings it already has: uPlot's axes are only half
|
|
// initialised while its scales have no range.
|
|
table(plots),
|
|
element,
|
|
)
|
|
chart.current = plot
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
plot.setSize({
|
|
width: element.clientWidth,
|
|
height: canvasHeight(element),
|
|
})
|
|
})
|
|
observer.observe(element)
|
|
|
|
return () => {
|
|
observer.disconnect()
|
|
plot.destroy()
|
|
// The legend was moved out of the plot's root, so destroying it leaves
|
|
// the table behind.
|
|
legend.current?.replaceChildren()
|
|
chart.current = null
|
|
}
|
|
}, [key, ready])
|
|
|
|
// Every render, deliberately. `plots` is rebuilt on every render in both
|
|
// consumers, so no memo can hit, and any signature short of hashing shares
|
|
// the blind spot a point count has: once a rolling window is full, a refetch
|
|
// carrying different readings leaves the count where it was and never fires.
|
|
// Safe to run this often because `setData` is idempotent and re-ranges the
|
|
// scales *from the data* — the opposite of the `redraw(false)` below. That
|
|
// re-ranging is exactly what a dragged window has to be spared, so while one
|
|
// is held the data goes in and the scales stay where they were put. Clearing
|
|
// the window renders, which brings the next pass through here with the reset
|
|
// back on: that is what puts the whole range back.
|
|
useEffect(() => {
|
|
if (!chart.current || plots.length === 0) return
|
|
chart.current.setData(table(plots), !zoomed.current)
|
|
})
|
|
|
|
// The canvas cannot follow a CSS variable, so a theme swap is a redraw. The
|
|
// paths are geometry and stay as they are — and leaving them alone is what
|
|
// keeps this safe on the mount it also fires on: a full redraw re-sets the x
|
|
// scale from the chart's own bounds, which are still empty when the chart was
|
|
// built in this same commit (uPlot ranges its scales in a microtask). That
|
|
// overwrites the range it was about to take from the data, and the chart is
|
|
// left with no x axis and no lines until something sets its data again.
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the theme is the signal, not something the effect reads.
|
|
useEffect(() => {
|
|
chart.current?.redraw(false)
|
|
}, [resolvedTheme])
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="relative min-h-0 flex-1">
|
|
<div ref={host} className="absolute inset-0" />
|
|
{/* Double-clicking does the same thing, but nothing says so. */}
|
|
{showReset ? (
|
|
<button
|
|
type="button"
|
|
onClick={clearZoom}
|
|
data-testid="chart-reset-zoom"
|
|
className="absolute top-1 right-1 z-10 rounded-md border border-border bg-card/90 px-1.5 py-0.5 text-muted-foreground text-xs hover:text-foreground"
|
|
>
|
|
Reset zoom
|
|
</button>
|
|
) : null}
|
|
{points === 0 ? (
|
|
pending ? (
|
|
<Skeleton className="absolute inset-0" />
|
|
) : (
|
|
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
|
{empty}
|
|
</p>
|
|
)
|
|
) : null}
|
|
</div>
|
|
{/* Kept at the legend's resting height, so the plot does not resize
|
|
under the pointer the first time a reading arrives. uPlot mounts a
|
|
table here and a table cannot lay out below its min-content width, so
|
|
the labels get their own scroller rather than widening the card. */}
|
|
<div ref={legend} className="min-h-6 min-w-0 shrink-0 overflow-x-auto" />
|
|
</div>
|
|
)
|
|
}
|