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 =