The shell, styling, i18n, and theming are in place. Now we give the board a real data model: TypeScript contracts, column metadata, priority mappings, and initial tasks. This is where the placeholder columns become the actual Kanban columns.
Files this lesson: src/app/types.ts (new), src/app/data.ts (new), src/app/components/KanbanBoard.tsx (rewritten). page.tsx stays untouched.
1. Domain Types (types.ts)
Create src/app/types.ts to define the domain contracts for tasks, columns, priorities, and drag state:
import type { ComponentType } from 'react';
export type ColumnId = 'todo' | 'in-progress' | 'in-review' | 'done';export type Priority = 'high' | 'medium' | 'low';
export interface WorkItem { id: string; column: ColumnId; ref: string; priority: Priority; title: string; description: string; lastEdited: string; dueDate: string;}
export interface ColumnMeta { id: ColumnId; title: string; variant: 'neutral' | 'accent' | 'warning' | 'success'; tooltip: string; emptyTitle: string; emptyDescription: string; emptyIcon: ComponentType<{ className?: string }>;}
export interface DropTarget { column: ColumnId; index: number;}
export interface DragState { id: string; width: number; height: number; offsetX: number; offsetY: number; pointerX: number; pointerY: number; target: DropTarget | null;}Note how ColumnMeta.variant is typed to exactly the variants StatusDot accepts (neutral, accent, warning, success) — an invalid combination is a compile error instead of a runtime surprise. DragState and DropTarget are defined now and used by the drag hook in lesson 11.
2. Columns Metadata & Initial Data (data.ts)
Create src/app/data.ts to export static configuration metadata and sample data:
import { ArrowPathIcon, CheckCircleIcon, ClipboardDocumentCheckIcon, InboxIcon,} from '@heroicons/react/24/outline';import type { ColumnMeta, Priority, WorkItem } from './types';
export const DRAG_THRESHOLD = 5;export const COLUMN_WIDTH = 300;
export const COLUMNS: ColumnMeta[] = [ { id: 'todo', title: 'To-do', variant: 'neutral', tooltip: 'Items assigned to this sprint, waiting to be picked up.', emptyTitle: 'To-do is empty', emptyDescription: 'Items pulled into this sprint appear here.', emptyIcon: InboxIcon, }, { id: 'in-progress', title: 'In progress', variant: 'accent', tooltip: 'Items currently in progress.', emptyTitle: 'Nothing in progress', emptyDescription: 'Items being worked on appear here.', emptyIcon: ArrowPathIcon, }, { id: 'in-review', title: 'In review', variant: 'warning', tooltip: 'Items waiting for your review.', emptyTitle: 'Nothing in review', emptyDescription: 'Items awaiting your review appear here.', emptyIcon: ClipboardDocumentCheckIcon, }, { id: 'done', title: 'Done', variant: 'success', tooltip: 'Items that have been handled.', emptyTitle: 'Nothing done yet', emptyDescription: 'Completed items appear here.', emptyIcon: CheckCircleIcon, },];
export const PRIORITY_META: Record< Priority, { label: string; variant: 'error' | 'warning' | 'teal' }> = { high: { label: 'High', variant: 'error' }, medium: { label: 'Medium', variant: 'warning' }, low: { label: 'Low', variant: 'teal' },};
export const INITIAL_ITEMS: WorkItem[] = [ { id: 't1', column: 'todo', ref: 'Task 4821', priority: 'low', title: 'Draft project kickoff brief', description: 'Write a short brief outlining goals, scope, and success criteria.', lastEdited: '2h ago', dueDate: 'Jul 8', }, { id: 'p1', column: 'in-progress', ref: 'Task 4825', priority: 'high', title: 'Design the landing page layout', description: 'Create a first-pass layout for the landing page.', lastEdited: '18m ago', dueDate: 'Jul 3', }, { id: 'r1', column: 'done', ref: 'Task 4788', priority: 'low', title: 'Write the weekly status update', description: 'Summarize progress, blockers, and next steps for the team.', lastEdited: 'Yesterday', dueDate: 'Jul 1', },];
COLUMN_WIDTHlives in two places on purpose.data.tsexports it as the domain-level source of truth for components that need the number.styles.ts(lesson 4) keeps its own local copy because StyleX inlines every value insidestylex.createat build time and can’t resolve the./dataimport — see the gotcha note in lesson 4.
Astryx Concepts: Icons & Semantic Variants
This lesson’s data layer is deliberately design-system-aware — the models are shaped by Astryx’s vocabulary so they flow straight into components without translation:
- Heroicons integration: Astryx components accept Heroicons icons out of the box.
ColumnMeta.emptyIconstores the icon component (typed asComponentType<{ className?: string }>), so each column can render its own empty-state icon later (lesson 9). The four icons introduced here —InboxIcon,ArrowPathIcon,ClipboardDocumentCheckIcon,CheckCircleIcon— each describe a column’s state. - Semantic variant vocabulary:
ColumnMeta.variantis typed to exactly the variantsStatusDotaccepts (neutral,accent,warning,success), andPRIORITY_METAmaps priorities to badge variants (error,warning,teal). - One nuance:
BadgeandStatusDotshareneutral,warning, andsuccess, butaccentexists only onStatusDot— the accent-colored badge isinfo. The column shell in lesson 9 passesmeta.variantstraight toStatusDot, whereaccentis valid.
This is the Astryx way to model domain data: define your contracts against the design system’s tokens and variants, not against ad-hoc strings.
Rendering the Data: KanbanBoard.tsx
Rewrite src/app/components/KanbanBoard.tsx to map COLUMNS into the styled column shells and INITIAL_ITEMS into task cards. Every style rule comes from styles.ts; every string that is user-facing goes through the translator:
'use client';
import { Section } from '@astryxdesign/core/Section';import { Layout, LayoutHeader, LayoutContent, HStack, VStack,} from '@astryxdesign/core/Layout';import { Heading, Text } from '@astryxdesign/core/Text';import { Card } from '@astryxdesign/core/Card';import { Button } from '@astryxdesign/core/Button';import { useTranslator } from '@astryxdesign/core/i18n';import { usePreferences } from '../providers';import { styles } from '../styles';import { COLUMNS, INITIAL_ITEMS } from '../data';
export default function KanbanBoard() { const t = useTranslator(); const { mode, setMode, locale, setLocale } = usePreferences();
return ( <Section height="100dvh"> <Layout height="fill" header={ <LayoutHeader hasDivider padding={4}> <HStack hAlign="between" vAlign="center"> <Heading level={3}>{t('@app.board.title')}</Heading> <HStack gap={2} vAlign="center"> <Text type="supporting" color="secondary" hasTabularNumbers> 4 {t('@app.tasks')} {INITIAL_ITEMS.length} {t('@app.tasks')} </Text> <Button label={t('@app.switchLanguage')} variant="secondary" onClick={() => setLocale(locale === 'en' ? 'fr' : 'en')} /> <Button label={ mode === 'system' ? 'Light mode' : mode === 'light' ? 'Dark mode' : 'System mode' } variant="secondary" onClick={() => setMode( mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system', ) } /> </HStack> </HStack> </LayoutHeader> } content={ <LayoutContent padding={0}> <HStack gap={4} xstyle={styles.boardColumns}> {[0, 1, 2, 3].map(i => ( <Card key={i} variant="muted" padding={0} xstyle={styles.columnShell} > <VStack gap={3} padding={3}> <Heading level={4}>Column {i + 1}</Heading> <Card padding={3} xstyle={styles.card}> <Heading level={5}>Placeholder task</Heading> <Text type="supporting" color="secondary" maxLines={2}> A placeholder card inside the styled column shell. </Text> </Card> </VStack> </Card> ))} {COLUMNS.map(col => { const items = INITIAL_ITEMS.filter(it => it.column === col.id); return ( <Card key={col.id} variant="muted" padding={0} xstyle={styles.columnShell} > <VStack gap={3} padding={3}> <Heading level={4}>{col.title}</Heading> <Text type="supporting" color="secondary" hasTabularNumbers > {items.length} {t('@app.tasks')} </Text> <VStack gap={2}> {items.map(item => ( <Card key={item.id} padding={3} xstyle={styles.card} > <Heading level={5}>{item.title}</Heading> <Text type="supporting" color="secondary" maxLines={2} > {item.description} </Text> </Card> ))} </VStack> </VStack> </Card> ); })} </HStack> </LayoutContent> } /> </Section> );}The columns now come from COLUMNS (title, variant, and — soon — status dot and empty state), and the tasks come from INITIAL_ITEMS. Columns with no tasks render with just their header — the empty state arrives in lesson 9.
See It in Action
Run npm run dev. The board now shows three real columns with tasks (To-do has one, In progress has one, Done has one, In review is empty), the header counter reads the real total, and hovering any task card still lifts it. Flip the language and theme toggles — data-driven rendering composes with both.
What You Built
types.ts— contracts typed against Astryx variant vocabularies.data.ts— column metadata, priority mappings, and initial tasks.- A data-driven board: columns and cards rendered from
COLUMNSandINITIAL_ITEMS.
In the next lesson, we will extract the task presentation into its own component: BoardCardBody.