Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion .claude/agents/docs-screenshot-capturer.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,42 @@ Open the browser and log in:
- Full-page captures only for page overview screenshots
- Use `browser_snapshot` to find the correct `ref` for the element you want to capture
- 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
- **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.
- **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.

**Parent-container-preferred rule (modals, dialogs, panels):**

Climb one DOM level whenever picking the tightest element would produce a cramped capture:

- 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.
- Card / wizard step: prefer the containing `<section>` / panel over the tight card.
- Toolbar / form row: prefer the panel that the row lives inside, not the row itself.

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).

**Small-element rule (≤ 600 CSS px in either dimension):**

For tiny widgets — notifications, badges, button rows, toasts, status pills — pick one of two paths:

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.
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:
```js
() => {
const el = document.querySelector('.target-notification');
el.dataset.originalStyle = el.getAttribute('style') ?? '';
el.style.cssText += 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); padding: 32px; background: var(--bai-bg-muted); z-index: 9999;';
return 'ok';
}
// …take screenshot…
() => {
const el = document.querySelector('.target-notification');
el.setAttribute('style', el.dataset.originalStyle ?? '');
delete el.dataset.originalStyle;
return 'ok';
}
```

Default to path 1. Use path 2 only when you have a specific reason to fill more of the column.

**Re-capture preflight (when overwriting an existing screenshot):**

Expand All @@ -141,9 +177,10 @@ The filename of an existing screenshot encodes a contract about what it shows. S
```
2. Open `/tmp/old.png` and identify its scope:
- Header strip: very wide, ≤300 px tall (e.g., 2358×222) → use `ref` of `[data-testid="webui-header"]`
- Modal/dialog only: medium, no chrome (e.g., 988×804) → use `ref` of `.ant-modal-wrap .ant-modal` or `[role="dialog"]`
- 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
- Sidebar segment: narrow column → use `ref` of `.ant-layout-sider`
- Wizard step / panel: capture the specific panel `ref`, not the layout root
- Small widget (≤ 600 px / notification / badge / button row) → see **Small-element rule** above
- Full page (~viewport × viewport): `fullPage: true` is acceptable
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`.

Expand Down
53 changes: 53 additions & 0 deletions packages/backend.ai-docs-toolkit/src/markdown-processor-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
normalizeRstTables,
convertIndentedNotes,
resolveMarkdownPath,
getImageDimensions,
IMAGE_SCALE_FACTOR,
} from "./markdown-processor.js";
import type { Chapter, Heading } from "./markdown-processor.js";
import {
Expand Down Expand Up @@ -331,12 +333,41 @@ function reportLinkDiagnostics(diagnostics: LinkDiagnostic[]): void {
* misses (defensive: should not happen because the pre-pass walks the
* exact same tokens marked will render).
*/
/**
* Convert a web-absolute image URL (e.g. `/sessions_all/images/foo.png`,
* the form `rewriteImagePathsForWeb` produces) back to a disk path under
* `<srcDir>/<lang>/…` so we can read the PNG header for natural-size
* auto-capping. Returns null for off-tree URLs (http(s):, leading-slash
* paths outside the lang dir, paths that escape the lang root via `..`)
* — the caller must fall back to unsized rendering in that case.
*/
function resolveWebImageDiskPath(
href: string,
srcDir: string | undefined,
lang: string | undefined,
): string | null {
if (!srcDir || !lang) return null;
if (/^(?:https?|file):\/\//.test(href)) return null;
if (!href.startsWith("/")) return null;
const langRoot = path.resolve(srcDir, lang);
const resolved = path.resolve(langRoot, "." + href);
// Defense in depth: rewriteImagePathsForWeb already drops out-of-tree
// markdown image refs, but a path like `/../other-lang/foo.png` that
// slipped past it would otherwise let the renderer read PNG headers
// outside the language root just to compute display dimensions.
if (resolved !== langRoot && !resolved.startsWith(langRoot + path.sep)) {
return null;
}
return resolved;
}

function buildWebRenderer(
chapterSlug: string,
headings: Heading[],
options?: {
chapterIndex?: number;
lang?: string;
srcDir?: string;
figureLabels?: Record<string, string>;
/** Pre-rendered Shiki HTML keyed by `${lang}\0${code}`. */
highlightedCode?: Map<string, string>;
Expand All @@ -346,6 +377,8 @@ function buildWebRenderer(
const chapterIndex = options?.chapterIndex ?? 0;
const figureLabel = getFigureLabel(options?.lang, options?.figureLabels);
const highlightedCode = options?.highlightedCode;
const srcDir = options?.srcDir;
const lang = options?.lang;

return {
heading(text: string, level: number, _raw: string): string {
Expand All @@ -359,9 +392,28 @@ function buildWebRenderer(
const titleAttr = title ? ` title="${title}"` : "";
const { cleanAlt, sizeHint } = parseImageSizeHint(text || "");

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

if (chapterIndex > 0) {
Expand Down Expand Up @@ -657,6 +709,7 @@ export async function processMarkdownFilesForWeb(
renderer: buildWebRenderer(chapterSlug, headings, {
chapterIndex,
lang,
srcDir,
figureLabels,
highlightedCode,
}),
Expand Down
15 changes: 13 additions & 2 deletions packages/backend.ai-docs-toolkit/src/markdown-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function renderShellSessionForPdf(code: string): string {
/**
* Read image dimensions from a PNG or JPEG file header.
*/
function getImageDimensions(filePath: string): { width: number; height: number } | null {
export function getImageDimensions(filePath: string): { width: number; height: number } | null {
try {
const buf = Buffer.alloc(32);
const fd = fs.openSync(filePath, 'r');
Expand Down Expand Up @@ -76,7 +76,18 @@ function getImageDimensions(filePath: string): { width: number; height: number }
return null;
}

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

function resolveImageFilePath(src: string): string | null {
try {
Expand Down
69 changes: 47 additions & 22 deletions packages/backend.ai-docs-toolkit/src/styles-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1856,48 +1856,73 @@ li > ul, li > ol {
}

/* ==========================================================================
Images (FR-2726 Phase 3 — BAI figure styling)
Images (FR-2726 Phase 3 / FR-2907 — BAI figure styling with matte)
--------------------------------------------------------------------------
The doc-image wrapper acts as a clean BAI-style figure: 1px border,
12px radius, soft shadow. Captions (when produced by the markdown
pipeline as <figcaption>) sit centered below the image. Stat cards
and other content components opt into the BAI palette via the
.bai-stat-grid / .bai-stat-card classes below.
The doc-figure wrapper acts as a "matte" frame around captured UI:
- Border + radius + soft shadow live on the FIGURE, not the IMG,
so an inner card/modal screenshot's own rounded corners don't
visually clash with the outer wrapper radius.
- Padding + a subtly off-white background provide breathing room
around element-level captures whose content is flush to the
PNG's edges (Playwright ref screenshots have no padding by
default).
- The img is bare inside the matte (no border/radius/background)
so it sits cleanly on the matte surface.

For bare <img class="doc-image"> (catalog/sample contexts that
don't wrap in <figure>), keep a minimal border + radius as a
fallback so the image still reads as a framed figure.
========================================================================== */
.doc-image {
max-width: 100%;
height: auto;
display: block;
margin: 1.25rem auto;
/* Bare-img fallback (no enclosing figure). Inside .doc-figure,
the matte rules below strip these. */
border: 1px solid var(--bai-border);
border-radius: var(--bai-radius-lg);
box-shadow: var(--bai-shadow-sm);
border-radius: var(--bai-radius);
background: var(--bai-bg);
}

figure {
/* Figure owns the outer vertical rhythm only. The image is
centered horizontally by the figure .doc-image rule below
(margin: 0 auto), and the caption is centered by figcaption's
own text-align rule — so this block intentionally avoids
setting text-align (which would only catch stray inline
content and could surprise authors). */
margin: 1.25rem 0;
figure.doc-figure {
/* Matte/frame around captured UI. Padding is the headline behavior
change here: it gives element-level Playwright captures (where
the screenshot bounding box has zero internal padding) visible
breathing room without recapture.

width: fit-content makes the matte shrink to its largest
child — the inner img (capped by the renderer's natural-size
style) or the figcaption text, whichever is wider. Without this,
the figure would default to full article-column width and a
capped 380-px notification would sit inside a column-wide matte
instead of a 380-px frame. margin: 0 auto keeps the frame
centered in the column. */
margin: 1.25rem auto;
padding: 16px;
width: fit-content;
max-width: 100%;
background: var(--bai-bg-muted);
border: 1px solid var(--bai-border);
border-radius: var(--bai-radius-lg);
box-shadow: var(--bai-shadow-sm);
Comment thread
yomybaby marked this conversation as resolved.
}

figure .doc-image {
/* Override .doc-image's default block centering only on the
vertical axis (figure owns the outer top/bottom margin); keep
auto on the horizontal axis so narrow images stay centered
instead of left-aligning to the figure's edge. */
figure.doc-figure .doc-image {
/* Inside the matte: bare image. The wrapper owns the border and
radius so a screenshot of a card/modal with its own rounded
corners does not visually compete with a second outer radius. */
margin: 0 auto;
border: 0;
border-radius: 0;
background: transparent;
}

figcaption {
text-align: center;
font-size: 12.5px;
color: var(--bai-text-3);
padding: 12px;
padding: 12px 0 0;
}

/* Stat-card grid (opt-in HTML). Authors who need a quick numeric
Expand Down
31 changes: 28 additions & 3 deletions packages/backend.ai-docs-toolkit/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,27 @@ p {
}

/* ==========================================================================
Images – figure with caption and shadow
Images – figure with caption and matte (FR-2907)
--------------------------------------------------------------------------
The matte (padding + light off-white background + outer border) mirrors
the web build's .doc-figure treatment so PDF and HTML stay visually
aligned. Border and radius live on the figure, not the inner img, so
a screenshot of an already-rounded card/modal does not show a second
competing radius around it.
========================================================================== */
.doc-figure {
margin: 16px 0;
padding: 0;
/* Shrink to the largest child (image at its capped width, or the
caption text) so a small capture is not surrounded by a printable-
column-wide matte. margin: ... auto keeps the frame centered. */
margin: 16px auto;
padding: 12px;
width: fit-content;
max-width: 100%;
page-break-inside: avoid;
text-align: center;
background: #fafafa;
border: 0.5px solid ${theme.borderColor};
border-radius: 8px;
Comment thread
yomybaby marked this conversation as resolved.
}

.doc-figure figcaption {
Expand All @@ -345,10 +359,21 @@ p {
max-width: 100%;
height: auto;
display: inline-block;
/* Bare-img fallback for catalog / non-figure contexts. Inside a
.doc-figure the matte wrapper above owns the border + radius and
this rule is visually neutralised (img sits on the matte). */
border-radius: 4px;
border: 0.5px solid #d0d0d0;
}

.doc-figure .doc-image {
/* Inside the matte the image is bare. Strips the bare-img border /
radius so the screenshot's own rounded corners (modal, card) are
not surrounded by a second mismatched radius. */
border: 0;
border-radius: 0;
}

/* ==========================================================================
Tables
========================================================================== */
Expand Down
Loading
Loading