From 5bb9af4e391d873e83a10dff025d33883e19816b Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Tue, 7 Jul 2026 22:41:45 +0400 Subject: [PATCH] feat(ui): board loading skeleton + empty state (U4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the U4 data-states gap from the board-empty-loading-first-run mockup: the pilots showed a plain 'Loading…' line and, with zero tasks, four blank columns under a useless toolbar. - BoardSkeleton (libs/ui): shimmer placeholder mirroring the column/card layout so the load→data transition doesn't shift the page; role=status + aria-busy + injectable label; honors prefers-reduced-motion - BoardView: a centered empty state (heading + description) when there are zero tasks and emptyTitle is supplied — replaces the whole board rather than rendering an empty toolbar over blank columns - both pilots: loading → , and pass empty title/description to BoardView; i18n pilot.empty.* (en/fr, both apps) Verified: libs/ui + Electron tsc 0, web/electron biome clean, 24 adapter tests green; skeleton (4 cols / shimmer / aria-busy) and empty state (replaces board, no toolbar) confirmed live in the demo via DOM. Co-Authored-By: Claude Fable 5 Signed-off-by: Oleg Miagkov --- .../renderer/components/KanbanPilotView.tsx | 5 +- .../src/shared/i18n/locales/en/kanban.json | 4 ++ .../src/shared/i18n/locales/fr/kanban.json | 4 ++ .../src/i18n/locales/en/tasks.json | 4 ++ .../src/i18n/locales/fr/tasks.json | 4 ++ apps/web-frontend/src/pages/KanbanPilot.tsx | 5 +- libs/ui/src/index.ts | 3 + libs/ui/src/screens/BoardSkeleton.css | 67 +++++++++++++++++++ libs/ui/src/screens/BoardSkeleton.tsx | 40 +++++++++++ libs/ui/src/screens/BoardView.css | 27 ++++++++ libs/ui/src/screens/BoardView.tsx | 20 ++++++ 11 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 libs/ui/src/screens/BoardSkeleton.css create mode 100644 libs/ui/src/screens/BoardSkeleton.tsx diff --git a/apps/frontend/src/renderer/components/KanbanPilotView.tsx b/apps/frontend/src/renderer/components/KanbanPilotView.tsx index edd7d2f00..42c3e92fa 100644 --- a/apps/frontend/src/renderer/components/KanbanPilotView.tsx +++ b/apps/frontend/src/renderer/components/KanbanPilotView.tsx @@ -11,6 +11,7 @@ import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AutoCodeClientProvider, + BoardSkeleton, BoardView, TaskDetail as UiTaskDetail, buildBoardViewLabels, @@ -47,7 +48,7 @@ function PilotBoard({ onOpen }: Readonly<{ onOpen: (id: string) => void }>) { return (

{t('kanban:pilot.title')}

- {loading &&

{t('kanban:pilot.loading')}

} + {loading && } {error && (

{t('kanban:pilot.error')}{' '} @@ -62,6 +63,8 @@ function PilotBoard({ onOpen }: Readonly<{ onOpen: (id: string) => void }>) { columns={columns} labels={labels} onSelectTask={(task) => onOpen(task.id)} + emptyTitle={t('kanban:pilot.empty.title')} + emptyDescription={t('kanban:pilot.empty.description')} /> )}

diff --git a/apps/frontend/src/shared/i18n/locales/en/kanban.json b/apps/frontend/src/shared/i18n/locales/en/kanban.json index 3fd148731..93abee84b 100644 --- a/apps/frontend/src/shared/i18n/locales/en/kanban.json +++ b/apps/frontend/src/shared/i18n/locales/en/kanban.json @@ -22,6 +22,10 @@ "loading": "Loading tasks…", "error": "Failed to load tasks.", "retry": "Retry", + "empty": { + "title": "No tasks yet", + "description": "Create your first spec to see it on the board." + }, "columns": { "draft": "Draft", "running": "Running", diff --git a/apps/frontend/src/shared/i18n/locales/fr/kanban.json b/apps/frontend/src/shared/i18n/locales/fr/kanban.json index 507b19c5b..699f7c6e0 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/kanban.json +++ b/apps/frontend/src/shared/i18n/locales/fr/kanban.json @@ -22,6 +22,10 @@ "loading": "Chargement des tâches…", "error": "Échec du chargement des tâches.", "retry": "Réessayer", + "empty": { + "title": "Aucune tâche pour le moment", + "description": "Créez votre première spéc pour la voir sur le tableau." + }, "columns": { "draft": "Brouillon", "running": "En cours", diff --git a/apps/web-frontend/src/i18n/locales/en/tasks.json b/apps/web-frontend/src/i18n/locales/en/tasks.json index a49ea9585..74e129da9 100644 --- a/apps/web-frontend/src/i18n/locales/en/tasks.json +++ b/apps/web-frontend/src/i18n/locales/en/tasks.json @@ -120,6 +120,10 @@ "loading": "Loading tasks…", "error": "Failed to load tasks.", "retry": "Retry", + "empty": { + "title": "No tasks yet", + "description": "Create your first spec to see it on the board." + }, "columns": { "draft": "Draft", "running": "Running", diff --git a/apps/web-frontend/src/i18n/locales/fr/tasks.json b/apps/web-frontend/src/i18n/locales/fr/tasks.json index 250ac6736..2c811bee9 100644 --- a/apps/web-frontend/src/i18n/locales/fr/tasks.json +++ b/apps/web-frontend/src/i18n/locales/fr/tasks.json @@ -120,6 +120,10 @@ "loading": "Chargement des tâches…", "error": "Échec du chargement des tâches.", "retry": "Réessayer", + "empty": { + "title": "Aucune tâche pour le moment", + "description": "Créez votre première spéc pour la voir sur le tableau." + }, "columns": { "draft": "Brouillon", "running": "En cours", diff --git a/apps/web-frontend/src/pages/KanbanPilot.tsx b/apps/web-frontend/src/pages/KanbanPilot.tsx index 1d42daba4..8ffeba73a 100644 --- a/apps/web-frontend/src/pages/KanbanPilot.tsx +++ b/apps/web-frontend/src/pages/KanbanPilot.tsx @@ -9,6 +9,7 @@ import type { KanbanColumn, UiTask } from "@auto-code/ui"; import { AutoCodeClientProvider, + BoardSkeleton, BoardView, buildBoardViewLabels, useTasks, @@ -45,7 +46,7 @@ function PilotBoard() { return (

{t("tasks:kanbanPilot.title")}

- {loading &&

{t("tasks:kanbanPilot.loading")}

} + {loading && } {error && (

{t("tasks:kanbanPilot.error")}{" "} @@ -60,6 +61,8 @@ function PilotBoard() { columns={columns} labels={labels} onSelectTask={handleSelect} + emptyTitle={t("tasks:kanbanPilot.empty.title")} + emptyDescription={t("tasks:kanbanPilot.empty.description")} /> )}

diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 6e95b5156..f2b8a15ec 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -46,6 +46,9 @@ export type { BoardViewLabels, Translate, } from './screens/BoardView'; +// Board data states (U4) +export { BoardSkeleton } from './screens/BoardSkeleton'; +export type { BoardSkeletonProps } from './screens/BoardSkeleton'; export { filterUiTasks } from './client/filtering'; export type { UiTaskFilter } from './client/filtering'; export { useBoardFilter, FILTER_IDS } from './client/useBoardFilter'; diff --git a/libs/ui/src/screens/BoardSkeleton.css b/libs/ui/src/screens/BoardSkeleton.css new file mode 100644 index 000000000..66a4fed0b --- /dev/null +++ b/libs/ui/src/screens/BoardSkeleton.css @@ -0,0 +1,67 @@ +.ac-board-skeleton { + display: grid; + grid-template-columns: repeat(4, minmax(220px, 1fr)); + gap: 14px; + align-items: start; + padding: 18px 28px 28px; + font-family: var(--font-sans); +} + +.ac-board-skeleton__column { + min-width: 0; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--soft); +} + +.ac-board-skeleton__head { + height: 40px; + border-bottom: 1px solid var(--line); +} + +.ac-board-skeleton__body { + display: grid; + gap: 10px; + padding: 10px; +} + +.ac-board-skeleton__card { + height: 64px; + border-radius: 10px; + background: linear-gradient( + 90deg, + var(--rail) 25%, + var(--line) 37%, + var(--rail) 63% + ); + background-size: 400% 100%; + animation: ac-board-skeleton-shimmer 1.4s ease-in-out infinite; +} + +@keyframes ac-board-skeleton-shimmer { + from { + background-position: 100% 0; + } + to { + background-position: 0 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .ac-board-skeleton__card { + animation: none; + } +} + +@media (max-width: 1180px) { + .ac-board-skeleton { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .ac-board-skeleton { + grid-template-columns: 1fr; + padding: 14px; + } +} diff --git a/libs/ui/src/screens/BoardSkeleton.tsx b/libs/ui/src/screens/BoardSkeleton.tsx new file mode 100644 index 000000000..b32f1c990 --- /dev/null +++ b/libs/ui/src/screens/BoardSkeleton.tsx @@ -0,0 +1,40 @@ +import './BoardSkeleton.css'; + +export interface BoardSkeletonProps { + /** Number of placeholder columns (defaults to 4, matching the board). */ + columns?: number; + /** Accessible loading label (announced to screen readers). */ + label?: string; +} + +const CARDS_PER_COLUMN = [2, 3, 1, 2]; + +/** + * Shimmer placeholder shown while the board's tasks load, mirroring the + * KanbanBoard column/card layout so the transition to real data doesn't + * shift the page. Presentational — pair with `useTasks().loading`. + */ +export function BoardSkeleton({ columns = 4, label }: Readonly) { + return ( +
+ {Array.from({ length: columns }, (_, columnIndex) => ( +
+
+
+ {Array.from( + { length: CARDS_PER_COLUMN[columnIndex % CARDS_PER_COLUMN.length] }, + (_, cardIndex) => ( +
+ ), + )} +
+
+ ))} +
+ ); +} diff --git a/libs/ui/src/screens/BoardView.css b/libs/ui/src/screens/BoardView.css index af23d1653..7ada2b823 100644 --- a/libs/ui/src/screens/BoardView.css +++ b/libs/ui/src/screens/BoardView.css @@ -6,3 +6,30 @@ font-family: var(--font-sans); font-size: 12.5px; } + +.ac-board-view__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 320px; + padding: 48px 28px; + text-align: center; + font-family: var(--font-sans); +} + +.ac-board-view__empty-title { + margin: 0; + color: var(--ink); + font-size: 15px; + font-weight: 760; +} + +.ac-board-view__empty-desc { + margin: 0; + max-width: 42ch; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} diff --git a/libs/ui/src/screens/BoardView.tsx b/libs/ui/src/screens/BoardView.tsx index 36515fe53..c26b15055 100644 --- a/libs/ui/src/screens/BoardView.tsx +++ b/libs/ui/src/screens/BoardView.tsx @@ -62,6 +62,10 @@ export interface BoardViewProps { columns?: KanbanColumn[]; labels: BoardViewLabels; onSelectTask: (task: UiTask) => void; + /** Heading for the genuinely-empty board (no tasks at all). */ + emptyTitle?: string; + /** Sub-text under the empty heading. */ + emptyDescription?: string; } /** @@ -69,16 +73,32 @@ export interface BoardViewProps { * switch) over a live-narrowing KanbanBoard, with a polite status line for * the match count (WCAG 4.1.3). Both app pilots render this identically; * loading/error states stay with the caller. + * + * With zero tasks it shows a centered empty state (when emptyTitle is given) + * instead of an empty toolbar over four blank columns. */ export function BoardView({ tasks, columns, labels, onSelectTask, + emptyTitle, + emptyDescription, }: Readonly) { const { query, setQuery, filterId, setFilterId, filtering, visibleTasks } = useBoardFilter(tasks); + if (tasks.length === 0 && emptyTitle != null) { + return ( +
+

{emptyTitle}

+ {emptyDescription != null && ( +

{emptyDescription}

+ )} +
+ ); + } + let matchStatus = ''; if (filtering) { matchStatus =