This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web/components: make the button group match the figma segmented control

a joined group is a tab switcher, but the selected segment was the plain default
variant, which reads as the hovered state of an unselected one, and the group
itself drew nothing.

per figma (design system 605:3859): the group owns the outer border and a
recessed background-subtle surface, the selected segment is default + its inset
shadow so it reads as a raised card, and unselected segments are ghost with the
default foreground rather than ghost's muted one. each segment's own border is
pulled onto the group's (-m-px) so the selected segment's edge doubles as the
divider and unselected segments show no divider at all.

segmentProps() carries the recipe so the call sites stop repeating the variant
ternary. verified pixel for pixel against the figma render: 36px tall, #e5e7eb
border on all four edges, #ffffff on the selected segment against #f9fafb on the
unselected, a 1px divider at the selected segment's edge, and the inset shadow
two rows above the bottom border.

ghost's own foreground is still muted where it's used outside a group, which
figma disagrees with; that's a wider change, left alone here.

Signed-off-by: eti <eti@eti.tf>

author
eti
committer
dawn
date (Jul 31, 2026, 10:57 PM +0300) commit c107ec0f parent 7c3ec8ca change-id uuoqkxpn
+46 -9
+3 -3
web/src/lib/components/repo/issues/IssueToolbar.svelte
··· 4 4 import CircleX from "$icon/circle-x"; 5 5 import Plus from "$icon/plus"; 6 6 import Button from "$lib/components/ui/Button.svelte"; 7 - import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; 7 + import ButtonGroup, { segmentProps } from "$lib/components/ui/ButtonGroup.svelte"; 8 8 import IssueSearch from "./IssueSearch.svelte"; 9 9 10 10 interface Props { ··· 33 33 <!-- TODO: wire up state filter navigation --> 34 34 <Button 35 35 href={resolve(`${base}?state=open` as "/")} 36 - variant={state === "open" ? "default" : "ghost"} 36 + {...segmentProps(state === "open")} 37 37 size="sm" 38 38 icon={CircleDot} 39 39 > ··· 42 42 </Button> 43 43 <Button 44 44 href={resolve(`${base}?state=closed` as "/")} 45 - variant={state === "closed" ? "default" : "ghost"} 45 + {...segmentProps(state === "closed")} 46 46 size="sm" 47 47 icon={CircleX} 48 48 >
+13 -1
web/src/lib/components/ui/ButtonGroup.stories.svelte
··· 1 1 <script module lang="ts"> 2 2 import { defineMeta } from "@storybook/addon-svelte-csf"; 3 3 import Button from "./Button.svelte"; 4 - import ButtonGroup from "./ButtonGroup.svelte"; 4 + import ButtonGroup, { segmentProps } from "./ButtonGroup.svelte"; 5 5 6 6 const { Story } = defineMeta({ 7 7 title: "UI/ButtonGroup", ··· 47 47 </ButtonGroup> 48 48 </div> 49 49 </Story> 50 + 51 + <!-- 52 + The tab/segment-switcher recipe (Open/Closed issue filters, Write/Preview editor tabs): 53 + the selected segment gets the `default` variant + insetShadow, unselected segments are 54 + `ghost`. Never plain `default` for unselected — that reads as the hovered state. 55 + --> 56 + <Story name="Tab Switcher"> 57 + <ButtonGroup> 58 + <Button {...segmentProps(true)}>Open</Button> 59 + <Button {...segmentProps(false)}>Closed</Button> 60 + </ButtonGroup> 61 + </Story>
+27 -2
web/src/lib/components/ui/ButtonGroup.svelte
··· 1 1 <script module lang="ts"> 2 2 import { tv, type VariantProps } from "tailwind-variants"; 3 + import type { ButtonVariants } from "./Button.svelte"; 3 4 4 5 export const buttonGroup = tv({ 5 6 base: "inline-flex items-center", 6 7 variants: { 8 + // joined groups are the segmented control from Figma: the group draws the outer 9 + // border and the recessed surface, and each segment's own 1px border is pulled 10 + // onto it (-m-px) so an unselected segment shows the group's surface while a 11 + // selected one covers it and reads as a raised card. 7 12 spaced: { 8 13 true: "gap-2", 9 14 false: [ 10 - "gap-0", 11 - "*:relative", 15 + "gap-0 rounded border border-border-default bg-background-subtle", 16 + "*:relative *:-my-px", 17 + "[&>*:first-child]:-ml-px [&>*:last-child]:-mr-px", 12 18 "[&>*:not(:first-child)]:-ml-px [&>*:not(:first-child)]:[--btn-radius-l:0px]", 13 19 "[&>*:not(:last-child)]:[--btn-radius-r:0px]", 14 20 "[&>*:focus-visible]:z-10 [&>*:hover]:z-10" ··· 21 27 }); 22 28 23 29 export type ButtonGroupVariants = VariantProps<typeof buttonGroup>; 30 + 31 + /** 32 + * Recipe for using ButtonGroup as a tab/segment switcher (e.g. Open/Closed issue filters, 33 + * Write/Preview editor tabs). Per Figma, the selected segment reads as a raised, lighter 34 + * surface (`default` + its inset shadow) while unselected segments are flat `ghost` 35 + * buttons — never the plain `default` variant, which reads as the *hovered* state of an 36 + * unselected segment. Figma also gives an unselected segment the same foreground as a 37 + * selected one, where `ghost` on its own is muted, so the recipe overrides the colour 38 + * here rather than changing `ghost` everywhere it's used. 39 + */ 40 + export function segmentProps(selected: boolean): { 41 + variant: ButtonVariants["variant"]; 42 + insetShadow: boolean; 43 + class?: string; 44 + } { 45 + return selected 46 + ? { variant: "default", insetShadow: true } 47 + : { variant: "ghost", insetShadow: false, class: "text-foreground-default" }; 48 + } 24 49 </script> 25 50 26 51 <script lang="ts">
+3 -3
web/src/lib/components/ui/MarkdownEditor.svelte
··· 3 3 import Eye from "$icon/eye"; 4 4 import Pencil from "$icon/pencil"; 5 5 import Button from "./Button.svelte"; 6 - import ButtonGroup from "./ButtonGroup.svelte"; 6 + import ButtonGroup, { segmentProps } from "./ButtonGroup.svelte"; 7 7 import Textarea from "./Textarea.svelte"; 8 8 import { renderMarkup, type MarkupContext } from "$lib/markup"; 9 9 ··· 86 86 type="button" 87 87 size="sm" 88 88 icon={Pencil} 89 - variant={tab === "write" ? "default" : "ghost"} 89 + {...segmentProps(tab === "write")} 90 90 onclick={() => (tab = "write")} 91 91 > 92 92 Write ··· 95 95 type="button" 96 96 size="sm" 97 97 icon={Eye} 98 - variant={tab === "preview" ? "default" : "ghost"} 98 + {...segmentProps(tab === "preview")} 99 99 onclick={() => (tab = "preview")} 100 100 > 101 101 Preview