Dashboard chrome: an icon picker, one segmented shape, a rail without bars

- IconPicker replaces the three icon selects (rail icon, icon-widget rule,
  "Otherwise"): the glyphs in a grid, and a button that clears back to none —
  which a Radix SelectItem could never offer.
- ModePicker/StylePicker drop out in favour of a shared ui/Segmented, the same
  sliding-thumb shape RangePicker and the widget-side control already wear.
- PanelRail draws no scrollbars at all: hiding them also takes back the gutter
  a vertical bar claimed from a column exactly as wide as its buttons, which is
  what pushed a horizontal bar under them.
- The panels dialog can re-pair one screen (POST /panels/{id}/unpair) without
  deleting the panel it hangs on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 12:16:17 +02:00
co-authored by Claude Opus 5
parent 3674099758
commit 6be6f18dc2
5 changed files with 264 additions and 118 deletions
+74
View File
@@ -0,0 +1,74 @@
// The thumb's transition lives beside the dashboard's own widgets, and CSS is
// chunked per entry — so the rule is pulled in wherever this control is used,
// or two copies of one shape would move differently.
import "@/components/Dashboard/dashboard.css"
import { cn } from "@/lib/utils"
/**
* One of N, as the one segmented shape: a single border pill, no dividers,
* transparent segments, and `bg-accent` held by a thumb that slides rather than
* a fill that jumps from cell to cell (root DESIGN-GUIDELINES.md).
*
* ponytail: sized for editor chrome — `w-fit`, `text-xs`, a mouse-sized target.
* The widget-side copy in `Dashboard/widgets.tsx` is a full-width pill with a
* 44px touch target, so pointing that one here needs a size prop first.
*/
export function Segmented({
value,
options,
label,
testId,
onChange,
}: {
value: string
/** `[value, label]` pairs, in the order they are drawn. */
options: readonly (readonly [string, string])[]
/** Names the group for screen readers. */
label: string
testId?: string
onChange: (value: string) => void
}) {
const chosen = options.findIndex(([option]) => option === value)
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 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={testId}
className="relative grid w-fit min-w-0 items-center rounded-full border border-border p-1"
style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
}}
>
<legend className="sr-only">{label}</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) / ${options.length})`,
width: `calc((100% - 0.5rem) / ${options.length})`,
}}
/>
) : null}
{options.map(([option, optionLabel], index) => (
<button
key={option}
type="button"
aria-pressed={index === chosen}
onClick={() => onChange(option)}
className={cn(
"relative z-10 min-w-0 truncate rounded-full px-2.5 py-1 text-xs transition-colors",
index === chosen
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{optionLabel}
</button>
))}
</fieldset>
)
}