feat(blocks): extract six full-guide block components

PhasePanel, FleetDiagram, HandoffTable, WorktreeMap, RouteTable,
SkillPackage as static shells. Each takes typed props and renders
server-side markup with the data-* hooks verified by scripts/verify.mjs
(data-phase, data-tree, data-worker, data-route, data-skill-file). No
client:* directives; interactive islands wire up in task 15.

Tokens only. No raw hex or px font sizes (check-tokens passes). The
parent-background seam colour for the gap:1px grid trick in
FleetDiagram is a documented token gap; see component header comment
and the task final report.
This commit is contained in:
Marcos Paulo
2026-09-05 07:09:38 +00:00
parent 73ceae2aa8
commit 233cc5d6e6
6 changed files with 745 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
---
// FleetDiagram — orchestrator card, an arrow, and a 1-up of worker cards.
//
// Static shell: the captain renders literally, the workers render as toggle
// buttons with the `data-worker` hook asserted by `scripts/verify.mjs`. The
// `initial` worker is marked active and pressed.
//
// The source uses `gap:1px` over a coloured parent to draw hairlines between
// workers; here the parent uses `var(--ink)` and the cards paint over it, so
// the seam reads as the same tone — see the final report for the token gap
// (source uses an off-token seam colour for the divider).
interface Worker {
/** Used as the `data-worker` hook and the key in the source. */
id: string;
/** Short label rendered uppercase, e.g. "UI". */
label: string;
/** Body copy describing the worker's remit. */
strong: string;
/** Path label, e.g. "agent/ui". */
code: string;
}
interface Props {
orchestrator: {
eyebrow: string;
/** May contain inline `<br>`; rendered with `set:html`. */
title: string;
code: string;
};
workers: Worker[];
initial?: string;
}
const { orchestrator, workers, initial = workers[0]?.id } = Astro.props;
---
<div class="fleet-grid">
<article class="captain">
<span class="captain-eyebrow">{orchestrator.eyebrow}</span>
<h2 set:html={orchestrator.title} />
<code>{orchestrator.code}</code>
</article>
<div class="arrow" aria-hidden="true"></div>
<div class="workers" role="group" aria-label="Worker agents">
{
workers.map((worker) => (
<button
class:list={['worker-card', { active: worker.id === initial }]}
data-worker={worker.id}
aria-pressed={worker.id === initial}
>
<span>{worker.label}</span>
<strong>{worker.strong}</strong>
<code>{worker.code}</code>
</button>
))
}
</div>
</div>
<style>
.fleet-grid {
display: grid;
grid-template-columns: 0.8fr 50px 1.5fr;
gap: 24px;
margin-top: 30px;
color: var(--paper);
background: var(--deep);
}
.captain {
display: grid;
align-content: center;
gap: 16px;
padding: 32px;
}
.captain-eyebrow {
color: var(--gold);
font:
500 var(--step-0) 'DM Mono',
monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.captain h2 {
margin: 0;
font-size: var(--step-5);
line-height: 0.98;
letter-spacing: -0.06em;
}
.captain code {
color: var(--gold);
font:
500 var(--step-0) 'DM Mono',
monospace;
}
.arrow {
display: grid;
place-items: center;
color: var(--gold);
font-size: var(--step-5);
}
.workers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
/* House style: gap:1px over a coloured parent background fakes borders.
Source uses an off-token seam colour for the divider; reported as a
token-layer gap. */
background: var(--ink);
}
.worker-card {
display: grid;
align-content: space-between;
gap: 20px;
min-height: 180px;
padding: 22px;
color: var(--paper);
background: var(--ink);
border: 0;
text-align: left;
cursor: pointer;
}
.worker-card span {
color: var(--muted);
font:
500 var(--step-0) 'DM Mono',
monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.worker-card strong {
font-size: var(--step-1);
line-height: 1.2;
}
.worker-card code {
color: var(--gold);
font:
500 var(--step-0) 'DM Mono',
monospace;
}
.worker-card.active {
box-shadow: inset 4px 0 0 var(--gold);
}
@media (max-width: 800px) {
.fleet-grid {
grid-template-columns: 1fr;
}
.arrow {
transform: rotate(90deg);
min-height: 35px;
}
.workers {
grid-template-columns: 1fr;
}
}
</style>
+90
View File
@@ -0,0 +1,90 @@
---
// HandoffTable — the four-row "what crosses contexts" table.
//
// Static shell: the row data is passed in via `rows` so this component has
// no opinion on what each handoff package contains. The table structure is
// the load-bearing part of the design (dark header, blue row labels,
// muted body) and lives here so the next page that needs it gets the same
// beat for free.
interface Row {
/** The package name, rendered as a `<th>` (column 1). */
package: string;
/** What the package contains (column 2). */
contains: string;
/** Why this matters (column 3). */
why: string;
}
interface Props {
/** Column headers in render order. */
columns: [string, string, string];
rows: Row[];
}
const { columns, rows } = Astro.props;
---
<table class="handoff-table">
<thead>
<tr>
<th>{columns[0]}</th>
<th>{columns[1]}</th>
<th>{columns[2]}</th>
</tr>
</thead>
<tbody>
{
rows.map((row) => (
<tr>
<th scope="row">{row.package}</th>
<td>{row.contains}</td>
<td>{row.why}</td>
</tr>
))
}
</tbody>
</table>
<style>
.handoff-table {
width: 100%;
margin-top: 30px;
border-collapse: collapse;
text-align: left;
}
.handoff-table th,
.handoff-table td {
padding: 17px 14px;
border-bottom: 1px solid var(--line);
}
.handoff-table thead {
color: var(--paper);
background: var(--ink);
font:
500 var(--step-0) 'DM Mono',
monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.handoff-table tbody th {
color: var(--blue);
font:
600 14px 'DM Mono',
monospace;
}
.handoff-table td {
color: var(--muted);
font-size: var(--step-1);
}
@media (max-width: 800px) {
.handoff-table {
display: block;
overflow-x: auto;
}
.handoff-table {
min-width: 650px;
}
}
</style>
+128
View File
@@ -0,0 +1,128 @@
---
// PhasePanel — the three-step "Click a phase / see the handoff" block.
//
// Static shell: the tab buttons and the panel markup are server-rendered.
// The `initial` phase's content is shown by default; the other tabs still
// carry the `data-phase` hook so the interactive island (task 15) can swap
// the panel on click.
//
// Every `data-phase` value is asserted by `scripts/verify.mjs`.
export type PhaseId = 'plan' | 'build' | 'review';
interface Phase {
id: PhaseId;
/** Two-letter label rendered in the tab, e.g. "PLAN". */
label: string;
/** Numeric prefix, e.g. "01". */
number: string;
title: string;
body: string;
/** Headline + small caption shown above the panel title. */
meta: { deliverable: string; gate: string };
/** Code-line evidence shown at the bottom of the panel. */
evidence: string;
}
interface Props {
phases: Phase[];
/** Initial active phase. Defaults to the first entry. */
initial?: PhaseId;
}
const { phases, initial = phases[0]?.id ?? 'plan' } = Astro.props;
const active = phases.find((phase) => phase.id === initial) ?? phases[0];
---
<div class="phase-tabs" role="tablist" aria-label="Workflow phases">
{
phases.map((phase) => (
<button
class:list={['phase-tab', { active: phase.id === initial }]}
data-phase={phase.id}
role="tab"
aria-selected={phase.id === initial}
>
<b>{phase.number}</b> {phase.label}
</button>
))
}
</div>
<article class="phase-panel" id="phase-panel" aria-live="polite">
<div class="phase-meta">
<span>{active.meta.deliverable}</span>
<small>{active.meta.gate}</small>
</div>
<h3>{active.title}</h3>
<p>{active.body}</p>
<code>{active.evidence}</code>
</article>
<style>
.phase-tabs {
display: grid;
gap: 8px;
}
.phase-tab {
display: grid;
grid-template-columns: 42px 1fr;
gap: 10px;
padding: 15px;
border: 1px solid var(--line);
color: var(--ink);
background: transparent;
font:
500 var(--step-0) 'DM Mono',
monospace;
letter-spacing: 0.07em;
text-align: left;
cursor: pointer;
}
.phase-tab b {
color: var(--accent);
}
.phase-tab.active {
color: var(--paper);
border-color: var(--ink);
background: var(--ink);
}
.phase-panel {
min-height: 260px;
padding: 30px;
color: var(--paper);
background: var(--accent);
}
.phase-meta {
display: flex;
justify-content: space-between;
gap: 15px;
color: var(--gold);
font:
500 var(--step-0) 'DM Mono',
monospace;
}
.phase-meta small {
color: var(--paper);
opacity: 0.7;
}
.phase-panel h3 {
margin: 44px 0 12px;
font-size: var(--step-5);
line-height: 1.05;
}
.phase-panel p {
line-height: 1.6;
opacity: 0.82;
}
.phase-panel code {
display: block;
margin-top: 26px;
color: var(--gold);
font:
500 var(--step-0) 'DM Mono',
monospace;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
---
// RouteTable — the model-routing matrix. Four buttons (one per job profile),
// each carrying the `data-route` hook asserted by `scripts/verify.mjs`.
//
// Static shell: the initial route is marked active and pressed. The shell
// renders the column header + the four rows; task 15 will hydrate the
// "route detail" panel to the right.
interface Route {
id: 'plan' | 'build' | 'explore' | 'review' | string;
/** Strong label, e.g. "Plan". */
strong: string;
/** Profile descriptor, e.g. "strong / broad". */
profile: string;
/** Prompt shape copy. */
prompt: string;
}
interface Props {
routes: Route[];
initial?: string;
}
const { routes, initial = routes[0]?.id ?? 'plan' } = Astro.props;
---
<div class="route-table">
<div class="head">
<span>Work</span>
<span>Profile</span>
<span>Prompt shape</span>
</div>
{
routes.map((route) => (
<button
class:list={['route-row', { active: route.id === initial }]}
data-route={route.id}
aria-pressed={route.id === initial}
>
<strong>{route.strong}</strong>
<b>{route.profile}</b>
<small>{route.prompt}</small>
</button>
))
}
</div>
<style>
.route-table {
border-top: 1px solid var(--line);
border-left: 1px solid var(--line);
}
.head,
.route-row {
display: grid;
grid-template-columns: 0.8fr 0.9fr 1.4fr;
}
.head {
color: var(--paper);
background: var(--ink);
font:
500 var(--step-0) 'DM Mono',
monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.head > *,
.route-row > * {
padding: 15px;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.route-row {
width: 100%;
padding: 0;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.route-row strong {
font-size: var(--step-1);
}
.route-row b {
color: var(--blue);
letter-spacing: 0;
text-transform: none;
}
.route-row small {
color: var(--muted);
font-size: var(--step-1);
}
.route-row.active {
background: var(--line);
}
.route-row.active strong {
box-shadow: inset 4px 0 0 var(--gold);
}
.route-row:focus-visible {
outline: 3px solid var(--gold);
outline-offset: -3px;
}
@media (max-width: 800px) {
.route-table {
overflow-x: auto;
}
.head,
.route-row {
min-width: 620px;
}
}
</style>
+90
View File
@@ -0,0 +1,90 @@
---
// SkillPackage — the four-file skill-package picker (SKILL.md, references/,
// scripts/, assets/). Buttons carry the `data-skill-file` hook asserted by
// `scripts/verify.mjs`.
//
// Static shell: the initial file is marked active and selected. The block is
// paired with a `<article>` detail panel by the parent page; this component
// renders only the picker.
interface PackageFile {
id: 'skill' | 'references' | 'scripts' | 'assets' | string;
/** Path rendered inside `<code>`, e.g. "SKILL.md". */
path: string;
/** Helper copy under the path. */
small: string;
}
interface Props {
files: PackageFile[];
initial?: string;
}
const { files, initial = files[0]?.id ?? 'skill' } = Astro.props;
---
<div class="skill-package" role="tree" aria-label="Skill package files">
<span class="skill-package-label">SKILL PACKAGE</span>
{
files.map((file) => (
<button
class:list={['skill-package-row', { active: file.id === initial }]}
data-skill-file={file.id}
role="treeitem"
aria-selected={file.id === initial}
>
<code>{file.path}</code>
<small>{file.small}</small>
</button>
))
}
</div>
<style>
.skill-package {
display: grid;
align-content: start;
color: var(--paper);
background: var(--blue);
}
.skill-package-label {
padding: 20px;
color: var(--gold);
border-bottom: 1px solid var(--line);
font:
500 var(--step-0) 'DM Mono',
monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.skill-package-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
padding: 17px 20px;
border: 0;
border-bottom: 1px solid var(--line);
color: var(--paper);
background: transparent;
text-align: left;
cursor: pointer;
}
.skill-package-row code {
font:
500 12px 'DM Mono',
monospace;
}
.skill-package-row small {
opacity: 0.7;
}
.skill-package-row.active {
/* House style: left bar via inset box-shadow, not a border. */
box-shadow: inset 4px 0 0 var(--gold);
}
.skill-package-row:focus-visible {
outline: 3px solid var(--gold);
outline-offset: -3px;
}
</style>
+160
View File
@@ -0,0 +1,160 @@
---
// WorktreeMap — the repository-topology diagram with the SVG trunk-and-branches
// behind four `tree-node` buttons.
//
// Static shell: the SVG paths render server-side; the nodes are buttons with
// the `data-tree` hook asserted by `scripts/verify.mjs`. The `root` node and
// the `initial` branch are marked selected.
interface Branch {
/** The `data-tree` hook, e.g. "ui", "tests", "docs". */
id: string;
/** Uppercase label, e.g. "UI AGENT". */
label: string;
/** Strong line, e.g. "agent/ui". */
strong: string;
/** Status small, e.g. "3 files · working". */
small: string;
/** CSS modifier so each branch picks up its tone. */
tone: 'ui' | 'tests' | 'docs' | string;
}
interface Props {
branches: Branch[];
initial?: string;
}
const { branches, initial = 'main' } = Astro.props;
---
<div class="tree-stage" role="tree" aria-label="Repository worktree topology">
<svg viewBox="0 0 760 330" preserveAspectRatio="none" aria-hidden="true">
<path class="tree-edge trunk" d="M380 48 V118"></path>
<path class="tree-edge" d="M380 118 C380 170 110 150 110 224"></path>
<path class="tree-edge" d="M380 118 V224"></path>
<path class="tree-edge" d="M380 118 C380 170 650 150 650 224"></path>
</svg>
<button
class:list={['tree-node', 'root', { active: initial === 'main' }]}
data-tree="main"
role="treeitem"
aria-selected={initial === 'main'}
><span>ROOT</span><strong>main</strong><small> clean</small></button
>
{
branches.map((branch) => (
<button
class:list={['tree-node', 'branch', branch.tone, { active: branch.id === initial }]}
data-tree={branch.id}
role="treeitem"
aria-selected={branch.id === initial}
>
<>
<span>{branch.label}</span>
<strong>{branch.strong}</strong>
<small>{branch.small}</small>
</>
</button>
))
}
</div>
<style>
.tree-stage {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
padding-top: 90px;
}
/* SVG trunk-and-branches behind the buttons. */
.tree-stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.tree-edge {
fill: none;
stroke: var(--gold);
stroke-width: 2;
}
/* Root sits at the top centre, spanning both columns. */
.tree-node.root {
grid-column: 1 / -1;
justify-self: center;
width: 180px;
}
.tree-node {
display: grid;
align-content: start;
gap: 12px;
min-height: 135px;
padding: 20px;
color: var(--paper);
background: var(--ink);
border: 0;
text-align: left;
cursor: pointer;
}
.tree-node.root {
background: var(--gold);
color: var(--ink);
}
.tree-node span {
color: var(--accent);
font:
500 var(--step-0) 'DM Mono',
monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.tree-node strong {
font:
600 var(--step-1) 'DM Mono',
monospace;
}
.tree-node small {
margin-top: 0;
color: var(--muted);
font-size: var(--step-0);
}
.tree-node.root small {
color: var(--muted);
}
.tree-node.active {
box-shadow: inset 4px 0 0 var(--gold);
}
.tree-node:focus-visible {
outline: 3px solid var(--gold);
outline-offset: -3px;
}
/* Branch tone variants — left/right placement stays on the grid columns. */
.tree-node.branch.ui {
grid-column: 1;
}
.tree-node.branch.tests {
grid-column: 1 / -1;
justify-self: center;
}
.tree-node.branch.docs {
grid-column: 2;
}
@media (max-width: 560px) {
.tree-stage {
grid-template-columns: 1fr;
padding-top: 90px;
}
.tree-node.branch.ui,
.tree-node.branch.tests,
.tree-node.branch.docs {
grid-column: 1;
justify-self: stretch;
}
}
</style>