A bar drew its nested reading on top of the outer one in --chart-5, which measures 2.53:1 against --primary and lost the 3:1 guideline for non-text. The readings now partition the fill end to end, up to three of them, in a token of their own: --primary-nested, the primary hue a few steps deeper, 3.14:1 light and 3.12:1 dark. It cannot also clear 3:1 against --muted — in dark those two are 5.82:1 apart and a colour 3:1 from both would need a 9:1 gap — so a segment is drawn inside a gutter of outer fill rather than ever bordering the track, which is what separates neighbours too, and what caps the count at three. A nested value larger than its outer used to spill onto the track; it is clamped. `inner` still reads as a single binding, so no dashboard needs migrating. On a phone, .widget-grid took its width from the widest thing any widget held — a truncating flex item still offers its whole unwrapped line as a min-content contribution — and a handful of widgets had no floor of their own: the uPlot legend is a table, a fieldset carries min-inline-size: min-content from the UA sheet, and buttons are whitespace-nowrap. Each is capped now. A widget's body scrolls rather than clipping, so long text stops painting over the title. Gauges and bars move between readings instead of jumping, and a segmented control slides one thumb rather than recolouring cells. The gauge arc is drawn whole and revealed by its dash, because `d` cannot be transitioned. UplotChart pushed new readings only when the point count changed, so once a rolling window was full a refetch left the old values on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
99 lines
3.5 KiB
TypeScript
99 lines
3.5 KiB
TypeScript
// The segmented shape's thumb transition lives beside the dashboard's own
|
|
// widgets, and CSS is chunked per entry — so the rule is pulled in wherever
|
|
// this picker is used, or the two copies of one shape would move differently.
|
|
import "@/components/Dashboard/dashboard.css"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
/**
|
|
* A window of history, and everything a query needs to ask for it.
|
|
*
|
|
* `bucketS` keeps the number of points a chart draws roughly constant: a week
|
|
* of minute buckets is ten thousand readings nobody can see and a megabyte of
|
|
* JSON every refresh, so the longer windows are folded coarser server-side.
|
|
*/
|
|
export type Range = { label: string; hours: number; bucketS: number }
|
|
|
|
/**
|
|
* The windows on offer.
|
|
*
|
|
* Bounded by what the collector keeps: it prunes buckets, failures and runs at
|
|
* `OBS_RETENTION_DAYS` (30 by default), so a week is behind the last preset
|
|
* rather than an empty chart.
|
|
*/
|
|
export const RANGES: Range[] = [
|
|
{ label: "1h", hours: 1, bucketS: 60 },
|
|
{ label: "6h", hours: 6, bucketS: 60 },
|
|
{ label: "24h", hours: 24, bucketS: 60 },
|
|
{ label: "7d", hours: 168, bucketS: 900 },
|
|
]
|
|
|
|
/** A day: long enough to hold a night's worth of trouble, short enough to read. */
|
|
export const DEFAULT_RANGE = RANGES[2]
|
|
|
|
/**
|
|
* Where the window starts, as the stamp the history endpoints take.
|
|
*
|
|
* Read at fetch time rather than when the options are built, so the window
|
|
* slides with the clock instead of freezing where the screen opened.
|
|
*/
|
|
export const rangeStart = (range: Range) =>
|
|
new Date(Date.now() - range.hours * 3600_000).toISOString()
|
|
|
|
/**
|
|
* The window a screen is showing, as presets.
|
|
*
|
|
* The one segmented shape: a single border pill, transparent segments,
|
|
* bg-accent on the selected one (root DESIGN-GUIDELINES.md).
|
|
*/
|
|
export function RangePicker({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: Range
|
|
onChange: (range: Range) => void
|
|
}) {
|
|
const chosen = RANGES.findIndex((range) => range.hours === value.hours)
|
|
return (
|
|
// A `fieldset` carries `min-inline-size: min-content` from the UA sheet,
|
|
// which no width utility overrides. Equal tracks and no gap put the
|
|
// sliding thumb at its share of the padded box without measuring — a grid
|
|
// rather than a flex row because `flex-1` under `w-fit` sizes the segments
|
|
// to a share of the widest label instead of to the label itself.
|
|
<fieldset
|
|
data-testid="range-picker"
|
|
className="relative grid w-fit min-w-0 items-center rounded-full border border-border p-1"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${RANGES.length}, minmax(0, 1fr))`,
|
|
}}
|
|
>
|
|
<legend className="sr-only">Time range</legend>
|
|
{chosen >= 0 ? (
|
|
<span
|
|
aria-hidden
|
|
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
|
|
style={{
|
|
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${RANGES.length})`,
|
|
width: `calc((100% - 0.5rem) / ${RANGES.length})`,
|
|
}}
|
|
/>
|
|
) : null}
|
|
{RANGES.map((range, index) => (
|
|
<button
|
|
key={range.label}
|
|
type="button"
|
|
aria-pressed={index === chosen}
|
|
onClick={() => onChange(range)}
|
|
className={cn(
|
|
"relative z-10 min-w-0 rounded-full px-2.5 py-1 text-xs transition-colors",
|
|
index === chosen
|
|
? "text-accent-foreground"
|
|
: "text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
>
|
|
{range.label}
|
|
</button>
|
|
))}
|
|
</fieldset>
|
|
)
|
|
}
|