Templates

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

#BlockBuilt fromStatus
1Hero “Your next step”card, eyebrow, heading, text, button, stackComposition. Gap: touch size of the text button
2Stats rowstat, formatCountExisting recipe
3Onboarding guidecard, progressBar, stepsExisting recipes. Gap: spoken state
4Tablestable, badge, buttonExisting recipes. Gap: narrow-screen fold, badge text colour
5Rating cellscoreBar, ratingOf, roundRatingExisting recipe. Fixed track width: layout: 'cell' (3.5.0)
6Needs attentioncard, itemList, badge, button, stackComposition. Gap: row layout, inline empty line
7One company at a glancegauge, gaugeRing, gaugeArc, statExisting recipes. Gap: stat group beside the gauge
8Page layoutcontainer, stack, gridComposition. 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() with sideNav() 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 an h2 drawn at the h3 step with heading({ 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 in button().

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>
        </>
      )}
    </>
  );
}
  • columns is 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, with aria-busy on the cell. Empty is the app’s dash in state: 'empty', never 0. Error is the same dash in every cell, then one message and one retry in the action slot 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 returns null for 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 the reason written 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.
  • time is the optional estimate on the right (“≈ 2 min”). Omit it and the row is unchanged.
  • The progress is role="progressbar" with aria-valuenow, aria-valuemin and aria-valuemax and 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-only span because steps() has no slot for it (compare pagination’s pageLabel). A stateLabel slot 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 wrap slot scrolls sideways on a narrow screen. Give it role="region", tabIndex={0} and an aria-label so 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 over text-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, and formatCount(null) is null, so the cell shows the dash. The rating cell shows a dash too below three reviews. The empty table is the empty slot, 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 until badge() gets an ink variant. The neutral tone 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 of ratingOf(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 (tintValue stays 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 uses layout: 'inline' and the template's own 96px rule; move both to cell when 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. stack centres and wraps but cannot push the badge and the link to the right edge. The template does it in its own CSS. A layout variant on itemList() (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 uses text(). An inline variant would let the rule “empty states use emptyState()” 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) and gaugeArc() (the arc). The app never does the arithmetic.
  • Unrated: gaugeArc() returns null, so draw the track and a dash; ratingOf(0) is neutral, so the figure is muted and the label says “not yet rated”.
  • Colour is ratingOf(roundRating(average)), passed to gauge({ rating }). The tier word shows at lg only.
  • 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 from md. The template uses a fluid column plus a 20rem aside from lg. A cols: 'aside' variant of grid() 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.

BlockMissingProposedFallback today
1 HeroTouch floor on a standalone text buttonbutton variant text, or a touch size for linkvariant: 'link' (small on touch)
3 GuideA slot for the spoken statesteps slot stateLabel: 'sr-only'A bare sr-only span
4 TablesNarrow-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 variantShow every column; the recipe’s tones, below AA in dark
5 Rating cellA fixed-width trackscoreBar layout: 'cell'The inline layout (track fills the cell)
6 QueueText left, actions right; inline empty lineitemList layout: 'split'; emptyState inline variantstack row; text() sentence
7 At a glanceStat group that fills the rowSame layout family as block 8Content-width group
8 LayoutFluid main column plus a fixed asidegrid cols: 'aside'grid({ cols: 2 }) (equal columns)
Sporting Scouter Design System
Every token on this site is generated from theme.css.