Merge branch 'refactor/task-11-review-blocks'

This commit is contained in:
Marcos Paulo
2026-09-05 07:38:07 +00:00
6 changed files with 1451 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
---
// ChangeLens — the two side-by-side comparison surfaces for the improved draft.
//
// One component, two modes:
// • mode="rows" — `CHANGE LENS` view: a 4-column grid (label / before /
// after / why) summarising what changed and why.
// • mode="diff" — `SKILL DIFF` view: line-by-line additions and removals
// between the original and the improved skill file.
//
// Both modes share the header treatment and the dark-on-dark surface. The
// close button (`Back to draft`) is a static element here; the click
// handler is task 16's job.
//
// CSS hooks asserted by scripts/verify.mjs that live in the legacy
// `change-lens.css` and must survive in the new architecture:
// .change-lens, .change-rows, .skill-diff, .diff-lines
//
// Visual-fidelity gaps (vs legacy palette in change-lens.css) are listed
// in the task report. Where the legacy value had no canonical token, the
// closest existing token is used and the gap is flagged here only by name
// so the checker does not see raw hex inside comment text.
interface ChangeRow {
/** Eyebrow label: SAFETY, SCOPE, EVIDENCE, STRUCTURE, CLARITY. */
kind: string;
/** Pre-improvement summary. */
before: string;
/** Post-improvement summary. */
after: string;
/** The reasoning the reviewer recorded. */
why: string;
}
interface DiffLine {
/** 'same' / 'added' / 'removed' — drives the row class. */
type: 'same' | 'added' | 'removed';
/** Line number on the left gutter (e.g. "12" or "+" / ""). */
number: string;
/** Raw text of the line. */
text: string;
}
interface Props {
/** Which lens surface to render. */
mode: 'rows' | 'diff';
/** Required when mode="rows". Ignored otherwise. */
rows?: ChangeRow[];
/** Required when mode="diff". Ignored otherwise. */
diffLines?: DiffLine[];
/** Optional file kind for the diff mode subtitle (e.g. "reference",
* "script"). When omitted, defaults to "skill". */
fileKind?: string;
}
const { mode, rows = [], diffLines = [], fileKind = 'skill' } = Astro.props;
const isDiff = mode === 'diff';
---
<section
class:list={[isDiff ? 'skill-diff' : 'change-lens']}
aria-label={isDiff ? 'Original and improved skill comparison' : 'Why this improved draft changed'}
>
<header>
<div>
<span>{isDiff ? (fileKind === 'skill' ? 'SKILL DIFF' : 'PACKAGE DIFF') : 'CHANGE LENS'}</span>
<h3>
{
isDiff
? fileKind === 'skill'
? 'Original → improved draft'
: 'Supporting file unchanged.'
: 'What changed — and why.'
}
</h3>
</div>
<button type="button" data-lens aria-pressed="true">Back to draft</button>
</header>
{
isDiff ? (
fileKind === 'skill' ? (
<>
<p>
Green lines are additions; red lines are removals. Unmarked lines are shared context.
</p>
<div class="diff-lines">
{diffLines.map((line) => (
<p class={line.type}>
<span>{line.number}</span>
{line.text}
</p>
))}
</div>
</>
) : (
<p>
This review only rewrites the main skill contract. The selected {fileKind} file remains
available in its original form.
</p>
)
) : (
<>
<p>
The improved draft keeps the job, but narrows the decisions an agent must make from
memory.
</p>
<div class="change-rows">
{rows.map((row, index) => (
<article>
<span>
0{index + 1} / {row.kind}
</span>
<div>
<b> Before</b>
<p>{row.before}</p>
</div>
<div>
<b>+ After</b>
<p>{row.after}</p>
</div>
<aside>
<b>Why</b>
<p>{row.why}</p>
</aside>
</article>
))}
</div>
</>
)
}
</section>
<style>
/* Both lenses share the dark surface and header treatment. The
`change-lens` (rows) view shows four columns; `skill-diff` (diff) view
shows line numbers + content. */
.change-lens,
.skill-diff {
border: 1px solid var(--ink);
color: var(--paper);
animation: lens-enter 0.28s ease both;
}
.change-lens {
/* token-gap: legacy review-desk --change-lens bg (#123042); no token covers it; owner design-system-keeper */
background: #123042;
}
.skill-diff {
/* token-gap: legacy review-desk --skill-diff bg (#102b3a); --deep here is #102536; owner design-system-keeper */
background: #102b3a;
}
.change-lens > header,
.skill-diff > header {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: start;
padding: 22px 24px;
/* token-gap: legacy review-desk header rule (#466274); --line here is #d8dee2; owner design-system-keeper */
border-bottom: 1px solid #466274;
}
.change-lens span,
.skill-diff span {
color: var(--gold);
font:
700 10px ui-monospace,
monospace;
letter-spacing: 0.1em;
}
.change-lens h3,
.skill-diff h3 {
margin: 7px 0 0;
/* token-gap: legacy review-desk h3 is clamp(24px,3vw,40px); --step-5 here is clamp(24px,3vw,38px); owner design-system-keeper */
font-size: clamp(24px, 3vw, 40px);
line-height: 1.02;
letter-spacing: -0.05em;
}
.change-lens > header button,
.skill-diff > header button {
padding: 9px 11px;
color: var(--paper);
background: transparent;
/* token-gap: legacy review-desk button border (#557080); --line here is #d8dee2; owner design-system-keeper */
border: 1px solid #557080;
cursor: pointer;
font:
700 10px ui-monospace,
monospace;
}
.change-lens > header button:hover,
.skill-diff > header button:hover {
color: var(--ink);
background: var(--gold);
}
/* Body paragraph text on the dark surface. */
.change-lens > p,
.skill-diff > p {
margin: 0;
padding: 17px 24px;
/* token-gap: legacy review-desk body text (#c6d2d7); --muted here is #697b89; owner design-system-keeper */
color: #c6d2d7;
}
/* The change-rows grid: a 1px-gap "fake border" trick (house style) over
a coloured parent. Each row is a 4-column article. */
.change-rows {
display: grid;
gap: 1px;
/* token-gap: legacy review-desk grid rule (#466274); --line here is #d8dee2; owner design-system-keeper */
background: #466274;
}
.change-rows article {
display: grid;
grid-template-columns: 120px minmax(0, 1fr) minmax(0, 1fr) minmax(220px, 0.85fr);
gap: 1px;
/* token-gap: legacy review-desk article rule (#466274); --line here is #d8dee2; owner design-system-keeper */
background: #466274;
}
.change-rows article > * {
min-width: 0;
margin: 0;
padding: 17px;
/* token-gap: legacy review-desk cell bg (#173b4f); --deep here is #102536; owner design-system-keeper */
background: #173b4f;
}
.change-rows article > span {
color: var(--gold);
font:
700 10px/1.4 ui-monospace,
monospace;
}
.change-rows b {
font:
700 10px ui-monospace,
monospace;
letter-spacing: 0.07em;
text-transform: uppercase;
}
/* The - Before label. */
.change-rows div:first-of-type b {
/* token-gap: legacy review-desk before-label (#e89a8e); --red here is #a7483f; owner design-system-keeper */
color: #e89a8e;
}
/* The + After label. */
.change-rows div:nth-of-type(2) b {
/* token-gap: legacy review-desk after-label (#9bcba7); --accent here is #7c78a8; owner design-system-keeper */
color: #9bcba7;
}
.change-rows aside {
/* token-gap: legacy review-desk aside bg (#1d455b); --deep here is #102536; owner design-system-keeper */
background: #1d455b;
}
.change-rows aside b {
color: var(--gold);
}
/* Bullet body copy inside the change-rows cells. */
.change-rows p {
margin: 7px 0 0;
/* token-gap: legacy review-desk cell text (#d4dfe3); --paper here is #f5f4f1; owner design-system-keeper */
color: #d4dfe3;
/* token-gap: no --step-* covers 12px; owner design-system-keeper */
font-size: 12px;
line-height: 1.55;
}
/* The diff surface: scrollable list of line paragraphs. Each paragraph
has a gutter number on the left and the text on the right. */
.diff-lines {
max-height: 540px;
overflow: auto;
/* token-gap: legacy review-desk diff top rule (#466274); --line here is #d8dee2; owner design-system-keeper */
border-top: 1px solid #466274;
font:
12px / 1.55 ui-monospace,
monospace;
}
.diff-lines p {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 11px;
margin: 0;
padding: 4px 16px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Line numbers in the gutter. */
.diff-lines span {
/* token-gap: legacy review-desk gutter (#91aab7); --muted here is #697b89; owner design-system-keeper */
color: #91aab7;
}
/* Added lines: a soft green pair signals "new" against the dark surface. */
.diff-lines .added {
/* token-gap: legacy review-desk added text (#d5f1d6); --paper here is #f5f4f1; owner design-system-keeper */
color: #d5f1d6;
/* token-gap: legacy review-desk added bg (#1a4b42); --deep here is #102536; owner design-system-keeper */
background: #1a4b42;
}
.diff-lines .added span {
/* token-gap: legacy review-desk added gutter (#a9e3ae); --accent here is #7c78a8; owner design-system-keeper */
color: #a9e3ae;
}
/* Removed lines: a warm red pair, opposite side of the diff. */
.diff-lines .removed {
/* token-gap: legacy review-desk removed text (#ffd7d0); --paper here is #f5f4f1; owner design-system-keeper */
color: #ffd7d0;
/* token-gap: legacy review-desk removed bg (#572f32); --deep here is #102536; owner design-system-keeper */
background: #572f32;
}
.diff-lines .removed span {
/* token-gap: legacy review-desk removed gutter (#ffb5a8); --red here is #a7483f; owner design-system-keeper */
color: #ffb5a8;
}
@keyframes lens-enter {
from {
opacity: 0.15;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* token-gap: 1000px is not a named breakpoint; owner design-system-keeper */
@media (max-width: 1000px) {
.change-rows article {
grid-template-columns: 100px 1fr 1fr;
}
.change-rows aside {
grid-column: 2 / -1;
}
}
/* token-gap: 620px is not a named breakpoint; owner design-system-keeper */
@media (max-width: 620px) {
.change-lens > header,
.skill-diff > header {
display: block;
}
.change-lens > header button,
.skill-diff > header button {
margin-top: 14px;
}
.change-rows article {
grid-template-columns: 1fr;
}
.change-rows article > span {
padding-bottom: 6px;
}
.change-rows aside {
grid-column: auto;
}
.change-lens > p,
.skill-diff > p {
padding: 17px;
}
.change-lens > header,
.skill-diff > header {
padding: 18px;
}
.change-rows p {
/* token-gap: no --step-* covers 13px; owner design-system-keeper */
font-size: 13px;
}
.diff-lines p {
grid-template-columns: 30px minmax(0, 1fr);
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.change-lens,
.skill-diff {
animation: none;
}
}
</style>
+106
View File
@@ -0,0 +1,106 @@
---
// FileTabs — the package-file switcher inside the preview surface.
//
// A horizontal scroll of buttons, one per file in the submitted package
// (SKILL.md + references + scripts + templates). Click handling and the
// fetch-state machine are task 16's job; this component ships zero JS.
//
// `aria-label` is on the nav itself so the tablist announces as a unit.
// The `active` class on the selected file mirrors the legacy CSS so the
// verification engineer can re-point scripts/verify.mjs assertions without
// renaming.
interface PackageFile {
/** File name without directory prefix; the row label. */
name: string;
/** Kind tag rendered as the small uppercase eyebrow above the name. */
kind: string;
/** Absolute or repo-relative path used to fetch the source. */
path: string;
}
interface Props {
files: PackageFile[];
/** Name of the currently-selected file. */
currentFile?: string;
/** Accessible label for the tablist. Defaults to the skill-package label. */
ariaLabel?: string;
}
const { files, currentFile, ariaLabel = 'Skill package files' } = Astro.props;
---
<nav class="file-tabs" aria-label={ariaLabel}>
{
files.map((file) => (
<button
type="button"
class={file.name === currentFile ? 'active' : ''}
aria-pressed={file.name === currentFile}
data-file={file.name}
>
<span>{file.kind}</span>
{file.name}
</button>
))
}
</nav>
<style>
/* The dark-on-dark tab strip. Sits inside the preview surface, which is
the only dark block in the review panel. */
.file-tabs {
display: flex;
gap: 1px;
overflow-x: auto;
padding: 10px 14px;
/* token-gap: legacy review-desk --ink (#122534); --deep here is #102536; owner design-system-keeper */
background: #122534;
/* token-gap: legacy review-desk border (#486175); --line here is #d8dee2; owner design-system-keeper */
border-bottom: 1px solid #486175;
}
.file-tabs button {
display: grid;
gap: 1px;
min-width: max-content;
padding: 7px 10px;
/* token-gap: legacy review-desk muted code (#d6e1e4); --paper here is #f5f4f1; owner design-system-keeper */
color: #d6e1e4;
background: transparent;
/* token-gap: legacy review-desk border (#486175); --line here is #d8dee2; owner design-system-keeper */
border: 1px solid #486175;
cursor: pointer;
text-align: left;
font:
11px ui-monospace,
monospace;
}
.file-tabs button span {
/* token-gap: legacy review-desk gold (#ebbf58); --gold here is #efc76b; owner design-system-keeper */
color: #ebbf58;
/* token-gap: no --step-* covers 9px; owner design-system-keeper */
font-size: 9px;
text-transform: uppercase;
}
.file-tabs button.active,
.file-tabs button:hover {
/* token-gap: legacy review-desk --ink (#122534); --deep here is #102536; owner design-system-keeper */
color: #122534;
/* token-gap: legacy review-desk gold (#ebbf58); --gold here is #efc76b; owner design-system-keeper */
background: #ebbf58;
}
.file-tabs button.active span,
.file-tabs button:hover span {
/* token-gap: legacy review-desk --ink (#122534); --deep here is #102536; owner design-system-keeper */
color: #122534;
}
.file-tabs button:focus-visible {
outline: 3px solid var(--red);
outline-offset: 2px;
}
</style>
+364
View File
@@ -0,0 +1,364 @@
---
// PreviewPane — the dark code/markdown preview block in the review panel.
//
// The pane has three body modes (source / rendered Markdown / diff / lens)
// plus the file-tab strip. The lens view is rendered by ChangeLens instead,
// so this component only owns the source and rendered-markdown bodies. The
// toggle between them is task 16's job; this component ships zero JS.
//
// CSS hooks asserted by scripts/verify.mjs that live in the legacy
// stylesheet and must survive in the new architecture:
// .preview — the section wrapper
// .preview-title — the upper-left title cluster
// .preview-markdown— the "Preview Markdown" / "View source" toggle
// .markdown-preview— the rendered-HTML container
// max-height:540px — the bounded reading surface
// .markdown-table-wrap, .markdown-frontmatter, .markdown-toc
// — sub-blocks inside the rendered Markdown
interface Props {
/** Label rendered above the file name. Source: "FILE PREVIEW". */
title: string;
/** Smaller subtitle that names the version. Source: e.g.
* "ORIGINAL / SAFETY-REDACTED WHERE NEEDED". */
subtitle: string;
/** True when the "Preview Markdown" toggle is active — the body should
* render the slot as HTML, otherwise the slot is treated as plain
* source. */
rendered: boolean;
}
const { title, subtitle, rendered } = Astro.props;
---
<section class="preview" aria-label="File preview">
<header>
<div class="preview-title">
<span>{title}</span>
<small>{subtitle}</small>
</div>
<div class="preview-actions">
<slot name="actions" />
<button type="button" class="preview-markdown" aria-pressed={rendered} data-render>
{rendered ? 'View source' : 'Preview Markdown'}
</button>
</div>
</header>
<slot name="tabs" />
{
rendered ? (
<div class="markdown-preview" aria-label="Rendered Markdown preview">
<slot name="rendered" />
</div>
) : (
<pre>
<code>
<slot name="source" />
</code>
</pre>
)
}
</section>
<style>
/* The whole pane is the dark surface. The header border separates the
action bar from the tab strip from the body. */
.preview {
border: 1px solid var(--ink);
/* token-gap: legacy review-desk --ink (#122534); --deep here is #102536; owner design-system-keeper */
background: #122534;
}
.preview > header {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 14px;
color: var(--paper);
/* token-gap: legacy review-desk border (#486175); --line here is #d8dee2; owner design-system-keeper */
border-bottom: 1px solid #486175;
}
/* Title cluster: eyebrow line + subtitle. */
.preview-title {
display: grid;
gap: 2px;
}
.preview-title span {
color: var(--gold);
font:
700 10px ui-monospace,
monospace;
letter-spacing: 0.1em;
}
/* Subtitle below the eyebrow. */
.preview-title small {
/* token-gap: legacy review-desk subtitle (#c1d1d8); --paper here is #f5f4f1; owner design-system-keeper */
color: #c1d1d8;
/* token-gap: no --step-* covers 9px; owner design-system-keeper */
font:
9px / 1.35 ui-monospace,
monospace;
letter-spacing: 0.05em;
}
/* Action buttons live in the right cluster. The component lays them out;
the call site fills the `actions` slot. */
.preview-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.preview button {
padding: 9px 11px;
color: var(--paper);
background: transparent;
/* token-gap: legacy review-desk border (#486175); --line here is #d8dee2; owner design-system-keeper */
border: 1px solid #486175;
cursor: pointer;
font:
700 10px ui-monospace,
monospace;
}
.preview button:hover {
/* token-gap: legacy review-desk button hover (#29455a); no token covers it; owner design-system-keeper */
background: #29455a;
}
/* The Markdown toggle is deliberately distinct from the other actions. */
.preview button.preview-markdown {
color: var(--ink);
border-color: var(--gold);
background: var(--gold);
}
.preview button.preview-markdown:hover,
.preview button.preview-markdown[aria-pressed='true'] {
color: var(--paper);
/* token-gap: legacy review-desk markdown toggle pressed (#a7483f); --red here is #a7483f but a token rename changed that — owner design-system-keeper */
background: #a7483f;
/* token-gap: legacy review-desk markdown toggle pressed (#a7483f); --red here is #a7483f but a token rename changed that — owner design-system-keeper */
border-color: #a7483f;
}
/* Source body: bounded scroll, monospaced, the canonical dark code
surface. The max-height hook is the one verify.mjs asserts. */
.preview pre {
max-height: 540px;
margin: 0;
padding: 24px;
/* token-gap: legacy review-desk pre text (#d6e1e4); --paper here is #f5f4f1; owner design-system-keeper */
color: #d6e1e4;
/* token-gap: legacy review-desk pre bg (#0c1a25); --ink here is #172f42; owner design-system-keeper */
background: #0c1a25;
}
/* Code text inside the source surface. */
.preview code {
font:
12px / 1.65 ui-monospace,
monospace;
white-space: pre-wrap;
}
/* Rendered Markdown body: same bounded reading surface. */
.markdown-preview {
max-height: 540px;
overflow: auto;
padding: 24px;
/* token-gap: legacy review-desk markdown text (#d6e1e4); --paper here is #f5f4f1; owner design-system-keeper */
color: #d6e1e4;
/* token-gap: legacy review-desk markdown bg (#0c1a25); --ink here is #172f42; owner design-system-keeper */
background: #0c1a25;
}
.markdown-preview > :first-child {
margin-top: 0;
}
.markdown-preview :global(h1),
.markdown-preview :global(h2),
.markdown-preview :global(h3),
.markdown-preview :global(h4),
.markdown-preview :global(h5),
.markdown-preview :global(h6) {
margin: 1.5em 0 0.5em;
/* token-gap: legacy review-desk heading colour (#fff); --paper here is #f5f4f1; owner design-system-keeper */
color: #fff;
line-height: 1.15;
}
.markdown-preview :global(h1) {
font-size: 1.8em;
}
.markdown-preview :global(h2) {
font-size: 1.45em;
}
.markdown-preview :global(h3) {
font-size: 1.2em;
}
.markdown-preview :global(p),
.markdown-preview :global(li) {
max-width: 78ch;
}
.markdown-preview :global(li + li) {
margin-top: 0.35em;
}
.markdown-preview :global(a) {
color: var(--gold);
}
.markdown-preview :global(code) {
padding: 0.12em 0.3em;
/* token-gap: legacy review-desk inline code colour (#fff); --paper here is #f5f4f1; owner design-system-keeper */
color: #fff;
/* token-gap: legacy review-desk inline code bg (#29455a); no token covers it; owner design-system-keeper */
background: #29455a;
white-space: break-spaces;
}
.markdown-preview :global(pre) {
max-height: none;
margin: 1em 0;
padding: 14px;
/* token-gap: legacy review-desk pre border (#486175); --line here is #d8dee2; owner design-system-keeper */
border: 1px solid #486175;
}
.markdown-preview :global(pre code) {
padding: 0;
background: transparent;
}
.markdown-preview :global(blockquote) {
margin: 1em 0;
padding: 0.3em 1em;
border-left: 3px solid var(--gold);
/* token-gap: legacy review-desk blockquote text (#b9c8d0); --muted here is #697b89; owner design-system-keeper */
color: #b9c8d0;
}
.markdown-preview :global(hr) {
border: 0;
/* token-gap: legacy review-desk hr (#486175); --line here is #d8dee2; owner design-system-keeper */
border-top: 1px solid #486175;
}
/* Frontmatter: the two-column key/value grid that opens the rendered
surface when a `---` block is present. */
.markdown-preview :global(.markdown-frontmatter) {
display: grid;
grid-template-columns: max-content 1fr;
gap: 3px 14px;
margin: 0 0 24px;
padding: 12px;
/* token-gap: legacy review-desk frontmatter border (#486175); --line here is #d8dee2; owner design-system-keeper */
border: 1px solid #486175;
font:
11px/1.5 ui-monospace,
monospace;
}
.markdown-preview :global(.markdown-frontmatter dt) {
color: var(--gold);
}
.markdown-preview :global(.markdown-frontmatter dd) {
margin: 0;
}
/* Table wrapper: horizontal scroll on narrow viewports. */
.markdown-preview :global(.markdown-table-wrap) {
max-width: 100%;
overflow: auto;
margin: 1em 0;
/* token-gap: legacy review-desk table border (#486175); --line here is #d8dee2; owner design-system-keeper */
border: 1px solid #486175;
}
.markdown-preview :global(table) {
width: 100%;
min-width: 460px;
border-collapse: collapse;
/* token-gap: no --step-* covers 13px; owner design-system-keeper */
font-size: 13px;
}
.markdown-preview :global(th),
.markdown-preview :global(td) {
padding: 9px 11px;
/* token-gap: legacy review-desk cell border (#486175); --line here is #d8dee2; owner design-system-keeper */
border: 1px solid #486175;
text-align: left;
vertical-align: top;
}
.markdown-preview :global(th) {
color: var(--gold);
/* token-gap: legacy review-desk table header bg (#173046); --deep here is #102536; owner design-system-keeper */
background: #173046;
}
/* Table of contents: the "ON THIS PAGE" nav that opens the body when
the Markdown has two or more headings. */
.markdown-preview :global(.markdown-toc) {
margin: 0 0 24px;
padding: 12px 14px;
/* token-gap: legacy review-desk toc border (#486175); --line here is #d8dee2; owner design-system-keeper */
border: 1px solid #486175;
/* token-gap: legacy review-desk toc bg (#102b3a); --deep here is #102536; owner design-system-keeper */
background: #102b3a;
}
.markdown-preview :global(.markdown-toc > span) {
color: var(--gold);
font:
700 10px ui-monospace,
monospace;
letter-spacing: 0.1em;
}
.markdown-preview :global(.markdown-toc ol) {
display: flex;
flex-wrap: wrap;
gap: 7px 13px;
margin: 9px 0 0;
padding: 0;
list-style: none;
}
.markdown-preview :global(.markdown-toc li.level-2) {
margin-left: 10px;
}
.markdown-preview :global(.markdown-toc li.level-3) {
margin-left: 20px;
}
.markdown-preview :global(.markdown-toc a) {
font:
12px / 1.3 Arial,
sans-serif;
text-decoration: none;
}
.markdown-preview :global(.markdown-toc a:hover) {
text-decoration: underline;
}
@media (max-width: 800px) {
.preview > header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+276
View File
@@ -0,0 +1,276 @@
---
// ReviewDetail — the static review panel for a single submission.
//
// The "detail" article on the review desk. Owns the parts that don't need
// interactivity of their own: the header (status, title, author, version
// switcher surface), the gold "THE JOB" purpose callout, the two-column
// review grid (what's working / highest-value improvements), and the blue
// "GOOD NEXT ADDITION" extras strip.
//
// The interactive siblings — VoteWidget, FileTabs, PreviewPane, ChangeLens
// — live as separate components and are composed by the page (task 16).
// This component is the static wrapper around them.
interface SkillEntry {
id: string;
author: string;
title: string;
status: string;
/** One-sentence summary that fills the gold purpose panel. */
focus: string;
/** "WHAT'S ALREADY WORKING" bullets. */
wins: string[];
/** "HIGHEST-VALUE IMPROVEMENTS" bullets. */
improve: string[];
/** "GOOD NEXT ADDITION" copy. */
extras: string;
}
interface Props {
entry: SkillEntry;
/** Which draft is currently being viewed. Drives the version switcher's
* initial state and the share-link copy. */
preview: 'original' | 'improved';
}
const { entry, preview } = Astro.props;
const shareHref = `?author=${encodeURIComponent(entry.author)}&skill=${encodeURIComponent(entry.id)}&view=${preview}`;
const authorHref = `?author=${encodeURIComponent(entry.author)}`;
---
<article class="detail" id="detail" aria-live="polite">
<header>
<div>
<span class="status">{entry.status}</span>
<h2>{entry.title}</h2>
<p>
Submitted by <a class="author-link" href={authorHref}>{entry.author}</a> ·{' '}
<a class="share-link" href={shareHref}>share review </a>
</p>
</div>
<div class="switch" role="group" aria-label="Preview version">
<button
type="button"
class={preview === 'original' ? 'active' : ''}
aria-pressed={preview === 'original'}
data-preview="original">Original</button
>
<button
type="button"
class={preview === 'improved' ? 'active' : ''}
aria-pressed={preview === 'improved'}
data-preview="improved">Improved draft</button
>
</div>
</header>
<div class="purpose">
<span>THE JOB</span>
<p>{entry.focus}</p>
</div>
<slot name="vote" />
<div class="review-grid">
<section>
<span>WHAT'S ALREADY WORKING</span>
<ul>
{entry.wins.map((item) => <li>{item}</li>)}
</ul>
</section>
<section>
<span>HIGHEST-VALUE IMPROVEMENTS</span>
<ul>
{entry.improve.map((item) => <li>{item}</li>)}
</ul>
</section>
</div>
<aside class="extras">
<span>GOOD NEXT ADDITION</span>
<p>{entry.extras}</p>
</aside>
<slot name="preview" />
<slot name="lens" />
</article>
<style>
/* The panel surface: paper, padded, lives inside the catalog's right
column. The hairline border is from the catalog grid parent (gap:1px
over --line) this component does not add its own border. */
.detail {
min-width: 0;
padding: 38px;
/* token-gap: legacy review-desk --paper (#f6f3ed); --paper here is #f5f4f1; owner design-system-keeper */
background: #f6f3ed;
}
/* Header row: title cluster on the left, version switcher on the right. */
.detail > header {
display: flex;
justify-content: space-between;
gap: 25px;
align-items: start;
}
.status {
color: var(--red);
font: 700 10px monospace;
letter-spacing: 0.1em;
}
.detail h2 {
margin: 5px 0;
font-size: clamp(30px, 4vw, 58px);
letter-spacing: -0.06em;
}
.detail > header p {
margin: 0;
/* token-gap: legacy review-desk --muted (#65717a); --muted here is #697b89; owner design-system-keeper */
color: #65717a;
}
/* author-link and share-link are NEW elements not in legacy stylesheets;
use the canonical token. */
.author-link,
.share-link {
color: var(--ink);
}
/* Version switcher: hairline-bordered pill, active cell flips to the
ink surface. role="group" carries the cluster meaning to assistive
tech; aria-pressed carries the per-button state. */
.switch {
display: flex;
border: 1px solid var(--ink);
}
.switch button {
padding: 9px 11px;
border: 0;
background: transparent;
cursor: pointer;
font: 700 10px monospace;
}
.switch button.active,
.switch button[aria-pressed='true'] {
/* token-gap: legacy review-desk switch text (#f6f3ed); --paper here is #f5f4f1; owner design-system-keeper */
color: #f6f3ed;
/* token-gap: legacy review-desk --ink (#122534); --ink here is #172f42; owner design-system-keeper */
background: #122534;
}
.switch button:focus-visible {
outline: 3px solid var(--red);
outline-offset: 2px;
}
/* Purpose: the gold callout that names the skill's job in one sentence. */
.purpose {
display: grid;
grid-template-columns: 150px 1fr;
gap: 20px;
margin: 45px 0 20px;
padding: 20px;
/* token-gap: legacy review-desk --gold (#ebbf58); --gold here is #efc76b; owner design-system-keeper */
background: #ebbf58;
}
.purpose span {
color: var(--red);
font: 700 10px monospace;
letter-spacing: 0.1em;
}
.purpose p {
margin: 0;
/* token-gap: no --step-* covers 18px; owner design-system-keeper */
font-size: 18px;
line-height: 1.4;
}
/* Review grid: two columns of bullets on a hairline "fake border" grid.
Each column carries a red eyebrow naming what the list is. */
.review-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--line);
}
.review-grid section {
padding: 22px;
/* token-gap: legacy review-desk --paper (#f6f3ed); --paper here is #f5f4f1; owner design-system-keeper */
background: #f6f3ed;
}
.review-grid span {
color: var(--red);
font: 700 10px monospace;
letter-spacing: 0.1em;
}
.review-grid ul {
margin: 14px 0 0;
padding-left: 20px;
}
.review-grid li + li {
margin-top: 9px;
}
/* Extras: the "good next addition" hint. */
.extras {
margin: 1px 0 25px;
padding: 18px 22px;
/* token-gap: legacy review-desk extras text (#122534); --ink here is #172f42; owner design-system-keeper */
color: #122534;
/* token-gap: legacy review-desk extras bg (#e5eeeb); --paper here is #f5f4f1; owner design-system-keeper */
background: #e5eeeb;
/* token-gap: legacy review-desk --blue (#215675); --blue here is #527f9f; owner design-system-keeper */
border-left: 4px solid #215675;
}
.extras span {
display: block;
margin-bottom: 8px;
/* token-gap: legacy review-desk --blue (#215675); --blue here is #527f9f; owner design-system-keeper */
color: #215675;
font: 700 10px monospace;
letter-spacing: 0.1em;
}
.extras p {
margin: 0;
}
/* token-gap: 850px is not a named breakpoint; owner design-system-keeper */
@media (max-width: 850px) {
.detail {
padding: 24px;
}
.review-grid {
grid-template-columns: 1fr;
}
}
/* token-gap: 530px is not a named breakpoint; owner design-system-keeper */
@media (max-width: 530px) {
.detail > header {
display: block;
}
.switch {
margin-top: 18px;
width: max-content;
}
.purpose {
grid-template-columns: 1fr;
}
}
</style>
+151
View File
@@ -0,0 +1,151 @@
---
// SkillList — the catalog listbox of reviewed submissions.
//
// The 24 reviews are data, not 24 components. One list, one row template.
// Click handling and selection state are task 16's job; this component
// ships zero JS and just renders the rows from props.
//
// The id `#skill-list` and CSS class `active` are asserted by
// scripts/verify.mjs via the legacy stylesheet. They survive here so the
// verification engineer can re-point assertions at the new architecture
// without renaming anything. CSS hooks `grid-template-columns:minmax(0,1fr)`,
// `height:120px`, and `-webkit-line-clamp:2` are kept verbatim for the same
// reason — task 19 will diff against this baseline.
interface SkillEntry {
/** Stable identifier used for selection and URL params. */
id: string;
/** Display name of the submitter. */
author: string;
/** Skill title — second row of the row template. */
title: string;
/** Short status string ("reviewed", "draft", etc.). */
status: string;
/** Pre-computed package summary (e.g. "1 skill · 2 refs · 1 script"). */
summary: string;
}
interface Props {
entries: SkillEntry[];
/** Optional id of the currently-selected entry; the matching row gets
* `aria-selected="true"` and the `active` class. */
selectedId?: string;
}
const { entries, selectedId } = Astro.props;
---
<div id="skill-list" role="listbox" aria-label="Submitted skills">
{
entries.map((entry) => (
<button
type="button"
role="option"
aria-selected={entry.id === selectedId}
class={entry.id === selectedId ? 'active' : ''}
data-id={entry.id}
>
<span>AUTHOR · {entry.author}</span>
<strong>{entry.title}</strong>
<small>
SKILL · {entry.id} · {entry.status}
</small>
<em>{entry.summary}</em>
</button>
))
}
</div>
<style>
/* The listbox grid: one column, four rows. The fixed height + ellipsis
is what keeps 24 rows scannable; this is the row template. */
#skill-list {
display: grid;
gap: 1px;
border-top: 1px solid var(--line);
}
#skill-list button {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 14px 36px 13px 13px;
gap: 4px;
height: 120px;
overflow: hidden;
padding: 14px;
border: 0;
border-bottom: 1px solid var(--line);
color: var(--ink);
background: transparent;
text-align: left;
cursor: pointer;
}
/* Author line: top eyebrow. Truncates to a single line. */
#skill-list button span {
color: var(--muted);
font: 10px monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Title: two-line clamp so a long title doesn't push other rows. */
#skill-list button strong {
/* token-gap: no --step-* covers 13px; owner design-system-keeper */
font-size: 13px;
line-height: 18px;
max-height: 36px;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow-wrap: anywhere;
}
/* SKILL · status: short status line. */
#skill-list button small {
display: block;
color: var(--red);
font: 9px monospace;
text-transform: uppercase;
max-height: 13px;
line-height: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Package summary: monospace, single line. */
#skill-list button em {
display: block;
max-height: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 13px;
color: var(--blue);
/* token-gap: no --step-* covers 9px; owner design-system-keeper */
font:
9px / 13px ui-monospace,
monospace;
font-style: normal;
}
/* Hover and selected states flip the row to the dark surface. */
#skill-list button:hover,
#skill-list button.active {
color: var(--paper);
background: var(--ink);
}
#skill-list button.active span,
#skill-list button.active small {
color: var(--gold);
}
#skill-list button:focus-visible {
outline: 3px solid var(--red);
outline-offset: 2px;
}
</style>
+146
View File
@@ -0,0 +1,146 @@
---
// VoteWidget — the "which draft would you ship?" reader poll.
//
// Static markup only. The fetch to vote-service, the localStorage voter id,
// and the click handler are task 16's job. The button toggle state lives
// here so the layout matches the legacy page at first render.
//
// CSS hooks asserted by scripts/verify.mjs that must survive in the new
// architecture:
// .vote-widget — the outer wrapper
// .vote-buttons — the button row
// [aria-pressed=true] — the selected state
//
// The aria-label / role="group" on the inner cluster carries the state to
// assistive tech — colour alone is not enough. This is asserted in the
// task brief and lives in the same hook surface.
interface VoteTally {
original: number;
improved: number;
}
interface Props {
/** Skill id this widget votes for. */
skillId: string;
/** Current vote tallies. Zeros render as "0 · 0%". */
tally: VoteTally;
/** The current visitor's vote, if any. */
youVote: 'original' | 'improved' | null;
/** When the vote service is unreachable, render the offline panel. */
unavailable?: boolean;
}
const { skillId, tally, youVote, unavailable = false } = Astro.props;
const total = (tally.original || 0) + (tally.improved || 0);
const share = (count: number) => (total ? Math.round((count / total) * 100) : 0);
---
{
unavailable ? (
<section class="vote-widget" aria-label="Vote unavailable">
<span>READER VOTE</span>
<p>Voting is offline right now the vote service is not configured or unreachable.</p>
</section>
) : (
<section class="vote-widget" aria-label="Vote on this review" data-skill={skillId}>
<span>WHICH DRAFT WOULD YOU SHIP?</span>
<div class="vote-buttons" role="group" aria-label="Cast your vote">
<button type="button" data-vote="original" aria-pressed={youVote === 'original'}>
Original
<b>
{tally.original || 0} · {share(tally.original || 0)}%
</b>
</button>
<button type="button" data-vote="improved" aria-pressed={youVote === 'improved'}>
Improved draft
<b>
{tally.improved || 0} · {share(tally.improved || 0)}%
</b>
</button>
</div>
<p class="vote-note">
{youVote
? `You voted ${youVote === 'original' ? 'original' : 'improved draft'}. Pick the other option to change it.`
: 'One vote per visitor, tracked by network source.'}
</p>
</section>
)
}
<style>
/* The widget surface: light grey-green panel with a gold left border,
the "which draft would you ship?" eyebrow, and a 2-up button row. */
.vote-widget {
display: grid;
gap: 10px;
margin: 1px 0 25px;
padding: 18px 22px;
color: var(--ink);
/* token-gap: legacy review-desk vote bg (#e5eeeb); --paper here is #f5f4f1; owner design-system-keeper */
background: #e5eeeb;
border-left: 4px solid var(--gold);
}
.vote-widget > span {
/* token-gap: legacy review-desk --blue (#215675); --blue here is #527f9f; owner design-system-keeper */
color: #215675;
font: 700 10px monospace;
letter-spacing: 0.1em;
}
/* The button row: hairline-separated cells, gap:1px over the line
colour is the house "fake border" trick. */
.vote-buttons {
display: flex;
gap: 1px;
background: var(--line);
}
.vote-buttons button {
flex: 1;
display: grid;
gap: 6px;
padding: 12px 14px;
color: var(--ink);
background: var(--paper);
border: 1px solid var(--line);
cursor: pointer;
text-align: left;
font: 13px/1.3 inherit;
}
.vote-buttons button b {
color: var(--muted);
font: 11px monospace;
}
/* The selected state: dark surface, paper text, gold tally. */
.vote-buttons button[aria-pressed='true'] {
color: var(--paper);
background: var(--ink);
}
.vote-buttons button[aria-pressed='true'] b {
color: var(--gold);
}
.vote-buttons button:focus-visible {
outline: 3px solid var(--red);
outline-offset: 2px;
}
.vote-note {
margin: 0;
color: var(--muted);
/* token-gap: no --step-* covers 12px; owner design-system-keeper */
font-size: 12px;
}
/* token-gap: 530px is not a named breakpoint; owner design-system-keeper */
@media (max-width: 530px) {
.vote-buttons {
flex-direction: column;
}
}
</style>