From 96e86edfb55d3895b34abc719b0a4fd49dc8c9e1 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Sat, 11 Jul 2026 09:14:16 +0400 Subject: [PATCH 1/2] feat(ui): extract shared Badge + Kbd primitives (U0.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundational primitives for the shared design system, de-duplicating the tonal-pill visual language that had drifted across three screens. - Badge (libs/ui/primitives): tone (good/info/warn/bad/neutral, from the shared BadgeTone) + size ('sm' dense card chip / 'md' detail badge). The tone colors were identical everywhere; size preserves the two historical sizings exactly. - Kbd: inline keyboard-hint text. - refactored KanbanBoard (statusChip + outcome badges → Badge size=sm), TaskDetail (badges → Badge size=md), BoardToolbar (⌘K → Kbd); removed the now-duplicated .ac-kanban__badge / .ac-task-detail__badge / .ac-toolbar__kbd CSS. Pixel-identical, verified live via computed styles: sm = min-height 22 / 0 7px / 10.5px·780 / radius 6 / nowrap; md = 2px 8px / 11px·600; tones resolve to the same tokens. libs/ui + Electron tsc 0, 24 adapter tests green, no stale badge classes remain. Storybook + Button/Input primitives follow separately (U0.5b) to isolate the Storybook dependency's impact on the hoisted-lockfile frontend CI. Co-Authored-By: Claude Fable 5 Signed-off-by: Oleg Miagkov --- libs/ui/src/index.ts | 5 ++++ libs/ui/src/primitives/Badge.css | 42 ++++++++++++++++++++++++++++ libs/ui/src/primitives/Badge.tsx | 28 +++++++++++++++++++ libs/ui/src/primitives/Kbd.css | 5 ++++ libs/ui/src/primitives/Kbd.tsx | 11 ++++++++ libs/ui/src/screens/BoardToolbar.css | 6 +--- libs/ui/src/screens/BoardToolbar.tsx | 5 ++-- libs/ui/src/screens/KanbanBoard.css | 17 +---------- libs/ui/src/screens/KanbanBoard.tsx | 14 +++++----- libs/ui/src/screens/TaskDetail.css | 12 +------- libs/ui/src/screens/TaskDetail.tsx | 8 ++++-- 11 files changed, 108 insertions(+), 45 deletions(-) create mode 100644 libs/ui/src/primitives/Badge.css create mode 100644 libs/ui/src/primitives/Badge.tsx create mode 100644 libs/ui/src/primitives/Kbd.css create mode 100644 libs/ui/src/primitives/Kbd.tsx diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index f2b8a15ec..ea4a2af2d 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -1,3 +1,8 @@ +// Primitives (U0.5) +export { Badge } from './primitives/Badge'; +export type { BadgeProps } from './primitives/Badge'; +export { Kbd } from './primitives/Kbd'; +export type { KbdProps } from './primitives/Kbd'; export { ThemeProvider, useTheme } from './theme/ThemeProvider'; export type { Theme, ResolvedTheme, ThemeProviderProps } from './theme/ThemeProvider'; export { AppShell } from './shell/AppShell'; diff --git a/libs/ui/src/primitives/Badge.css b/libs/ui/src/primitives/Badge.css new file mode 100644 index 000000000..6f89e07fc --- /dev/null +++ b/libs/ui/src/primitives/Badge.css @@ -0,0 +1,42 @@ +.ac-badge { + border-radius: 6px; + white-space: nowrap; +} + +/* Dense card chip (was .ac-kanban__badge). */ +.ac-badge--sm { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + font-size: 10.5px; + font-weight: 780; +} + +/* Detail / label badge (was .ac-task-detail__badge). */ +.ac-badge--md { + padding: 2px 8px; + font-size: 11px; + font-weight: 600; +} + +.ac-badge--good { + color: var(--green); + background: var(--green-soft); +} +.ac-badge--info { + color: var(--blue); + background: var(--blue-soft); +} +.ac-badge--warn { + color: var(--amber); + background: var(--amber-soft); +} +.ac-badge--bad { + color: var(--red); + background: var(--red-soft); +} +.ac-badge--neutral { + color: var(--muted); + background: var(--rail); +} diff --git a/libs/ui/src/primitives/Badge.tsx b/libs/ui/src/primitives/Badge.tsx new file mode 100644 index 000000000..5df4eae6c --- /dev/null +++ b/libs/ui/src/primitives/Badge.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; +import type { BadgeTone } from '../client/types'; +import './Badge.css'; + +export interface BadgeProps { + /** Semantic color; defaults to neutral. */ + tone?: BadgeTone; + /** 'sm' for dense card chips, 'md' for detail/label badges. */ + size?: 'sm' | 'md'; + children: ReactNode; +} + +/** + * Tonal pill used across the board and detail screens (status chips, outcome + * badges). The tone colors are shared design tokens; `size` preserves the two + * historical sizings (dense card chip vs. detail badge). + */ +export function Badge({ + tone = 'neutral', + size = 'md', + children, +}: Readonly) { + return ( + + {children} + + ); +} diff --git a/libs/ui/src/primitives/Kbd.css b/libs/ui/src/primitives/Kbd.css new file mode 100644 index 000000000..75b386e28 --- /dev/null +++ b/libs/ui/src/primitives/Kbd.css @@ -0,0 +1,5 @@ +.ac-kbd { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + color: var(--quiet); +} diff --git a/libs/ui/src/primitives/Kbd.tsx b/libs/ui/src/primitives/Kbd.tsx new file mode 100644 index 000000000..8136a64ac --- /dev/null +++ b/libs/ui/src/primitives/Kbd.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react'; +import './Kbd.css'; + +export interface KbdProps { + children: ReactNode; +} + +/** Inline keyboard-hint text (e.g. a "⌘K" shortcut label). */ +export function Kbd({ children }: Readonly) { + return {children}; +} diff --git a/libs/ui/src/screens/BoardToolbar.css b/libs/ui/src/screens/BoardToolbar.css index 33c6a5fa9..f42044982 100644 --- a/libs/ui/src/screens/BoardToolbar.css +++ b/libs/ui/src/screens/BoardToolbar.css @@ -44,11 +44,7 @@ color: var(--muted); } -.ac-toolbar__kbd { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 10.5px; - color: var(--quiet); -} +/* The ⌘K hint uses the shared primitive. */ /* The groups are fieldsets for semantics; reset the UA chrome. */ .ac-toolbar__filters { diff --git a/libs/ui/src/screens/BoardToolbar.tsx b/libs/ui/src/screens/BoardToolbar.tsx index ebb5dc93a..bf39a3b32 100644 --- a/libs/ui/src/screens/BoardToolbar.tsx +++ b/libs/ui/src/screens/BoardToolbar.tsx @@ -1,4 +1,5 @@ import type { ChangeEvent } from 'react'; +import { Kbd } from '../primitives/Kbd'; import './BoardToolbar.css'; export interface BoardToolbarFilter { @@ -70,9 +71,7 @@ export function BoardToolbar({ aria-label={searchLabel ?? searchPlaceholder} onChange={handleSearch} /> - {searchShortcut != null && ( - {searchShortcut} - )} + {searchShortcut != null && {searchShortcut}} )} {filters != null && filters.length > 0 && ( diff --git a/libs/ui/src/screens/KanbanBoard.css b/libs/ui/src/screens/KanbanBoard.css index b33a6af94..c539335d4 100644 --- a/libs/ui/src/screens/KanbanBoard.css +++ b/libs/ui/src/screens/KanbanBoard.css @@ -101,22 +101,7 @@ flex-wrap: wrap; } -.ac-kanban__badge { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 7px; - border-radius: 6px; - font-size: 10.5px; - font-weight: 780; - white-space: nowrap; -} - -.ac-kanban__badge--good { color: var(--green); background: var(--green-soft); } -.ac-kanban__badge--info { color: var(--blue); background: var(--blue-soft); } -.ac-kanban__badge--warn { color: var(--amber); background: var(--amber-soft); } -.ac-kanban__badge--bad { color: var(--red); background: var(--red-soft); } -.ac-kanban__badge--neutral { color: var(--muted); background: var(--rail); } +/* Status chip + outcome badges use the shared primitive. */ .ac-kanban__card-meta { display: flex; diff --git a/libs/ui/src/screens/KanbanBoard.tsx b/libs/ui/src/screens/KanbanBoard.tsx index 33d550482..110f458ce 100644 --- a/libs/ui/src/screens/KanbanBoard.tsx +++ b/libs/ui/src/screens/KanbanBoard.tsx @@ -1,5 +1,6 @@ import { Fragment } from 'react'; import type { KeyboardEvent } from 'react'; +import { Badge } from '../primitives/Badge'; import type { TaskStatus, UiTask } from '../client/types'; import './KanbanBoard.css'; @@ -81,11 +82,9 @@ function KanbanCard({ task, onSelect }: KanbanCardProps) {
{task.id} {task.statusChip != null && ( - + {task.statusChip.label} - + )}

{task.title}

@@ -100,12 +99,13 @@ function KanbanCard({ task, onSelect }: KanbanCardProps) { {task.badges != null && task.badges.length > 0 && (
{task.badges.map((badge) => ( - {badge.label} - + ))}
)} diff --git a/libs/ui/src/screens/TaskDetail.css b/libs/ui/src/screens/TaskDetail.css index 7c4cd26ae..933132a05 100644 --- a/libs/ui/src/screens/TaskDetail.css +++ b/libs/ui/src/screens/TaskDetail.css @@ -80,17 +80,7 @@ flex-wrap: wrap; gap: 6px; } -.ac-task-detail__badge { - font-size: 11px; - font-weight: 600; - padding: 2px 8px; - border-radius: 6px; -} -.ac-task-detail__badge--good { color: var(--green); background: var(--green-soft); } -.ac-task-detail__badge--info { color: var(--blue); background: var(--blue-soft); } -.ac-task-detail__badge--warn { color: var(--amber); background: var(--amber-soft); } -.ac-task-detail__badge--bad { color: var(--red); background: var(--red-soft); } -.ac-task-detail__badge--neutral { color: var(--muted); background: var(--rail); } +/* Badges use the shared primitive. */ .ac-task-detail__desc { margin: 0; diff --git a/libs/ui/src/screens/TaskDetail.tsx b/libs/ui/src/screens/TaskDetail.tsx index de8046dd2..9dc40661d 100644 --- a/libs/ui/src/screens/TaskDetail.tsx +++ b/libs/ui/src/screens/TaskDetail.tsx @@ -1,3 +1,4 @@ +import { Badge } from '../primitives/Badge'; import type { TaskStatus, UiTaskDetail } from '../client/types'; import './TaskDetail.css'; @@ -95,12 +96,13 @@ function TaskDetailBody({ task, statusLabels }: Readonly) { {task.badges != null && task.badges.length > 0 && (
{task.badges.map((badge) => ( - {badge.label} - + ))}
)} From 3f28a1f9c8a74a3a5f5eb4898298eaa10f60af9e Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Sat, 11 Jul 2026 09:24:17 +0400 Subject: [PATCH 2/2] fix(ui): forward rest props on Badge, semantic element (U0.5 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Badge: props extend HTMLAttributes — className is merged with the ac-badge classes and aria-*/data-*/handlers forward to the span, so consumers don't need a wrapper for standard attributes - Kbd: renders the semantic element instead of a ; the .ac-kbd class already sets font-family/size, so visual output is unchanged. Rest attributes forward likewise. Verified: libs/ui + Electron tsc 0; badge computed styles unchanged in the live demo (22px / 0 7px / 10.5px / 780). Co-Authored-By: Claude Fable 5 Signed-off-by: Oleg Miagkov --- libs/ui/src/primitives/Badge.tsx | 15 ++++++++++----- libs/ui/src/primitives/Kbd.tsx | 19 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/libs/ui/src/primitives/Badge.tsx b/libs/ui/src/primitives/Badge.tsx index 5df4eae6c..76fe73669 100644 --- a/libs/ui/src/primitives/Badge.tsx +++ b/libs/ui/src/primitives/Badge.tsx @@ -1,27 +1,32 @@ -import type { ReactNode } from 'react'; +import type { HTMLAttributes } from 'react'; import type { BadgeTone } from '../client/types'; import './Badge.css'; -export interface BadgeProps { +export interface BadgeProps extends HTMLAttributes { /** Semantic color; defaults to neutral. */ tone?: BadgeTone; /** 'sm' for dense card chips, 'md' for detail/label badges. */ size?: 'sm' | 'md'; - children: ReactNode; } /** * Tonal pill used across the board and detail screens (status chips, outcome * badges). The tone colors are shared design tokens; `size` preserves the two - * historical sizings (dense card chip vs. detail badge). + * historical sizings (dense card chip vs. detail badge). Remaining span + * attributes (className, aria-*, data-*, handlers) are forwarded. */ export function Badge({ tone = 'neutral', size = 'md', + className, children, + ...rest }: Readonly) { + const classes = `ac-badge ac-badge--${size} ac-badge--${tone}${ + className ? ` ${className}` : '' + }`; return ( - + {children} ); diff --git a/libs/ui/src/primitives/Kbd.tsx b/libs/ui/src/primitives/Kbd.tsx index 8136a64ac..1e8bbebb4 100644 --- a/libs/ui/src/primitives/Kbd.tsx +++ b/libs/ui/src/primitives/Kbd.tsx @@ -1,11 +1,16 @@ -import type { ReactNode } from 'react'; +import type { HTMLAttributes } from 'react'; import './Kbd.css'; -export interface KbdProps { - children: ReactNode; -} +export type KbdProps = HTMLAttributes; -/** Inline keyboard-hint text (e.g. a "⌘K" shortcut label). */ -export function Kbd({ children }: Readonly) { - return {children}; +/** + * Inline keyboard-hint text (e.g. a "⌘K" shortcut label), rendered as the + * semantic element. Remaining attributes are forwarded. + */ +export function Kbd({ className, children, ...rest }: Readonly) { + return ( + + {children} + + ); }