Dashboard home: structure
How to build each block of the Dashboard home template from @sportingscouter/ui: one structure snippet per block, with the recipe calls, the variants and where the app's words go. Copy the block, fill the slots with data, keep the names. The snippets use React, but the package ships class strings only, so the same calls work in Astro or plain templates.
The snippets are tested against the recipes: a test reads every slot and variant from the recipe source and checks each call, and check.sh (next to the generator in docs/superpowers/plans/assets/dashboard-home-snippets/) type-checks them against the real recipes in strict mode; run it before a release. A block marked Gap works with the recipes as they are, but the template looks better than the recipes alone can draw it: the table at the end lists what a later release should add. Do not paper over a gap in the app with your own classes.
Where each block stands
| # | Block | Built from | Status |
|---|---|---|---|
| 1 | Hero “Your next step” | card, eyebrow, heading, text, button, stack | Composition. Gap: touch size of the text button |
| 2 | Stats row | stat, formatCount | Existing recipe |
| 3 | Onboarding guide | card, progressBar, steps | Existing recipes. Gap: spoken state |
| 4 | Tables | table, badge, button | Existing recipes. Gap: narrow-screen fold, badge text colour |
| 5 | Rating cell | scoreBar, ratingOf, roundRating | Existing recipe. Fixed track width: layout: 'cell' (3.5.0) |
| 6 | Needs attention | card, itemList, badge, button, stack | Composition. Gap: row layout, inline empty line |
| 7 | One company at a glance | gauge, gaugeRing, gaugeArc, stat | Existing recipes. Gap: stat group beside the gauge |
| 8 | Page layout | container, stack, grid | Composition. Gap: main column plus fixed aside |
Rules that hold in every block
- Classes come from recipe calls. No arbitrary values, no palette utilities, no hand-picked colour. The only inline style is a bar width, which is data.
- No words in the package. Labels, the dash, the spoken state and every sentence are the app's, in the user's language.
- One primary button per view. Blue navigates, black commits: the text button next to it is
variant: 'link'. - Colour follows the rating. Tier, bar width and figure all come from
roundRating(value), never from the raw value. - Authenticated pages sit in the product shell.
appShell()withsideNav()frames everything below; see Side nav shell.
1. Hero “Your next step”
Composition. There is no hero recipe: it is a card() root holding an eyebrow, a heading, one sentence and the actions. The eyebrow names the company when the user has several (“Your next step · Europarque Eventos”).
import { card, eyebrow, heading, text, button, stack } from '@sportingscouter/ui';
type Action = { label: string; href: string };
/* One primary button per view. The text button is optional. */
function NextStepHero({ kicker, title, body, primary, secondary }: {
kicker: string; title: string; body: string; primary: Action; secondary?: Action;
}) {
const c = card();
return (
<article className={c.root()}>
<p className={eyebrow({ spacing: 'none' })}>{kicker}</p>
<h2 className={heading({ level: 3, measure: 'prose' })}>{title}</h2>
<p className={text({ size: 'sm' })}>{body}</p>
<div className={stack({ direction: 'row', gap: 'md' })}>
<a className={button({ variant: 'primary' })} href={primary.href}>{primary.label}</a>
{secondary && (
<a className={button({ variant: 'link' })} href={secondary.href}>{secondary.label}</a>
)}
</div>
</article>
);
}- One primary button. The second action is
variant: 'link': it is a destination, not a commitment. - Heading level. The page title is the
h1; the hero is anh2drawn at theh3step withheading({ level: 3 }). - Gap.
variant: 'link'has no padding and no height floor by design (it lives inside a sentence), so on a touch screen it is smaller than 44px. The template adds the floor in its own CSS. A standalone text-button variant with the floor belongs inbutton().
2. Stats row
Existing recipe. stat() as documented on Stat. The snippet adds the three row states.
import { stat, button, formatCount } from '@sportingscouter/ui';
type Cell = { id: string; label: string; count: number | null };
type Words = { dash: string; failed: string; retry: string };
/* `columns` is the number of stats (1 to 4): the hairlines are a 1px gap, so a spare column is a grey hole.
The state belongs to the row: the data arrives together. A single stat with its own delta is on the Stat page. */
function StatsRow({ cells, state, locale, words, onRetry }: {
cells: Cell[]; state: 'ready' | 'loading' | 'error'; locale: string; words: Words; onRetry: () => void;
}) {
const s = stat();
const columns = cells.length as 1 | 2 | 3 | 4;
return (
<>
<dl className={s.group({ columns })}>
{cells.map((cell) => {
const shown = state === 'ready' ? formatCount(cell.count, locale) : null;
const cellState = state === 'ready' ? (shown == null ? 'empty' : 'ready') : state;
return (
<div key={cell.id} className={s.root()} aria-busy={state === 'loading' ? true : undefined}>
<dt className={s.label()}>{cell.label}</dt>
{state === 'loading' ? (
<dd><span className={s.skeletonValue()} /></dd>
) : (
<dd className={s.value({ state: cellState })}>{shown ?? words.dash}</dd>
)}
</div>
);
})}
</dl>
{/* The row fails as one: every cell shows the dash, and one message and one retry say so. */}
{state === 'error' && (
<>
<p className={s.message()} role="alert">{words.failed}</p>
<div className={s.action()}>
<button type="button" className={button({ variant: 'link', size: 'sm' })} onClick={onRetry}>
{words.retry}
</button>
</div>
</>
)}
</>
);
}columnsis the number of stats, 1 to 4. The hairlines are a 1px gap over the border colour, so a spare column shows as a grey hole.- Loading keeps the real label and swaps the figure for
skeletonValue, witharia-busyon the cell. Empty is the app’s dash instate: 'empty', never0. Error is the same dash in every cell, then onemessageand one retry in theactionslot under the row: the row fails as a unit, so repeating the sentence in each cell would say it three times. - Counts go through
formatCount(n, locale), which returnsnullfor a missing figure; that is what selects the empty state. - The delta, the arrow and the note are on the Stat page. The dashboard rows above have none.
3. Onboarding guide
Existing recipes. A card() holding a progressBar() and a steps() list in the outline, sm style used for checklists. Each step has one of four states.
import { card, progressBar, steps, text, button } from '@sportingscouter/ui';
import { Check, Lock } from 'lucide-react';
type StepState = 'done' | 'current' | 'upcoming' | 'locked';
type Step = {
id: string; state: StepState; title: string; hint?: string;
href?: string; // done and locked steps have none
time?: string; // "≈ 2 min", or the app's word for automatic
reason?: string; // locked only: why, and when it opens
};
type Words = { title: string; progress: string; left: string; count: string; done: string; current: string };
function Guide({ items, words }: { items: Step[]; words: Words }) {
const c = card();
const bar = progressBar();
const g = steps({ markerStyle: 'outline', size: 'sm' });
const done = items.filter((s) => s.state === 'done').length;
const percent = Math.round((done / items.length) * 100);
return (
<article className={c.root()}>
<div className={c.header()}>
<h2 className={c.title()}>{words.title}</h2>
<span className={text({ size: 'sm', tone: 'muted' })}>{words.left}</span>
</div>
<div className={bar.root()} role="progressbar" aria-label={words.progress}
aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
<div className={bar.bar()} style={{ width: `${percent}%` }} />
</div>
<p className={text({ size: 'sm' })}>{words.count}</p>
<ol className={g.root()}>
{items.map((s, i) => (
<li key={s.id} className={g.item()} aria-current={s.state === 'current' ? 'step' : undefined}>
{/* Spoken words are the app's. Upcoming and locked markers are decoration: the title and the reason say it. */}
<span className={g.marker({ state: s.state })} aria-hidden={s.state === 'upcoming' || s.state === 'locked' ? true : undefined}>
{s.state === 'done' && (<><Check aria-hidden="true" /><span className="sr-only">{words.done}</span></>)}
{s.state === 'current' && (<>{i + 1}<span className="sr-only">{words.current}</span></>)}
{s.state === 'upcoming' && i + 1}
{s.state === 'locked' && <Lock aria-hidden="true" />}
</span>
<div className={g.content()}>
{/* A locked step is text, never a link or a button. */}
<div className={g.title({ state: s.state })}>
{s.href
? <a className={button({ variant: 'link', size: 'sm' })} href={s.href}>{s.title}</a>
: s.title}
</div>
{s.hint && <div className={g.body()}>{s.hint}</div>}
{s.state === 'locked' && s.reason && <div className={g.reason()}>{s.reason}</div>}
</div>
{s.time && <span className={g.time()}>{s.time}</span>}
</li>
))}
</ol>
</article>
);
}- done: a check icon and the spoken word “Done”; a plain title. current: the number, the spoken “current step”,
aria-current="step"on the item. upcoming: the number, hidden from assistive technology; the title is a link. locked: a lock icon in the marker, a muted title, and thereasonwritten under it. - A locked step is text. No link, no button, no
href. The reason carries the date or the condition (“Unlocks on race day”), because the lock icon alone says nothing to a screen reader. timeis the optional estimate on the right (“≈ 2 min”). Omit it and the row is unchanged.- The progress is
role="progressbar"witharia-valuenow,aria-valueminandaria-valuemaxand a label; the fill width is the one inline style. - The title link is
button({ variant: 'link', size: 'sm' }): without a class it would inherit the text colour and look like the plain titles. A link must always carry a recipe class. - Gap. The spoken state uses a bare
sr-onlyspan becausesteps()has no slot for it (comparepagination’spageLabel). AstateLabelslot would remove the loose class.
4. Tables
Existing recipes. table() on native markup, badge() for the phase, button({ variant: 'link' }) for the next action. The rating column is block 5.
import { table, badge, button, text, formatCount } from '@sportingscouter/ui';
type Phase = 'upcoming' | 'collecting' | 'closed';
type Edition = {
id: string; name: string; company: string; raceDate: string; phase: Phase;
reviews: number | null; // null until the edition starts collecting: a dash, never 0
rating: number | null; next: { label: string; href: string } | null;
};
type Words = {
region: string; edition: string; company: string; raceDate: string; phase: string;
reviews: string; rating: string; next: string; none: string; phases: Record<Phase, string>;
};
/* A badge says the phase in words; the tone is a hint, never the only signal. */
const PHASE_TONE = { upcoming: 'neutral', collecting: 'success', closed: 'neutral' } as const;
function EditionsTable({ rows, companyCount, locale, words, renderRating }: {
rows: Edition[]; companyCount: number; locale: string; words: Words;
renderRating: (row: Edition) => JSX.Element;
}) {
const t = table();
const showCompany = companyCount > 1; // with one company the column repeats itself on every row
return (
// The wrapper scrolls on a narrow screen, so it is a named region the keyboard can reach.
<div className={t.wrap()} role="region" tabIndex={0} aria-label={words.region}>
<table className={t.root()}>
<thead className={t.head()}>
<tr className={t.headRow()}>
<th scope="col" className={t.headCell()}>{words.edition}</th>
{showCompany && <th scope="col" className={t.headCell()}>{words.company}</th>}
<th scope="col" className={t.headCell()}>{words.raceDate}</th>
<th scope="col" className={t.headCell()}>{words.phase}</th>
<th scope="col" className={t.headCell({ class: t.numeric() })}>{words.reviews}</th>
<th scope="col" className={t.headCell()}>{words.rating}</th>
<th scope="col" className={t.headCell()}>{words.next}</th>
</tr>
</thead>
<tbody className={t.body()}>
{rows.length === 0 && (
<tr><td colSpan={showCompany ? 7 : 6} className={t.empty()}>{words.none}</td></tr>
)}
{rows.map((row) => (
<tr key={row.id} className={t.row()}>
<td className={t.cell()}>{row.name}</td>
{showCompany && <td className={t.cell()}>{row.company}</td>}
<td className={t.cell()}>{row.raceDate}</td>
<td className={t.cell()}>
<span className={badge({ tone: PHASE_TONE[row.phase] })}>{words.phases[row.phase]}</span>
</td>
<td className={t.cell({ class: t.numeric() })}>
{formatCount(row.reviews, locale) ?? '–'}
</td>
<td className={t.cell()}>{renderRating(row)}</td>
<td className={t.cell()}>
{row.next
? <a className={button({ variant: 'link', size: 'sm' })} href={row.next.href}>{row.next.label}</a>
: <span className={text({ size: 'sm', tone: 'muted' })}>–</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}- Scroll region. The
wrapslot scrolls sideways on a narrow screen. Give itrole="region",tabIndex={0}and anaria-labelso a keyboard user can reach and scroll it. - Numeric columns are right-aligned and tabular: pass
class: t.numeric()to the head cell and the cell so the later class wins overtext-left. - The Company column exists only with more than one company. With one company it repeats itself on every row. “Your companies” never has it: its first column is the company.
- A dash, not a zero. An edition that has not started collecting has
reviews: null, andformatCount(null)isnull, so the cell shows the dash. The rating cell shows a dash too below three reviews. The empty table is theemptyslot, a neutral sentence. - “Need you” column (several companies, in “Your companies”): a
badge()holding only the integer, destructive for the most urgent row and neutral for the others. Which row is the most urgent is the app’s rule, not the recipe’s. - Gap: narrow screens. The template folds the company under the edition name below 640px and hides the column. The recipe has no variant for that, so the fold lives in the template’s own CSS.
- Gap: badge text colour.
badge()sets status-coloured text on a tinted fill for the success and destructive tones. Measured on the card surface, that is 4.88:1 and 5.66:1 in the light theme but 2.65:1 and 2.42:1 in the dark theme, below the 4.5:1 AA minimum for small text. The template therefore draws them with ink text (14.52:1 and 15.65:1 in dark). The snippet uses the recipe’s tones because that is what the package ships today; the dark theme is below AA for those two untilbadge()gets an ink variant. Theneutraltone measures 6.72:1 in dark and 4.43:1 in light, just under the minimum.
5. Rating cell
Existing recipe. A scoreBar() in its smallest form: size: 'sm', scale: 'sm', layout: 'inline', with the label left out. Three slots are used: track, fill and value.
import { scoreBar, ratingOf, roundRating, formatRating, text } from '@sportingscouter/ui';
const MIN_REVIEWS = 3; // below this a mean says nothing: show a dash, not an empty bar
function RatingCell({ value, reviews, locale }: { value: number | null; reviews: number | null; locale: string }) {
// The figure on screen is the rounded one, and so are the tier and the width. One number, three uses.
const shown = reviews != null && reviews >= MIN_REVIEWS ? roundRating(value) : null; // 4.3, or null
const figure = formatRating(shown, locale); // "4.3" / "4,3", or null
if (shown == null || figure == null) {
return <span className={text({ size: 'sm', tone: 'muted' })}>–</span>;
}
const bar = scoreBar({ rating: ratingOf(shown), size: 'sm', scale: 'sm', layout: 'inline' });
const width = Math.round((shown / 5) * 100); // 4.3 → 86
return (
<span className={bar.root()}>
<span className={bar.track()} aria-hidden="true">
<span className={bar.fill()} style={{ width: `${width}%` }} />
</span>
<span className={bar.value()}>{figure}</span>
</span>
);
}- One number feeds three things.
shown = roundRating(value)is the figure, the input ofratingOf(shown)(the tier, and so the fill colour) and the width (shown / 5). A raw 3.45 shows “3.5” and takes the colour of 3.5. - The tier cuts are 90, 70, 50 and 30 percent of 5. They live in
ratingOf(); never write them again. - Below three reviews, or with no figure, show a muted dash and no bar: an empty track reads as a score of zero.
- The figure stays in ink (
tintValuestays off). Colour belongs to the data, not the type. - Gap: width (closed in 3.5.0).
layout: 'cell'is an auto-width row with a fixed 96px track, so in an auto-width cell the track no longer stretches. The snippet above still useslayout: 'inline'and the template's own 96px rule; move both tocellwhen the template is next released.
6. Needs attention
Composition. A card() holding an itemList(). A row is a sentence, a badge and a text link. The card header carries “View all N” when the list shows only the first few.
import { card, itemList, badge, button, text, stack } from '@sportingscouter/ui';
type Item = { id: string; company?: string; edition: string; message: string; tag: string; action: { label: string; href: string } };
/* Show the first few; the card says how many there are in total. */
function NeedsAttention({ items, total, title, viewAll, none }: {
items: Item[]; total: number; title: string; viewAll: { label: string; href: string }; none: string;
}) {
const c = card();
const list = itemList();
return (
<article className={c.root()}>
<div className={c.header()}>
<h2 className={c.title()}>{title}</h2>
{total > items.length && (
<a className={button({ variant: 'link', size: 'sm' })} href={viewAll.href}>{viewAll.label}</a>
)}
</div>
{items.length === 0 ? (
<p className={text({ size: 'sm' })}>{none}</p>
) : (
<ul className={list.root()}>
{items.map((item) => (
<li key={item.id} className={list.item()}>
<div className={stack({ direction: 'row', gap: 'md' })}>
<span className={text({ size: 'sm' })}>
{item.company && (<><strong className={text({ weight: 'semibold', tone: 'primary' })}>{item.company}</strong> · </>)}
{item.edition} · {item.message}
</span>
<span className={stack({ direction: 'row', gap: 'md' })}>
<span className={badge({ tone: 'neutral' })}>{item.tag}</span>
<a className={button({ variant: 'link', size: 'sm' })} href={item.action.href}>{item.action.label}</a>
</span>
</div>
</li>
))}
</ul>
)}
</article>
);
}- The row is company (only with several companies), edition, message, then a badge for the kind of action or its duration, then one text link. It is not a button: nothing here commits.
- “View all N” appears only when the total is greater than the number shown, and states the total.
- Empty is one plain sentence in the card (“Nothing needs you right now.”), not a panel.
- Gap: row layout.
stackcentres and wraps but cannot push the badge and the link to the right edge. The template does it in its own CSS. Alayoutvariant onitemList()(text left, actions right, wrapping) would replace it. - Gap: empty line.
emptyState()is a centred panel with section padding, too much for a line inside a card, so the snippet usestext(). An inline variant would let the rule “empty states useemptyState()” hold here too.
7. One company at a glance
Existing recipes. The rating gauge beside three stats. Only for a user with exactly one company: with several, show block 2 alone, because an average across companies compares nothing.
import { card, gauge, gaugeRing, gaugeArc, ratingOf, roundRating, formatRating, stat, formatCount, text, stack } from '@sportingscouter/ui';
type Tier = 'excellent' | 'very-good' | 'good' | 'fair' | 'poor' | 'neutral';
type Words = {
title: string; caption: string; notRated: string;
rating(figure: string, tier: string): string; tier: Record<Tier, string>;
stats: { events: string; editions: string; reviews: string };
};
/* Only for ONE company. With several, show the stats row alone: an average across companies compares nothing. */
function OneCompanyGlance({ average, counts, locale, words }: {
average: number | null; counts: { events: number; editions: number; reviews: number }; locale: string; words: Words;
}) {
const c = card();
const s = stat();
const shown = roundRating(average); // 4.2, or null: not rated yet
const tier = ratingOf(shown ?? 0); // 'very-good', or 'neutral'
const g = gauge({ size: 'lg', rating: tier });
const ring = gaugeRing('lg'); // the track, always
const arc = gaugeArc({ value: shown, max: 5, size: 'lg' }); // null when unrated: no arc
const figure = formatRating(average, locale);
return (
<article className={c.root()}>
<h2 className={c.title()}>{words.title}</h2>
<div className={stack({ direction: 'row', gap: 'lg' })}>
<div className={stack({ gap: 'sm', align: 'center' })}>
<span className={g.root()} role="img"
aria-label={figure ? words.rating(figure, words.tier[tier]) : words.notRated}>
<svg className={g.svg()} width={ring.size} height={ring.size} viewBox={ring.viewBox} aria-hidden="true">
<circle className={g.track()} cx={ring.center} cy={ring.center} r={ring.radius} fill="none" strokeWidth={ring.stroke} />
{arc && (
<circle className={g.arc()} cx={arc.center} cy={arc.center} r={arc.radius}
fill="none" strokeWidth={arc.stroke} strokeLinecap="round" strokeDasharray={arc.dasharray} />
)}
</svg>
<span className={g.figure()} aria-hidden="true">
{figure ?? '–'}
<span className={g.tier()}>{figure ? words.tier[tier] : null}</span>
</span>
</span>
<p className={text({ size: 'sm', tone: 'muted' })}>{words.caption}</p>
</div>
<dl className={s.group({ columns: 3 })}>
{([['events', counts.events], ['editions', counts.editions], ['reviews', counts.reviews]] as const).map(([key, n]) => (
<div key={key} className={s.root()}>
<dt className={s.label()}>{words.stats[key]}</dt>
<dd className={s.value({ state: formatCount(n, locale) == null ? 'empty' : 'ready' })}>{formatCount(n, locale) ?? '–'}</dd>
</div>
))}
</dl>
</div>
</article>
);
}- The geometry comes from
gaugeRing('lg')(the track, always) andgaugeArc()(the arc). The app never does the arithmetic. - Unrated:
gaugeArc()returnsnull, so draw the track and a dash;ratingOf(0)isneutral, so the figure is muted and the label says “not yet rated”. - Colour is
ratingOf(roundRating(average)), passed togauge({ rating }). The tier word shows atlgonly. - One gauge per view. It is a headline figure, not a chart.
- Gap: the stats group takes its content width beside the gauge. The template lets it grow to fill the row in its own CSS.
8. Page layout
Composition. A container() holding a stack(): title and lead, then one column, or a main column and a narrower aside. It is the content of the product shell’s main region; do not build another dashboard grid.
import { container, grid, stack, heading, text } from '@sportingscouter/ui';
/* Sits inside the product shell (appShell() + sideNav()): this is the content of the main region. */
function DashboardHome({ title, lead, main, aside, asideLabel }: {
title: string; lead: string; main: JSX.Element; aside?: JSX.Element; asideLabel?: string;
}) {
return (
<div className={container({ width: 'app' })}>
<div className={stack({ gap: 'lg' })}>
<header className={stack({ gap: 'sm' })}>
<h1 className={heading({ level: 2 })}>{title}</h1>
<p className={text({ size: 'lead' })}>{lead}</p>
</header>
{aside ? (
<div className={grid({ cols: 2, gap: 'md' })}>
<div className={stack({ gap: 'lg' })}>{main}</div>
<aside aria-label={asideLabel} className={stack({ gap: 'lg' })}>{aside}</aside>
</div>
) : (
<div className={stack({ gap: 'lg' })}>{main}</div>
)}
</div>
</div>
);
}- Single column for the views that have no aside (several companies, one company). Main plus aside only for the new-organiser and before-the-race views.
- The aside is a landmark (
<aside>with a label), after the main column in the source, so the reading order is the visual order. - Gap:
grid({ cols: 2 })makes two equal columns frommd. The template uses a fluid column plus a 20rem aside fromlg. Acols: 'aside'variant ofgrid()would do it.
What the recipes still lack
Everything the template draws with its own CSS, in one place. None of it blocks an implementation: each item has a working fallback in the snippet. Each is a minor release of the design system, to be decided there, not in an app.
| Block | Missing | Proposed | Fallback today |
|---|---|---|---|
| 1 Hero | Touch floor on a standalone text button | button variant text, or a touch size for link | variant: 'link' (small on touch) |
| 3 Guide | A slot for the spoken state | steps slot stateLabel: 'sr-only' | A bare sr-only span |
| 4 Tables | Narrow-screen fold of a column; ink text on tinted badges (success and destructive are 2.65:1 and 2.42:1 in dark) | table slots for the folded cell; badge ink variant | Show every column; the recipe’s tones, below AA in dark |
| 5 Rating cell | A fixed-width track | scoreBar layout: 'cell' | The inline layout (track fills the cell) |
| 6 Queue | Text left, actions right; inline empty line | itemList layout: 'split'; emptyState inline variant | stack row; text() sentence |
| 7 At a glance | Stat group that fills the row | Same layout family as block 8 | Content-width group |
| 8 Layout | Fluid main column plus a fixed aside | grid cols: 'aside' | grid({ cols: 2 }) (equal columns) |
theme.css.