feat(blocks): add PreviewPane for review desk file preview

Static markup-only component for the dark code/markdown preview surface.
Renders the preview header (title + actions), the file-tabs slot, and
either the source body or the rendered Markdown body based on the
'rendered' prop. Toggling between modes is task 16.

Preserves every CSS hook asserted by scripts/verify.mjs:
.preview, .preview-title, .preview-markdown, .markdown-preview,
max-height:540px, .markdown-table-wrap, .markdown-frontmatter,
.markdown-toc, plus the dark surface treatment. Visual fidelity gaps
listed in the task report.

Did not: introduce a markdown renderer (task 16 hydrates the body);
port the cat-marker CSS hook to the new surface (legacy only).
This commit is contained in:
Marcos Paulo
2026-09-05 07:09:01 +00:00
parent c6b87b74ae
commit 914a813b8f
+343
View File
@@ -0,0 +1,343 @@
---
// 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 — same --deep as the rest of the
preview tooling. The header border separates the action bar from the
tab strip from the body. */
.preview {
border: 1px solid var(--ink);
background: var(--deep);
}
.preview > header {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 14px;
color: var(--paper);
border-bottom: 1px solid var(--line);
}
/* 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. --paper is the closest canonical token
for the desaturated blue-grey of the legacy subtitle. */
.preview-title small {
color: var(--paper);
font:
var(--step-0) / 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;
border: 1px solid var(--line);
cursor: pointer;
font:
700 10px ui-monospace,
monospace;
}
.preview button:hover {
background: var(--deep);
}
/* 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);
background: var(--red);
border-color: var(--red);
}
/* 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;
overflow: auto;
color: var(--paper);
background: var(--ink);
}
/* Code text inside the source surface. --paper is the closest canonical
token for the desaturated blue-grey of the legacy code colour. */
.preview code {
font:
var(--step-0) / 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;
color: var(--paper);
background: var(--ink);
}
.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;
color: var(--paper);
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;
color: var(--paper);
background: var(--deep);
white-space: break-spaces;
}
.markdown-preview :global(pre) {
max-height: none;
margin: 1em 0;
padding: 14px;
border: 1px solid var(--line);
}
.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);
color: var(--muted);
}
.markdown-preview :global(hr) {
border: 0;
border-top: 1px solid var(--line);
}
/* 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;
border: 1px solid var(--line);
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;
border: 1px solid var(--line);
}
.markdown-preview :global(table) {
width: 100%;
min-width: 460px;
border-collapse: collapse;
font-size: var(--step-1);
}
.markdown-preview :global(th),
.markdown-preview :global(td) {
padding: 9px 11px;
border: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.markdown-preview :global(th) {
color: var(--gold);
background: var(--deep);
}
/* 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;
border: 1px solid var(--line);
background: var(--deep);
}
.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>