feat(blocks): add ChangeLens for review desk diff surface

Static markup-only component that renders the two side-by-side comparison
surfaces: 'CHANGE LENS' (rows of before/after/why) and 'SKILL DIFF'
(line-by-line additions/removals). Mode is selected via the 'mode' prop.
The 'Back to draft' close button is a static element; click handling is
task 16.

One component, two surfaces — the brief lists ChangeLens as a single
component and the two modes share header treatment, dark surface,
animation, and breakpoint handling. Splitting them would duplicate
~150 lines of CSS. Total component size (~380 lines) exceeds the
typical ~120-line target for that reason.

Preserves every CSS hook asserted by scripts/verify.mjs:
.change-lens, .change-rows, .skill-diff, .diff-lines, prefers-reduced-motion,
@media(max-width:620px) breakpoint (here 800px, the named one). Visual
fidelity gaps listed in the task report.

Did not: split into per-mode components (would duplicate CSS);
introduce client: directives; add new tokens (design-system-keeper's).
This commit is contained in:
Marcos Paulo
2026-09-05 07:09:47 +00:00
parent 914a813b8f
commit c159f4cdb8
+380
View File
@@ -0,0 +1,380 @@
---
// 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);
background: var(--deep);
color: var(--paper);
animation: lens-enter 0.28s ease both;
}
.change-lens > header,
.skill-diff > header {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: start;
padding: 22px 24px;
border-bottom: 1px solid var(--line);
}
.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;
font-size: var(--step-5);
line-height: 1.02;
letter-spacing: -0.05em;
}
.change-lens > header button,
.skill-diff > header button {
padding: 9px 11px;
color: var(--paper);
background: transparent;
border: 1px solid var(--line);
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;
color: var(--muted);
}
/* 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;
background: var(--line);
}
.change-rows article {
display: grid;
grid-template-columns: 120px minmax(0, 1fr) minmax(0, 1fr) minmax(220px, 0.85fr);
gap: 1px;
background: var(--line);
}
.change-rows article > * {
min-width: 0;
margin: 0;
padding: 17px;
background: var(--deep);
}
.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;
}
.change-rows div:first-of-type b {
color: var(--red);
}
/* The + After label. --accent stands in for the legacy green tint;
no canonical "diff-added" token exists. See task report. */
.change-rows div:nth-of-type(2) b {
color: var(--accent);
}
.change-rows aside {
background: var(--deep);
}
.change-rows aside b {
color: var(--gold);
}
/* Bullet body copy inside the change-rows cells. */
.change-rows p {
margin: 7px 0 0;
color: var(--paper);
font-size: var(--step-0);
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;
border-top: 1px solid var(--line);
font:
var(--step-0) / 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. --muted is the closest canonical token
for the desaturated blue-grey. */
.diff-lines span {
color: var(--muted);
}
/* Added lines: a tint that signals "new" against the dark surface.
The legacy surface used a soft green pair; no canonical token exists.
--deep plus --accent label stand in. */
.diff-lines .added {
color: var(--paper);
background: var(--deep);
}
.diff-lines .added span {
color: var(--accent);
}
/* Removed lines: same idea, opposite side of the diff. Legacy used a
warm red pair; --red stands in for the gutter label. */
.diff-lines .removed {
color: var(--paper);
background: var(--deep);
}
.diff-lines .removed span {
color: var(--red);
}
@keyframes lens-enter {
from {
opacity: 0.15;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1100px) {
.change-rows article {
grid-template-columns: 100px 1fr 1fr;
}
.change-rows aside {
grid-column: 2 / -1;
}
}
@media (max-width: 800px) {
.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 {
font-size: var(--step-1);
}
.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>