Building a page
A page made of several elements: one run of a made-up CI pipeline, with a theme
switch, stat tiles, a sortable table of jobs, and a layered graph of the steps
beside a detail panel of the page’s own. The whole example is
docs/examples/ci-pipeline.ts; it uses only the package’s public entry points,
and mount(host) builds the page in any element.
What the page holds
Section titled “What the page holds”The elements keep no state of their own that the page needs: each one draws
what its properties give it and sends a cae-* event for what the reader did.
The page holds the data, the theme, the selection and the order of the table,
and sets them back on the elements.
| The reader | The element sends | The page sets |
|---|---|---|
| picks a theme | cae-change from <cae-segmented> |
data-ca-theme on the page’s box, and the control’s value |
| sorts a column | cae-sort from <cae-data-table> |
the table’s sort, and its rows in that order (sortRows) |
| opens a row | cae-activate from <cae-data-table> |
the graph’s selected, and the panel |
| selects a mark, a node or the loop | cae-select from <cae-layered-graph> |
the graph’s selected, and the panel |
Registering and theming
Section titled “Registering and theming”Each define entry registers its elements; adoptThemes(document) adds the
themes’ rules once, and data-ca-theme on any ancestor then picks a theme
(Theming).
import '@contraagent/elements/segmented/define'; // <cae-segmented>import '@contraagent/elements/tiles/define'; // <cae-stat-tiles>import '@contraagent/elements/data-table/define'; // <cae-data-table>import '@contraagent/elements/graph/define'; // <cae-layered-graph>import { adoptThemes } from '@contraagent/elements/themes';import { fmtDur, pill, type StateStyles } from '@contraagent/elements';import { sortRows, type CaeDataTable, type DataColumn } from '@contraagent/elements/data-table';import type { GraphInput, GraphMark, GraphNodeInput } from '@contraagent/elements/graph';import type { StatTile } from '@contraagent/elements/tiles';Widths
Section titled “Widths”Each element is as wide as its container and lays itself out by its own width,
not the window’s: its host is a CSS container (container-type: inline-size),
so the same element fits a sidebar, a column or a whole page. Its width does
not come from its content: in a flex row, give it a width or flex: 1. A table
wider than its element scrolls inside its own box. This page’s two columns
become one below 760 px of the page’s own width (@container in its stylesheet).
The data
Section titled “The data”A step is a node of the graph and a job is one run of it, or of one of its
shards. The steps e2e and triage repeat in a retry loop.
// ---- The data: a step is a node, a job is one run of a step (or of one shard of it).
type State = 'done' | 'failed' | 'running' | 'queued';
interface Job { id: string; step: string; /** The shard ('1' to '3'), or null for a step that runs once. */ shard: string | null; /** The pass of the retry loop it ran in (1 outside the loop). */ pass: number; state: State; seconds: number; runner: string;}
const steps = [ { id: 'lint', label: 'lint', loop: false }, { id: 'unit', label: 'unit tests', loop: false }, { id: 'build', label: 'build', loop: false }, { id: 'e2e', label: 'e2e tests', loop: true }, { id: 'triage', label: 'triage failures', loop: true }, { id: 'deploy', label: 'deploy preview', loop: false },];const shards = ['1', '2', '3'];
const job = (step: string, shard: string | null, pass: number, state: State, seconds: number, runner: string): Job => ({ id: `${step}${shard ? `/${shard}` : ''}#${pass}`, step, shard, pass, state, seconds, runner,});
const jobs: Job[] = [ job('lint', null, 1, 'done', 41, 'small-1'), job('unit', '1', 1, 'done', 188, 'large-1'), job('unit', '2', 1, 'done', 203, 'large-2'), job('unit', '3', 1, 'done', 176, 'large-3'), job('build', null, 1, 'done', 315, 'large-1'), job('e2e', '1', 1, 'done', 402, 'large-2'), job('e2e', '2', 1, 'failed', 377, 'large-3'), job('e2e', '3', 1, 'done', 395, 'large-1'), job('triage', null, 1, 'done', 12, 'small-1'), job('e2e', '2', 2, 'done', 381, 'large-2'), job('triage', null, 2, 'done', 9, 'small-1'), job('deploy', null, 1, 'running', 74, 'deploy-1'),];States
Section titled “States”A state is the page’s own word: StateStyles gives each one a tone, a shape
and a rank, and the legend follows its key order.
// ---- What each state looks like: a word, a tone, a shape (the legend follows this order).
const states: StateStyles = { done: { label: 'done', tone: 'done', shape: 'round', rank: 0 }, queued: { label: 'queued', tone: 'muted', shape: 'ring', rank: 1 }, running: { label: 'running', tone: 'running', shape: 'round', rank: 2 }, failed: { label: 'failed', tone: 'failed', shape: 'square', rank: 3 },};const toneOf = (s: State) => states[s]!.tone;const jobName = (j: Job) => `${steps.find((s) => s.id === j.step)!.label}${j.shard ? ` shard ${j.shard}` : ''}`;The graph
Section titled “The graph”The shards are the graph’s series, so shard 2 is at the same place in every
node that has shards; a step that runs once has one mark in loose. Each mark
shows the last pass of its job, and the number of passes as its badge. The
loop is a cluster that cycles, closed by an edge with ranks: false. A
node’s counts and progress follow from its marks. The page puts nothing in
the graph’s inspector slot, so the graph draws no card beside the selection:
the panel beside the graph describes it.
// ---- The graph: a mark per shard (or one loose mark), the last pass's state, the passes as a badge.
/** The id of a mark: a step's shard, or the step's single job. */const markId = (j: Job) => `${j.step}/${j.shard ?? '-'}`;
function mark(runs: Job[]): GraphMark { const last = runs[runs.length - 1]!; const passes = runs.length > 1 ? `${runs.length} passes` : '1 pass'; return { id: markId(last), state: last.state, label: `${jobName(last)} | ${last.state} | ${passes}`, tip: jobName(last), badge: runs.length > 1 ? String(runs.length) : undefined };}
function node(step: (typeof steps)[number]): GraphNodeInput { const own = jobs.filter((j) => j.step === step.id); const runsOf = (shard: string | null) => own.filter((j) => j.shard === shard); const sharded = own.some((j) => j.shard); const marks = sharded ? shards.map((s) => (runsOf(s).length ? mark(runsOf(s)) : null)) : []; const loose = sharded ? [] : [mark(runsOf(null))]; // `counts` and `progress` ("2/3") default to what the marks say. // A job that failed in an earlier pass is not in the marks: the line under them names it. const failed = own.filter((j) => j.state === 'failed').length; return { id: step.id, label: step.label, cluster: step.loop ? 'retry' : null, marks, loose, notes: failed ? [{ text: `${failed} failed ${failed === 1 ? 'job' : 'jobs'}`, tone: 'failed' }] : [], };}
const graph: GraphInput = { series: shards, nodes: steps.map(node), clusters: [{ id: 'retry', parent: null, label: 'retry loop', note: 'up to 3 passes', cycle: true }], edges: [ { from: 'lint', to: 'build', style: 'solid', weight: 1, ranks: true }, { from: 'unit', to: 'build', style: 'solid', weight: 3, ranks: true }, { from: 'build', to: 'e2e', style: 'solid', weight: 3, ranks: true }, { from: 'e2e', to: 'triage', style: 'solid', weight: 3, ranks: true }, // The next pass: from triage back to the tests. It does not set the layers. { from: 'triage', to: 'e2e', style: 'solid', weight: 1, ranks: false }, { from: 'triage', to: 'deploy', style: 'solid', weight: 1, ranks: true }, ], stubs: [{ id: 'push', label: 'push to main', to: 'lint', style: 'solid', weight: 1 }],};The table and the tiles
Section titled “The table and the tiles”A column with compare sorts by it; a column with sortable and no compare
sorts by its cell, a string or a number.
// ---- The table: a row per job; every column sorts.
const columns: DataColumn<Job>[] = [ { id: 'job', header: 'Job', rowHeader: true, cell: jobName, sortable: true }, { id: 'pass', header: 'Pass', align: 'end', width: '72px', cell: (j) => j.pass, compare: (a, b) => a.pass - b.pass }, { id: 'state', header: 'State', width: '120px', cell: (j) => pill({ text: j.state, tone: toneOf(j.state) }), compare: (a, b) => states[a.state]!.rank - states[b.state]!.rank }, { id: 'time', header: 'Time', align: 'end', width: '96px', cell: (j) => fmtDur(j.seconds), compare: (a, b) => a.seconds - b.seconds }, { id: 'runner', header: 'Runner', width: '120px', cell: (j) => j.runner, sortable: true },];
// ---- The tiles.
const failed = jobs.filter((j) => j.state === 'failed');const passes = Math.max(...jobs.map((j) => j.pass));const count = (s: State) => jobs.filter((j) => j.state === s).length;const tiles: StatTile[] = [ { id: 'jobs', label: 'Jobs', value: jobs.length, sub: [[`${count('done')} done`, `${count('running')} running`]] }, { id: 'failed', label: 'Failed jobs', value: failed.length, tone: failed.length ? 'failed' : undefined, sub: [failed.length ? failed.map((j) => `${jobName(j)}, pass ${j.pass}`) : ['All jobs passed']] }, { id: 'passes', label: 'Passes of the retry loop', value: passes, sub: [['Up to 3']] }, { id: 'time', label: 'Job time', value: fmtDur(jobs.reduce((t, j) => t + j.seconds, 0)), tip: 'Sum over all jobs' },];The page’s styles
Section titled “The page’s styles”The page’s own text and boxes take the theme’s colours (--ca-*), so they
change with the switch.
// ---- The page's own styles. Its colours are the theme's, so the page changes with the switch.
const css = `.ci-page { background: var(--ca-bg); color: var(--ca-text); font: 14px/1.45 system-ui, sans-serif; padding: 16px; display: grid; gap: 16px; container-type: inline-size;}.ci-page header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }.ci-page h2 { font-size: 18px; margin: 0; flex: 1; color: var(--ca-text); }.ci-page h3 { font-size: 13px; font-weight: 600; margin: 0 0 8px; color: var(--ca-text-secondary); }.ci-page .body { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; }.ci-page .main { display: grid; gap: 16px; min-width: 0; }.ci-page aside { background: var(--ca-card); border: 1px solid var(--ca-line); border-radius: 8px; padding: 12px; position: sticky; top: 0; }.ci-page dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin: 0; }.ci-page dt { color: var(--ca-text-muted); }.ci-page dd { margin: 0; }@container (max-width: 760px) { .ci-page .body { grid-template-columns: minmax(0, 1fr); } }`;The page
Section titled “The page”// ---- The page: build it, then answer the events.
export function mount(host: HTMLElement): void { adoptThemes(document); host.dataset['caTheme'] = 'contraagent-dark';
const page = document.createElement('div'); page.className = 'ci-page'; page.innerHTML = `<style>${css}</style> <header><h2>web-app | pipeline 482 | main</h2></header> <div class="body"> <div class="main"> <section><h3>Pipeline</h3></section> <section></section> </div> <aside aria-live="polite"><h3>Selected</h3><div class="detail">No selection</div></aside> </div>`; const [graphBox, tableBox] = page.querySelectorAll('section'); const detail = page.querySelector<HTMLElement>('.detail')!;
// The theme: `data-ca-theme` on the box that holds the page. const theme = document.createElement('cae-segmented'); theme.label = 'Theme'; theme.options = [ { value: 'contraagent-dark', label: 'Dark' }, { value: 'contraagent-light', label: 'Light' }, { value: 'high-contrast', label: 'High contrast' }, ]; theme.value = 'contraagent-dark'; theme.addEventListener('cae-change', (e) => { theme.value = e.detail.value; host.dataset['caTheme'] = e.detail.value; }); page.querySelector('header')!.append(theme);
const stats = document.createElement('cae-stat-tiles'); stats.columns = 4; stats.tiles = tiles; page.querySelector('header')!.after(stats);
const table = document.createElement('cae-data-table') as CaeDataTable<Job>; table.caption = 'Jobs of pipeline 482'; table.columns = columns; table.rowKey = (j) => j.id; table.rows = jobs; table.addEventListener('cae-sort', (e) => { table.sort = e.detail; table.rows = sortRows(jobs, columns, e.detail); }); tableBox!.append(table);
const view = document.createElement('cae-layered-graph'); view.key = 'pipeline-482'; // a new key is a new graph: the frame shows all of it again view.graph = graph; view.states = states; view.strings = { cycle: 'next pass', badge: 'number of passes' }; graphBox!.append(view);
/** The selection: the graph shows it, the panel describes it. */ const select = (id: string | null) => { view.selected = id; detail.replaceChildren(describe(id)); }; view.addEventListener('cae-select', (e) => select(e.detail.id)); // A row of the table selects its job's mark in the graph. table.addEventListener('cae-activate', (e) => select(markId(jobs.find((j) => j.id === e.detail.id)!)));
host.replaceChildren(page);}
/** The panel's content for a mark, a node or the loop. */function describe(id: string | null): Node { const runs = jobs.filter((j) => markId(j) === id); const step = steps.find((s) => s.id === id); let title = 'No selection'; let rows: [string, string][] = []; if (runs.length) { const last = runs[runs.length - 1]!; title = jobName(last); rows = [['State', last.state], ['Passes', String(runs.length)], ...runs.map((j): [string, string] => [`Pass ${j.pass}`, `${j.state} | ${fmtDur(j.seconds)} | ${j.runner}`])]; } else if (step) { const own = jobs.filter((j) => j.step === step.id); title = step.label; rows = [['Jobs', String(own.length)], ['Failed', String(own.filter((j) => j.state === 'failed').length)], ['Time', fmtDur(own.reduce((t, j) => t + j.seconds, 0))]]; } else if (id === 'retry') { title = 'retry loop'; rows = [['Passes', `${passes} of up to 3`], ['Rerun', jobs.filter((j) => j.pass > 1 && j.shard).map(jobName).join(', ')]]; } const box = document.createElement('div'); const h = document.createElement('strong'); h.textContent = title; const dl = document.createElement('dl'); for (const [k, v] of rows) { const dt = document.createElement('dt'); const dd = document.createElement('dd'); dt.textContent = k; dd.textContent = v; dl.append(dt, dd); } box.append(h, dl); return box;}