diff --git a/src/components/blocks/ChangeLens.astro b/src/components/blocks/ChangeLens.astro
new file mode 100644
index 0000000..f149422
--- /dev/null
+++ b/src/components/blocks/ChangeLens.astro
@@ -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';
+---
+
+
+
+
+ >
+ )
+ }
+
+
+
diff --git a/src/components/blocks/FileTabs.astro b/src/components/blocks/FileTabs.astro
new file mode 100644
index 0000000..26868cf
--- /dev/null
+++ b/src/components/blocks/FileTabs.astro
@@ -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;
+---
+
+
+
+
diff --git a/src/components/blocks/PreviewPane.astro b/src/components/blocks/PreviewPane.astro
new file mode 100644
index 0000000..73ed231
--- /dev/null
+++ b/src/components/blocks/PreviewPane.astro
@@ -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;
+---
+
+
+
+
+ {title}
+ {subtitle}
+
+
+
+
+
+
+
+ {
+ rendered ? (
+
+
+
+ ) : (
+
+
+
+
+
+ )
+ }
+
+
+
diff --git a/src/components/blocks/ReviewDetail.astro b/src/components/blocks/ReviewDetail.astro
new file mode 100644
index 0000000..fdd3278
--- /dev/null
+++ b/src/components/blocks/ReviewDetail.astro
@@ -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)}`;
+---
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/blocks/SkillList.astro b/src/components/blocks/SkillList.astro
new file mode 100644
index 0000000..d4317ae
--- /dev/null
+++ b/src/components/blocks/SkillList.astro
@@ -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;
+---
+
+
+ {
+ entries.map((entry) => (
+
+ ))
+ }
+
+
+
diff --git a/src/components/blocks/VoteWidget.astro b/src/components/blocks/VoteWidget.astro
new file mode 100644
index 0000000..0861176
--- /dev/null
+++ b/src/components/blocks/VoteWidget.astro
@@ -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 ? (
+
+ READER VOTE
+
Voting is offline right now — the vote service is not configured or unreachable.
+
+ ) : (
+
+ WHICH DRAFT WOULD YOU SHIP?
+
+
+
+
+
+ {youVote
+ ? `You voted ${youVote === 'original' ? 'original' : 'improved draft'}. Pick the other option to change it.`
+ : 'One vote per visitor, tracked by network source.'}
+