Skip to content

Commit 1c89ff3

Browse files
committed
feat(FR-2907): add matte + natural-size cap for doc images, tighten capture guidance
- markdown-processor-web.ts: emit max-width=(pixel_width × 0.5) from PNG header so 2× zoom captures display at intended CSS size. Mirrors the PDF auto-scale logic. Explicit ![alt =<w>](url) size hints still win. - styles-web.ts / styles.ts: move border/radius/shadow from .doc-image (img) to figure.doc-figure (wrapper); add padding + neutral background so the wrapper acts as a matte. Element-level captures with content flush to the PNG edges now have visible breathing room without any recapture. Cards/modals with their own rounded corners no longer compete with a second outer radius. - markdown-processor.ts: export getImageDimensions + IMAGE_SCALE_FACTOR so the web renderer can reuse the PDF dimension/scale helpers. - SCREENSHOT-GUIDELINES.md: document the matte, the renderer-side auto size cap, the parent-container-preferred rule, and the small-element rule (≤ 600 CSS px). - docs-screenshot-capturer.md: prompt update — capture raw elements and let the matte frame them, prefer parent containers, handle small widgets via auto-cap or browser_evaluate repositioning.
1 parent 674da9d commit 1c89ff3

6 files changed

Lines changed: 185 additions & 28 deletions

File tree

.claude/agents/docs-screenshot-capturer.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,42 @@ Open the browser and log in:
129129
- Full-page captures only for page overview screenshots
130130
- Use `browser_snapshot` to find the correct `ref` for the element you want to capture
131131
- When UI has icon-only buttons, **always verify the button's accessible name** in the snapshot before clicking — e.g., "trash bin" vs download icon can look similar
132+
- **Do NOT bake padding into the screenshot.** The docs renderer adds a matte (padding + soft background + outer border + radius) around every captured image, so an element-level capture with content flush to the PNG edges still has visible breathing room in the docs. Capture the raw element; let the matte frame it.
133+
- **Do NOT try to "fix" the inner-vs-outer border-radius mismatch at capture time.** Inside the matte, the inner `<img>` is bare — the matte owns the only outer radius, so a screenshot of a card/modal with its own rounded corners sits cleanly on the matte instead of competing with a second radius.
134+
135+
**Parent-container-preferred rule (modals, dialogs, panels):**
136+
137+
Climb one DOM level whenever picking the tightest element would produce a cramped capture:
138+
139+
- Modal/dialog: prefer `.ant-modal-wrap` (the wrapper) over `.ant-modal` (the dialog itself). Use the inner element only when the dialog is large enough that its own padding already gives the captured content breathing room.
140+
- Card / wizard step: prefer the containing `<section>` / panel over the tight card.
141+
- Toolbar / form row: prefer the panel that the row lives inside, not the row itself.
142+
143+
The matte adds outer padding regardless, so picking the parent costs nothing visually but lets the capture pick up the application's intra-component spacing (and avoids clipping floating elements that overflow the inner element, like dropdown indicators or focus rings).
144+
145+
**Small-element rule (≤ 600 CSS px in either dimension):**
146+
147+
For tiny widgets — notifications, badges, button rows, toasts, status pills — pick one of two paths:
148+
149+
1. **Capture as-is and trust the renderer's auto size cap.** The web/PDF renderers read the PNG header and cap the display width at `pixel_width × 0.5` (the 2× zoom convention from SCREENSHOT-GUIDELINES). A 760×190 notification renders at ~380 CSS px wide on web and PDF, framed by the matte. This is usually correct.
150+
2. **Reposition with `browser_evaluate` for a deliberately larger capture** when the auto-capped display feels too small for the surrounding documentation context. Apply temporary CSS to move the widget to the viewport center with extra padding around it, then capture, then reset the style. Example:
151+
```js
152+
() => {
153+
const el = document.querySelector('.target-notification');
154+
el.dataset.originalStyle = el.getAttribute('style') ?? '';
155+
el.style.cssText += 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); padding: 32px; background: var(--bai-bg-muted); z-index: 9999;';
156+
return 'ok';
157+
}
158+
// …take screenshot…
159+
() => {
160+
const el = document.querySelector('.target-notification');
161+
el.setAttribute('style', el.dataset.originalStyle ?? '');
162+
delete el.dataset.originalStyle;
163+
return 'ok';
164+
}
165+
```
166+
167+
Default to path 1. Use path 2 only when you have a specific reason to fill more of the column.
132168
133169
**Re-capture preflight (when overwriting an existing screenshot):**
134170
@@ -141,9 +177,10 @@ The filename of an existing screenshot encodes a contract about what it shows. S
141177
```
142178
2. Open `/tmp/old.png` and identify its scope:
143179
- Header strip: very wide, ≤300 px tall (e.g., 2358×222) → use `ref` of `[data-testid="webui-header"]`
144-
- Modal/dialog only: medium, no chrome (e.g., 988×804) → use `ref` of `.ant-modal-wrap .ant-modal` or `[role="dialog"]`
180+
- Modal/dialog: medium, no chrome (e.g., 988×804) → prefer `ref` of `.ant-modal-wrap` (parent-container rule) and fall back to `.ant-modal-wrap .ant-modal` / `[role="dialog"]` only when the dialog has enough internal padding
145181
- Sidebar segment: narrow column → use `ref` of `.ant-layout-sider`
146182
- Wizard step / panel: capture the specific panel `ref`, not the layout root
183+
- Small widget (≤ 600 px / notification / badge / button row) → see **Small-element rule** above
147184
- Full page (~viewport × viewport): `fullPage: true` is acceptable
148185
3. After capture, sanity-check dimensions match the same order of magnitude as the old. If new dimensions differ by more than ~2× in either axis, you broke the framing — recapture with `ref`.
149186

packages/backend.ai-docs-toolkit/src/markdown-processor-web.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
normalizeRstTables,
1515
convertIndentedNotes,
1616
resolveMarkdownPath,
17+
getImageDimensions,
18+
IMAGE_SCALE_FACTOR,
1719
} from "./markdown-processor.js";
1820
import type { Chapter, Heading } from "./markdown-processor.js";
1921
import {
@@ -331,12 +333,32 @@ function reportLinkDiagnostics(diagnostics: LinkDiagnostic[]): void {
331333
* misses (defensive: should not happen because the pre-pass walks the
332334
* exact same tokens marked will render).
333335
*/
336+
/**
337+
* Convert a web-absolute image URL (e.g. `/sessions_all/images/foo.png`,
338+
* the form `rewriteImagePathsForWeb` produces) back to a disk path under
339+
* `<srcDir>/<lang>/…` so we can read the PNG header for natural-size
340+
* auto-capping. Returns null for off-tree URLs (http(s):, leading-slash
341+
* paths outside the lang dir, etc.) — the caller must fall back to
342+
* unsized rendering in that case.
343+
*/
344+
function resolveWebImageDiskPath(
345+
href: string,
346+
srcDir: string | undefined,
347+
lang: string | undefined,
348+
): string | null {
349+
if (!srcDir || !lang) return null;
350+
if (/^(?:https?|file):\/\//.test(href)) return null;
351+
if (!href.startsWith("/")) return null;
352+
return path.resolve(srcDir, lang, "." + href);
353+
}
354+
334355
function buildWebRenderer(
335356
chapterSlug: string,
336357
headings: Heading[],
337358
options?: {
338359
chapterIndex?: number;
339360
lang?: string;
361+
srcDir?: string;
340362
figureLabels?: Record<string, string>;
341363
/** Pre-rendered Shiki HTML keyed by `${lang}\0${code}`. */
342364
highlightedCode?: Map<string, string>;
@@ -346,6 +368,8 @@ function buildWebRenderer(
346368
const chapterIndex = options?.chapterIndex ?? 0;
347369
const figureLabel = getFigureLabel(options?.lang, options?.figureLabels);
348370
const highlightedCode = options?.highlightedCode;
371+
const srcDir = options?.srcDir;
372+
const lang = options?.lang;
349373

350374
return {
351375
heading(text: string, level: number, _raw: string): string {
@@ -359,9 +383,28 @@ function buildWebRenderer(
359383
const titleAttr = title ? ` title="${title}"` : "";
360384
const { cleanAlt, sizeHint } = parseImageSizeHint(text || "");
361385

386+
// Size resolution order (matches the PDF renderer):
387+
// 1. Explicit `![alt =<w>](url)` hint wins absolutely.
388+
// 2. Otherwise, read the PNG/JPEG header and cap at the natural
389+
// CSS display width (pixel width × IMAGE_SCALE_FACTOR, which
390+
// undoes the 2× zoom capture convention). This keeps small
391+
// captures (a 760-px-wide notification → 380 CSS px) from
392+
// stretching to fill the full article column.
393+
// 3. If neither path produces a size, emit no style — the
394+
// `.doc-image { max-width: 100% }` rule still prevents
395+
// horizontal overflow for legacy (non-2× / oversize) images.
362396
let styleAttr = "";
363397
if (sizeHint && sizeHint !== "auto") {
364398
styleAttr = ` style="width:${sizeHint}"`;
399+
} else if (!sizeHint) {
400+
const diskPath = resolveWebImageDiskPath(href, srcDir, lang);
401+
if (diskPath) {
402+
const dims = getImageDimensions(diskPath);
403+
if (dims) {
404+
const cap = Math.round(dims.width * IMAGE_SCALE_FACTOR);
405+
styleAttr = ` style="max-width:${cap}px"`;
406+
}
407+
}
365408
}
366409

367410
if (chapterIndex > 0) {
@@ -657,6 +700,7 @@ export async function processMarkdownFilesForWeb(
657700
renderer: buildWebRenderer(chapterSlug, headings, {
658701
chapterIndex,
659702
lang,
703+
srcDir,
660704
figureLabels,
661705
highlightedCode,
662706
}),

packages/backend.ai-docs-toolkit/src/markdown-processor.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function renderShellSessionForPdf(code: string): string {
3636
/**
3737
* Read image dimensions from a PNG or JPEG file header.
3838
*/
39-
function getImageDimensions(filePath: string): { width: number; height: number } | null {
39+
export function getImageDimensions(filePath: string): { width: number; height: number } | null {
4040
try {
4141
const buf = Buffer.alloc(32);
4242
const fd = fs.openSync(filePath, 'r');
@@ -76,7 +76,18 @@ function getImageDimensions(filePath: string): { width: number; height: number }
7676
return null;
7777
}
7878

79-
const IMAGE_SCALE_FACTOR = 0.5;
79+
/**
80+
* Display scaling factor for captured screenshots.
81+
*
82+
* Capture convention (SCREENSHOT-GUIDELINES.md): screenshots are taken
83+
* at 2× CSS zoom for sharper text, so the PNG's pixel width is roughly
84+
* twice the intended display width. Multiplying by 0.5 converts the
85+
* captured pixel width back to its intended CSS display width.
86+
*
87+
* Used by both the PDF and web renderers when no explicit size hint is
88+
* given via `![alt =<w>](url)`.
89+
*/
90+
export const IMAGE_SCALE_FACTOR = 0.5;
8091

8192
function resolveImageFilePath(src: string): string | null {
8293
try {

packages/backend.ai-docs-toolkit/src/styles-web.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1856,48 +1856,63 @@ li > ul, li > ol {
18561856
}
18571857
18581858
/* ==========================================================================
1859-
Images (FR-2726 Phase 3 — BAI figure styling)
1859+
Images (FR-2726 Phase 3 / FR-2907 — BAI figure styling with matte)
18601860
--------------------------------------------------------------------------
1861-
The doc-image wrapper acts as a clean BAI-style figure: 1px border,
1862-
12px radius, soft shadow. Captions (when produced by the markdown
1863-
pipeline as <figcaption>) sit centered below the image. Stat cards
1864-
and other content components opt into the BAI palette via the
1865-
.bai-stat-grid / .bai-stat-card classes below.
1861+
The doc-figure wrapper acts as a "matte" frame around captured UI:
1862+
- Border + radius + soft shadow live on the FIGURE, not the IMG,
1863+
so an inner card/modal screenshot's own rounded corners don't
1864+
visually clash with the outer wrapper radius.
1865+
- Padding + a subtly off-white background provide breathing room
1866+
around element-level captures whose content is flush to the
1867+
PNG's edges (Playwright ref screenshots have no padding by
1868+
default).
1869+
- The img is bare inside the matte (no border/radius/background)
1870+
so it sits cleanly on the matte surface.
1871+
1872+
For bare <img class="doc-image"> (catalog/sample contexts that
1873+
don't wrap in <figure>), keep a minimal border + radius as a
1874+
fallback so the image still reads as a framed figure.
18661875
========================================================================== */
18671876
.doc-image {
18681877
max-width: 100%;
18691878
height: auto;
18701879
display: block;
18711880
margin: 1.25rem auto;
1881+
/* Bare-img fallback (no enclosing figure). Inside .doc-figure,
1882+
the matte rules below strip these. */
18721883
border: 1px solid var(--bai-border);
1873-
border-radius: var(--bai-radius-lg);
1874-
box-shadow: var(--bai-shadow-sm);
1884+
border-radius: var(--bai-radius);
18751885
background: var(--bai-bg);
18761886
}
18771887
1878-
figure {
1879-
/* Figure owns the outer vertical rhythm only. The image is
1880-
centered horizontally by the figure .doc-image rule below
1881-
(margin: 0 auto), and the caption is centered by figcaption's
1882-
own text-align rule — so this block intentionally avoids
1883-
setting text-align (which would only catch stray inline
1884-
content and could surprise authors). */
1888+
figure.doc-figure {
1889+
/* Matte/frame around captured UI. Padding is the headline behavior
1890+
change here: it gives element-level Playwright captures (where
1891+
the screenshot bounding box has zero internal padding) visible
1892+
breathing room without recapture. */
18851893
margin: 1.25rem 0;
1894+
padding: 16px;
1895+
background: var(--bai-bg-muted);
1896+
border: 1px solid var(--bai-border);
1897+
border-radius: var(--bai-radius-lg);
1898+
box-shadow: var(--bai-shadow-sm);
18861899
}
18871900
1888-
figure .doc-image {
1889-
/* Override .doc-image's default block centering only on the
1890-
vertical axis (figure owns the outer top/bottom margin); keep
1891-
auto on the horizontal axis so narrow images stay centered
1892-
instead of left-aligning to the figure's edge. */
1901+
figure.doc-figure .doc-image {
1902+
/* Inside the matte: bare image. The wrapper owns the border and
1903+
radius so a screenshot of a card/modal with its own rounded
1904+
corners does not visually compete with a second outer radius. */
18931905
margin: 0 auto;
1906+
border: 0;
1907+
border-radius: 0;
1908+
background: transparent;
18941909
}
18951910
18961911
figcaption {
18971912
text-align: center;
18981913
font-size: 12.5px;
18991914
color: var(--bai-text-3);
1900-
padding: 12px;
1915+
padding: 12px 0 0;
19011916
}
19021917
19031918
/* Stat-card grid (opt-in HTML). Authors who need a quick numeric

packages/backend.ai-docs-toolkit/src/styles.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,22 @@ p {
317317
}
318318
319319
/* ==========================================================================
320-
Images – figure with caption and shadow
320+
Images – figure with caption and matte (FR-2907)
321+
--------------------------------------------------------------------------
322+
The matte (padding + light off-white background + outer border) mirrors
323+
the web build's .doc-figure treatment so PDF and HTML stay visually
324+
aligned. Border and radius live on the figure, not the inner img, so
325+
a screenshot of an already-rounded card/modal does not show a second
326+
competing radius around it.
321327
========================================================================== */
322328
.doc-figure {
323329
margin: 16px 0;
324-
padding: 0;
330+
padding: 12px;
325331
page-break-inside: avoid;
326332
text-align: center;
333+
background: #fafafa;
334+
border: 0.5px solid ${theme.borderColor};
335+
border-radius: 8px;
327336
}
328337
329338
.doc-figure figcaption {
@@ -345,10 +354,21 @@ p {
345354
max-width: 100%;
346355
height: auto;
347356
display: inline-block;
357+
/* Bare-img fallback for catalog / non-figure contexts. Inside a
358+
.doc-figure the matte wrapper above owns the border + radius and
359+
this rule is visually neutralised (img sits on the matte). */
348360
border-radius: 4px;
349361
border: 0.5px solid #d0d0d0;
350362
}
351363
364+
.doc-figure .doc-image {
365+
/* Inside the matte the image is bare. Strips the bare-img border /
366+
radius so the screenshot's own rounded corners (modal, card) are
367+
not surrounded by a second mismatched radius. */
368+
border: 0;
369+
border-radius: 0;
370+
}
371+
352372
/* ==========================================================================
353373
Tables
354374
========================================================================== */

packages/backend.ai-webui-docs/SCREENSHOT-GUIDELINES.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,40 @@ The resulting PNG will be ~2× the natural CSS dimensions in pixels (e.g., a 450
114114
### Focused Cropping
115115

116116
- **Prefer element-level screenshots over full-page captures** when documenting a specific feature or interaction
117-
- **For modals and dialogs: capture only the modal element itself**, not the full page. Use the modal's DOM element as the screenshot target (e.g., `.ant-modal-wrap .ant-modal`, `[role="dialog"]`)
117+
- **For modals and dialogs: prefer the modal wrapper, not the modal itself.** Use `ref` of `.ant-modal-wrap .ant-modal` (or `[role="dialog"]`) only when the inner element is large enough that its own padding gives the captured content breathing room. Otherwise, capture the surrounding wrapper element (e.g., `.ant-modal-wrap`, a `<section>` panel) so the screenshot picks up the application's natural spacing around the dialog.
118118
- Use `ref` parameter in `browser_take_screenshot` to capture only the relevant element (e.g., a modal, a toolbar section, a specific panel)
119119
- Full-page captures are appropriate for page overview screenshots, but for feature-specific documentation, crop to the relevant area so users can clearly identify what is being described
120120
- Include just enough surrounding context for users to orient themselves
121121

122+
### Padding & Framing
123+
124+
The docs renderer wraps every captured image in a "matte" frame — a soft off-white background with padding, an outer border, and rounded corners. The matte provides **visual breathing room** even when the captured PNG itself has no internal padding, so a tight element-level capture no longer reads as cramped. Two implications for authors:
125+
126+
- **Border-radius on the matte is the only outer radius**. Inside the matte, the inner `<img>` is bare (no border, no radius). This is intentional: if a captured screen has its own rounded corners (a modal, a card), they sit cleanly on the matte and no longer compete with a second outer radius. **Do not** try to "fix" inner radius mismatches at capture time — capture the raw element and let the matte frame it.
127+
- **You do not need to bake padding into the screenshot.** The matte gives every image equal breathing room. The capture should still avoid clipping content (don't crop a button's last pixel column), but you do not need to expand the bounding box just to leave whitespace.
128+
129+
#### Parent-container-preferred rule
130+
131+
When in doubt about which element to capture, climb one level: `.ant-modal-wrap` over `.ant-modal`, a containing `<section>` over a tightly-bounded card, a wizard step's outer panel over the inner form. The renderer's matte adds outer padding regardless, so picking the parent costs nothing visually but gives the capture access to the application's own intra-component spacing.
132+
133+
#### Small-element rule
134+
135+
When the target element's bounding box is **≤ 600 CSS px** in either dimension (notifications, badges, button rows, toasts, small status pills), an unmodified element capture will produce a tiny PNG that the renderer would otherwise display proportionally small. Two acceptable handling paths:
136+
137+
1. **Capture as-is and trust the auto size cap.** The renderer reads the PNG header and caps the display width at `pixel_width × 0.5` (the 2× zoom convention). A 760×190 notification capture will render at ~380 CSS px wide on the web and PDF, framed by the matte — usually fine.
138+
2. **Reposition the element on a neutral surface before capture** when you want the capture to *fill* the docs column. Use `browser_evaluate` to apply temporary CSS — e.g., move a floating notification to the viewport center with extra padding so the bounding box is larger and includes deliberate whitespace around the widget. Reset the style after capture.
139+
140+
Pick path (2) only when the auto-capped display feels too small for the surrounding documentation context. Most small-element captures look correct with path (1).
141+
142+
#### Image size caps (renderer-side, automatic)
143+
144+
The web and PDF renderers automatically size images based on the captured PNG's pixel dimensions (assuming the 2× zoom convention):
145+
146+
- Display width = `pixel_width × 0.5`, capped at the article column width.
147+
- Explicit overrides via the `![alt =<width>](url)` size hint take precedence (`=380px`, `=50%`, `=auto`). Use these sparingly — only when the automatic cap produces a visibly wrong result.
148+
149+
Authors do not normally need to think about this. Capture at 2× zoom, the renderer handles the display size.
150+
122151
### Match the Existing Screenshot's Framing (for re-captures)
123152

124153
When **replacing an existing screenshot** (same filename), the new image MUST match the previous image's framing scope. The filename encodes a contract about what the image shows.
@@ -141,9 +170,10 @@ Then capture the new screenshot at the **same scope**:
141170
| Old image scope | Capture method |
142171
|---|---|
143172
| Header strip (e.g., `header.png`) | `ref` of `header`/top-bar element only — never `fullPage: true` |
144-
| Modal/dialog only | `ref` of `.ant-modal-wrap .ant-modal` or `[role="dialog"]` |
173+
| Modal/dialog | `ref` of `.ant-modal-wrap` (wrapper, preferred — see [Parent-container-preferred rule](#parent-container-preferred-rule)) or `.ant-modal-wrap .ant-modal` / `[role="dialog"]` |
145174
| Sidebar segment | `ref` of the sidebar element |
146175
| Page region (e.g., a step in a wizard) | `ref` of the specific panel, not the whole layout |
176+
| Small widget (notification, badge, ≤ 600 CSS px) | See [Small-element rule](#small-element-rule) — capture as-is and trust the auto size cap, or reposition with `browser_evaluate` for full-column rendering |
147177
| Full page overview | `fullPage: true` is acceptable |
148178

149179
**Anti-pattern observed in PR #6708**: `header.png` was 2358×222 (header strip only) on `main`, recaptured as 2880×1800 (full viewport including sidebar + main content + breadcrumbs). The filename promises "header" but the new image shows everything. **Always run the preflight above before re-capturing.**

0 commit comments

Comments
 (0)