
Cocoindex Diagrams
- 1 installs
- 11.2k repo stars
- Updated August 4, 2026
- cocoindex-io/cocoindex
Author and maintain technical diagrams using component-based primitives with automated preview validation.
About
Creates inline SVG diagrams for documentation using shape-semantic primitives (DataBox, ProcessingComponent, FlowArrow). Use when authoring structured diagrams for docs with consistent styling, state annotations, and animation.
- Inline SVG diagrams as Astro components using semantic primitives
- Workflow with headless Chrome preview, palette unification, and layout pattern discipline
Cocoindex Diagrams by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,609 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cocoindex-io/cocoindex --skill cocoindex-diagramsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 11.2k |
| Last updated | August 4, 2026 |
| Repository | cocoindex-io/cocoindex ↗ |
What it does
Author and maintain technical diagrams using component-based primitives with automated preview validation.
Files
CocoIndex Docs Diagrams
Inline SVG diagrams in the docs are authored as Astro components under docs/src/components/diagrams/, composed from shape-semantic primitives that share a palette and styling. Static .svg files exported from design tools are legacy — always build new diagrams with the primitives.
When this skill applies
Apply this skill when:
- Adding a new diagram to a docs page (
.mdxunderdocs/src/content/docs/). - Editing or replacing an existing diagram component.
- Reviewing a rendered diagram for layout or style issues.
- Reworking a legacy
<img src="/docs/img/...svg">reference.
Do not apply for:
- Marketing-site diagrams under
cocoindex.github.io/(different style, different primitives). - General SVG editing outside
docs/src/components/diagrams/.
The canonical reference
Read docs/src/components/diagrams/README.md first. It documents the shape vocabulary, palette vars, shared CSS classes, animation conventions, directory layout, and MDX embedding pattern. This SKILL.md assumes that reference is current — do not duplicate it here.
Then consult the three references bundled with this skill for the session-specific knowledge that isn't in the project README:
- references/workflow.md — the preview loop (headless Chrome + base-path gotcha), how to crop and view output, rebuild discipline.
- references/pitfalls.md — the traps that repeatedly cost iterations (dg-step opacity, color unification, magic-number insets, labels without auto-wrap).
- references/layout-patterns.md — layout idioms (horizontal arrows, symmetric padding, compactness, bindings vs arrows).
Core procedure
When asked to create or edit a diagram, proceed in this order:
1. Read the reference materials
Load docs/src/components/diagrams/README.md plus the three references above. Skim, don't memorize — refer back when designing.
2. Design the layout before writing code
Sketch positions with concrete numbers in a config object at the top of the .astro file. Prefer absolute coordinates with a viewBox sized to content, and use named constants for box dimensions and column offsets.
Derive container size from content, not the other way around. Start from inner widths, gaps, and pad; compute APP_W = sum(cols) + (n-1) * GAP + 2 * APP_PAD_X. Downstream siblings (Target System, Drive Folder right) reference APP.x + APP_W, not a hardcoded x. Same for vertical: pick row centers so top-pad == bottom-pad. This keeps padding balanced on all four sides as you iterate. See references/layout-patterns.md.
3. Compose from primitives
Use the shape-semantic primitives:
| Shape | Meaning | Primitive |
|---|---|---|
| Sharp rectangle | Data (file, chunk, row) | DataBox |
| Round-cornered rectangle | Subsystem / logic | LogicBox (memoized + status props, slot) |
| Cream container with header | A Processing Component | ProcessingComponent (slot in local coords; memoized + status) |
| Peach container with header | A CocoIndex App | AppContainer (slot in local coords) |
| Bullet (flat left, rounded right) | Target state | TargetBullet |
| Animated dashed arrow with head | Flow / causation | FlowArrow |
| Static dashed line, no head | Binding / identity | Connector |
Delta state + cache status annotations (orthogonal, on any shape):
| Prop | Values | Visual |
|---|---|---|
state | new, updated, removed, changed, idle | Palm-green / gold / pink / thin-pink fill (state cascade is prevented by direct-child CSS combinator — see pitfalls). |
status | cache-ready, refreshing | Top-left badge. Cache-ready writes its check in on hover; refreshing spins on hover. Both suppressed when state="removed". |
highlight | true | Thick palm border for "currently-discussed element" |
New shape primitives should compose on top of `ShapeGroup`, not re-implement the wrapper <g> + state class + title + badge logic.
4. Render labels with foreignObject
All label-bearing primitives (DataBox, LogicBox, ProcessingComponent, TargetBullet) use <foreignObject> with a flex-centered <div class="dg-fo-label"> so the browser auto-wraps labels against the box width. Just pass label="..." — never pre-split into manual lines.
5. Preview and verify before reporting done
Run the preview script bundled with this skill:
scripts/preview.sh <docs-slug>
# Example: scripts/preview.sh programming_guide/core_conceptsThe script handles: building Astro, mirroring dist/ into a temp path that matches the site's /docs base URL (critical — see pitfalls), spinning up a local HTTP server on a free port, screenshotting with headless Chrome, and printing paths to PNG crops. Read the PNGs with the Read tool to self-check before reporting the diagram done.
Do NOT report a diagram complete based solely on "the build compiled". Always render and look. Small issues like overlapping labels, dimmed rows, or wrong arrow styles are invisible from code alone.
6. Iterate on visual feedback
Common iterations (see pitfalls for full details):
- Elements faded at ~35% opacity →
dg-stepleaking onto static rows; remove it. - Boxes rendering black in the preview → CSS base-path mismatch (not a code bug).
- Labels overlapping →
<foreignObject>not used, or box too narrow for the string. - Arrows to wrong targets → mixing absolute vs. PC-local coordinate space.
MDX embedding pattern
Import with an absolute path from the site root so it works at any docs depth:
---
title: Core Concepts
---
import ComponentWithChunks from '/src/components/diagrams/concepts/ComponentWithChunks.astro';
## Processing Component
<ComponentWithChunks />Discipline
- Shape carries meaning. Pick primitives by semantics, not "what looks right".
- No manual line-splitting. If a label overflows, widen the box or shorten the label.
- Absolute coords for outer layout; local coords inside slotted containers.
ProcessingComponent's slot renders children with (0, 0) = container top-left. - All flow arrows use the same color. Default coral;
variant="palm"/"muted"only when semantically meaningful. - Bindings are silent.
Connector(static, dashed, no arrowhead) for "X is bound to Y" — Drive Folder → file, vector → Vector Database. - Padding balanced on ALL sides. Left == right inside every container, and top == bottom. Derive the container's width/height from its content (
APP_W = sum(cols) + (n-1)*GAP + 2*APP_PAD_X); downstream siblings referenceAPP.x + APP_W, not a hardcoded x. See references/layout-patterns.md "Balanced padding on all sides". - Prefer compactness. Diagrams should read well at docs column width; stretch only when a visual story demands it.
- Never use `dg-step` for static content. It's opacity-35% by default and only lights up on hover — reserved for progressive-reveal narratives (e.g. "panel 1 → 2 → 3").
- All animations are idle by default, active on `.dg-root:hover`. Flow-drift, delta-pulse, check-draw, refresh-spin — each gated the same way. No always-on motion on a static page. When adding a new
@keyframesrule, register it in the@media (prefers-reduced-motion: reduce)block at the bottom ofdiagrams.css. - State rules in CSS use the direct-child combinator (`.dg-state-X > .dg-box`), not descendant. A state class on a container (PC with
pcState="updated") would otherwise cascade into every nested.dg-box. Each shape primitive owns its own state on its ownShapeGroup-wrapped<g>. - Compose new shape primitives on `ShapeGroup`, not from scratch. It owns the wrapper
<g>, state/highlight class composition,<title>deleted</title>tooltip, and theMemoMark/StatusBadgebadges (with suppression when removed). Never re-implement any of that. - Multi-state scenario diagrams take a `scenario` prop, inlined in the MDX next to the prose. Don't create a
.astrowrapper per scenario — the scenario is content, not a reusable component. See the<ComponentWithChunks scenario={{ rows: [...] }} />pattern.
Iteration expectations
Expect 2–4 preview cycles for any non-trivial diagram. After the first render, opacity/color/overlap issues almost always surface that weren't visible from code inspection. Budget for the loop — don't try to land the diagram in one shot.
Starter template
See assets/starter.astro for a minimal shape-semantic diagram skeleton. Copy and adapt rather than writing from scratch.
---
// ─── starter.astro ─────────────────────────────────────────────────
// Minimal shape-semantic diagram skeleton. Copy into
// docs/src/components/diagrams/concepts/MyDiagram.astro and adapt.
// Demonstrates: config-first layout, a ProcessingComponent with slot
// children in local coords, horizontal arrows, a binding connector.
import DiagramFrame from '../primitives/DiagramFrame.astro';
import ProcessingComponent from '../primitives/ProcessingComponent.astro';
import LogicBox from '../primitives/LogicBox.astro';
import DataBox from '../primitives/DataBox.astro';
import TargetBullet from '../primitives/TargetBullet.astro';
import FlowArrow from '../primitives/FlowArrow.astro';
import Connector from '../primitives/Connector.astro';
// ─── Layout ─────────────────────────────────────────────────────────
// Derive sizes from content so padding stays balanced on all sides.
const PC_PAD = 24;
const LEFT_W = 120, LEFT_H = 40;
const RIGHT_W = 120, RIGHT_H = 40;
const INNER_GAP = 60;
const PC_W = LEFT_W + INNER_GAP + RIGHT_W + PC_PAD * 2; // 348
const PC_H = 100;
const SRC_W = 110, SRC_H = 60;
const TGT_W = 110, TGT_H = 40;
const SRC_TO_PC = 40, PC_TO_TGT = 40;
const VB_W = 20 + SRC_W + SRC_TO_PC + PC_W + PC_TO_TGT + TGT_W + 20; // 548
const CY = 110;
const SRC = { x: 20, y: CY - SRC_H / 2, w: SRC_W, h: SRC_H };
const PC = { x: SRC.x + SRC.w + SRC_TO_PC, y: CY - PC_H / 2, w: PC_W, h: PC_H };
const TARGET = { x: PC.x + PC.w + PC_TO_TGT, y: CY - TGT_H / 2, w: TGT_W, h: TGT_H };
const LEFT_DX = PC_PAD;
const RIGHT_DX = PC_PAD + LEFT_W + INNER_GAP;
---
<DiagramFrame
viewBox={`0 0 ${VB_W} ${VB_H}`}
maxWidth={720}
title="My diagram"
desc="Describe what the diagram conveys."
>
{/* Left data box */}
<DataBox x={SRC.x} y={SRC.y + SRC.h / 2 - 14} w={SRC.w} h={28} label="input" />
{/* Flow into the PC */}
<FlowArrow d={`M ${SRC.x + SRC.w} ${SRC.y + SRC.h / 2} L ${PC.x} ${PC.y + PC.h / 2}`} />
<ProcessingComponent x={PC.x} y={PC.y} w={PC.w} h={PC.h} memoized={true}>
{/* coords here are relative to PC's top-left */}
<LogicBox x={LEFT_DX} y={PC.h / 2 - 20} w={120} h={40} label="Transform" />
<FlowArrow d={`M ${LEFT_DX + 120} ${PC.h / 2} L ${RIGHT_DX} ${PC.h / 2}`} />
<LogicBox x={RIGHT_DX} y={PC.h / 2 - 20} w={120} h={40} label="Emit" memoized={true} />
</ProcessingComponent>
{/* Binding to the target — no arrow, dashed */}
<Connector
d={`M ${PC.x + PC.w} ${PC.y + PC.h / 2} L ${TARGET.x} ${TARGET.y + TARGET.h / 2}`}
dashed={true}
/>
{/* Target state */}
<TargetBullet x={TARGET.x} y={TARGET.y + TARGET.h / 2 - 12} w={TARGET.w} h={24} label="output" />
</DiagramFrame>
Layout patterns
Idioms for composing legible diagrams from the shape-semantic primitives.
Config block at top
Every non-trivial diagram starts with a config block of named constants. Don't sprinkle magic numbers.
---
const VB_W = 980, VB_H = 380;
const TOP_Y = 30, TOP_H = 330;
const DRIVE = { x: 20, y: TOP_Y, w: 110, h: TOP_H };
const APP = { x: 160, y: TOP_Y, w: 664, h: TOP_H };
const VDB = { x: 844, y: TOP_Y, w: 110, h: TOP_H };
const PC_W = 510, PC_H = 128;
const ROW1_CY = 130, ROW2_CY = 268;
// ...
---Changing one constant re-flows the diagram; no hunting through JSX.
Horizontal arrows by default
When source and destination both have a vertical extent, route arrows horizontally rather than angled. Looks more polished; reads faster.
To enable this, align sibling containers (Drive Folder, CocoIndex App, Vector Database) at the same y and height. Then row-specific arrows enter/exit at the row's y-coordinate and stay level.
<FlowArrow d={`M ${DRIVE.x + DRIVE.w} ${row.rowCY} L ${FILE_X} ${row.rowCY}`} />Bindings vs flow
- FlowArrow: causal flow (A produces B, A transforms to B). Coral
dashed arrow with arrowhead and subtle drift animation.
- Connector: binding / identity (A is bound to B, A writes into B).
Static, dashed, no arrowhead.
Concrete rules:
- Drive Folder → file: binding (the file IS from the folder).
Connector. - file → Processing Component: flow (the PC processes the file).
FlowArrow. - Split → chunk: flow.
FlowArrow. - chunk → Embed: flow.
FlowArrow. - Embed → vector: flow.
FlowArrow. - vector → Vector Database: binding (the vector IS written to the DB).
Connector.
Balanced padding on all sides
Inside every container — AppContainer, ProcessingComponent, or any LogicBox with a slot — keep padding equal on all four sides: left == right, and top == bottom. Asymmetric padding is the single most common visual smell. The fix is always the same: derive the container size from content, not the other way around.
Horizontal: derive container width from content
Wrong (guess container width, scatter content inside):
const APP = { x: 140, y: TOP_Y, w: 700, h: TOP_H }; // guessed
const FILE_DX = 24;
const PC_DX = FILE_DX + FILE_W + 34;
const PC_W = 360;
// Right padding: 700 - (PC_DX + PC_W) = 256. Way more than left 24.Right (compute container width from what it actually holds):
const APP_PAD_X = 24;
const FILE_W = 62;
const PC_W = 360;
const FILE_TO_PC_GAP = 34;
const APP_W = FILE_W + FILE_TO_PC_GAP + PC_W + APP_PAD_X * 2;
// APP_W = 484. Left pad 24 = right pad 24 by construction.
const APP = { x: 140, y: TOP_Y, w: APP_W, h: TOP_H };
const DRIVE_R = { x: APP.x + APP_W + 20, y: TOP_Y, w: 100, h: TOP_H };For a horizontal content row:
[pad_x] col1 [gap] col2 [gap] … coln [pad_x]Set container_w = sum(cols) + (n-1) * gap + 2 * pad_x. Then left == right by construction; adding or removing columns stays balanced without re-tuning.
Vertical: same rule, stacked axis
If a container has a single content row, vertically center it: content_y_top = (container_h - content_h) / 2.
If it has multiple rows (e.g. two ProcessingComponents inside an App), choose ROW1_CY and ROW2_CY so the first row's top padding equals the last row's bottom padding:
top_pad == row1_top - container_top == container_bottom - rowN_bottomDon't forget the container's own top header label (~28px high) eats into the usable top area — count it toward "top padding" or keep row content below it.
Downstream siblings reference the derived width
When the container sits among siblings (Drive Folder, App, Vector Database), place each sibling relative to APP.x + APP_W, not a hard- coded x. Otherwise, changing inner layout silently breaks the outer spacing.
const SIBLING_GAP = 20;
const TGT = { x: APP.x + APP_W + SIBLING_GAP, y: TOP_Y, w: 100, h: TOP_H };
const VB_W = TGT.x + TGT.w + 20; // viewBox also derivedThis prevents the "content hugs left edge, right side floats in space" look seen when container widths are guessed independently of content.
Compactness
Default to compact. The docs content column is ~720px, so most diagrams render at maxWidth between 720 and 960. Extra vertical whitespace between sibling elements distances them conceptually — only add padding when the spacing conveys meaning.
When stacking two rows (e.g., two Processing Components), the inter-row gap should be small enough that they feel like variations of the same thing, not separate ideas.
Slotted containers use local coordinates
ProcessingComponent places its rect at translate(x y) and renders <slot /> inside. Children in the slot use coordinates relative to the container's top-left.
<ProcessingComponent x={PC_X} y={row.pcY} w={PC_W} h={PC_H} memoized={true}>
{/* (0,0) here = container's top-left */}
<LogicBox x={PC_PAD} y={(PC_H - SPLIT_H) / 2} ... />
</ProcessingComponent>Anything that visually crosses out of the container (e.g., a vector → Vector Database connector) must be drawn OUTSIDE the ProcessingComponent tag, in absolute coords.
Two coord spaces in one loop
A typical row iteration touches both:
{rows.map((row) => (
<g>
{/* absolute coords: outer layout */}
<Connector d={`M ${DRIVE.x + DRIVE.w} ${row.rowCY} L ${FILE_X} ${row.rowCY}`} />
<DataBox x={FILE_X} y={row.rowCY - FILE_H/2} ... />
<FlowArrow d={`M ${FILE_X + FILE_W} ${row.rowCY} L ${PC_X} ${row.rowCY}`} />
<ProcessingComponent x={PC_X} y={row.pcY} w={PC_W} h={PC_H} memoized={true}>
{/* local coords: inside the PC */}
<LogicBox x={PC_PAD} y={(PC_H - SPLIT_H)/2} ... />
{/* ... */}
</ProcessingComponent>
{/* absolute coords again: exits from PC to outside destinations */}
{chunkCYs.map((cy) => (
<Connector d={`M ${PC_X + VECT_DX + VECT_W} ${cy} L ${VDB.x} ${cy}`} dashed={true} />
))}
</g>
))}Keep external-space and internal-space blocks visually separated in the source for readability.
Row centers drive child positions
Define ROW_CY for each row up front, then derive file y, chunk y, embed y, etc. from it. This way shifting a row vertically only requires changing ROW_CY, not every child.
Memo marks are declarative
Never place <MemoMark> directly unless you're writing a brand new container primitive. Instead pass memoized={true} to LogicBox or ProcessingComponent, and the primitive renders the mark at the top- right corner with consistent inset and size.
Delta state + cache status
Every shape primitive (DataBox, LogicBox, TargetBullet, ProcessingComponent) accepts two orthogonal annotation props:
state— one ofnew(palm-green fill, inserted),updated
(gold fill, changed in place), removed (pink fill + struck-through label + hover tooltip "deleted"), or changed (thin pink stroke, for fingerprint-invalidation). Default idle.
status—cache-ready(green check badge, top-left) or
refreshing (coral spinning arrow, top-left). Only valid on LogicBox / ProcessingComponent.
The two can combine: e.g. a memoized function still being re-run would have status="refreshing"; one that hit cache would have status="cache-ready". A state="removed" element auto-suppresses both MemoMark and StatusBadge — nothing to memoize, nothing to cache-check.
The scenario pattern for multi-state diagrams
Don't create one .astro wrapper per "what if" scenario. Instead, parametrize the base diagram with a scenario prop and inline the scenario in the .mdx right next to the prose that describes it:
<ComponentWithChunks memoized={true} scenario={{ rows: [
{ file: 'a.md', pcStatus: 'cache-ready', chunks: [
{ label: 'chunk1', vectorLabel: 'vector1', embedStatus: 'cache-ready' },
{ label: 'chunk2', vectorLabel: 'vector2', embedStatus: 'cache-ready' },
] },
{ file: 'b.md', fileState: 'updated', pcStatus: 'refreshing', pcState: 'updated', chunks: [
{ label: 'chunk3', vectorLabel: 'vector3', embedStatus: 'cache-ready' },
{ label: 'chunk4', vectorLabel: 'vector4', state: 'removed' },
{ label: 'chunk5', vectorLabel: 'vector5', state: 'new' },
] },
]}} />The scenario is content (which file changed? which embed was cached?), not a reusable component. Inlining it in the MDX keeps the "what am I showing" and "what am I saying" together, so future edits don't drift.
Build new shape primitives on ShapeGroup
If you find yourself writing a new shape primitive, compose it on top of ShapeGroup (not from scratch). ShapeGroup absorbs the wrapper <g transform="translate(x y)">, base-class + state + highlight class composition, the native <title>deleted</title> tooltip for removed elements, and the optional MemoMark / StatusBadge rendering with removed-state suppression. Each primitive just slots in its own shape + label.
Pitfalls that cost iterations
A record of mistakes encountered while building the current diagram set. Each entry: the symptom, the cause, and the fix.
All diagram boxes render as solid black
Symptom: Diagrams look like black rectangles in screenshots; text is dimly visible but all fills are black.
Cause: The docs site uses base: '/docs' in astro.config.mjs, so built HTML references CSS at /docs/_astro/*.css. A naïve python3 -m http.server from dist/ serves CSS at /_astro/... instead, returning 404. With no stylesheet, var(--cream), var(--coral), etc. are undefined; SVG fill falls back to black.
Fix: Serve from a parent dir containing a docs/ symlink or copy of dist/, so /docs/_astro/... resolves. The scripts/preview.sh script handles this automatically.
Rows/content faded at 35% opacity
Symptom: Multiple rows in a diagram all appear dim; only show at full opacity when the user hovers.
Cause: dg-step + dg-step-N classes. These are designed for progressive-reveal narratives (e.g., a 3-panel "step 1 → 2 → 3") where each step should fade in on hover with stagger. If applied to parallel rows that should all be visible statically, they fade by default.
Fix: Remove dg-step from the <g> wrapping the row. Only use dg-step when hover-driven reveal is intentional.
Labels overflow / clip inside narrow boxes
Symptom: "Split into chunks" sticks out past the box edges.
Cause: Early versions used SVG <text> which does not wrap. Manual line-splitting via lines={['Split into', 'chunks']} worked but was awkward.
Fix: All label-bearing primitives (DataBox, LogicBox, ProcessingComponent, TargetBullet) now use <foreignObject> with a flex-centered <div class="dg-fo-label">. The browser wraps against the box width. Just pass label="...".
Magic-number insets scatter across call sites
Symptom: <MemoMark x={PC_X + 14} y={row.pcY + 4} size={12} /> and <MemoMark x={embedX + 6} y={mid - EMBED_H / 2 - 2} size={10} /> — different offsets, different sizes, invisible coupling to container dimensions.
Cause: Inset values baked into call sites instead of the primitive.
Fix: The primitive owns its inset + size. Callers pass the container's reference corner (top-right for MemoMark). The primitive internally does translate(x - INSET_X - w, y). Same for ProcessingComponent's header and memo mark — the container primitive knows its own dimensions and handles all internal positioning.
Source vs target color accidentally diverged
Symptom: Drive Folder in palm-green, Vector Database in coral — suggests a distinction that doesn't exist in CocoIndex.
Cause: Early LogicBox had variant="source" / variant="target" modifiers with different fills. Kept around from homepage conventions that don't apply to docs diagrams.
Fix: One neutral cream + maroon fill for all non-container logic boxes. Shape (not color) carries the semantic distinction — TargetBullet is visually different from LogicBox because of its bullet shape, not because of color. Only App containers get the peach tint for visual grouping.
Arrows at different heights hit the wrong target
Symptom: vector1 at y=93 points to Vector Database, but the arrow line crosses over vector2 at y=147 because both converge to VDB center.
Cause: Drawing all arrows to the single y-center of the destination box.
Fix: Keep arrows horizontal — draw each to the destination's left edge at the source's y-coordinate. This only works when destinations are tall enough to accept multiple horizontal entries. See layout-patterns.md.
Arrowheads on binding lines
Symptom: Drive Folder → file and vector → Vector Database both have arrowheads and moving dashes, making them feel like causal flow.
Cause: Using FlowArrow everywhere instead of distinguishing flow from binding.
Fix: Connector (static, dashed, no arrowhead) for bindings; FlowArrow only for causal flow. A binding is "X is bound to Y" — Drive Folder → file (the file IS from the folder), vector → Vector Database (the vector IS written to the DB).
MemoMark fills solid black, looks too heavy
Symptom: Memo marks render as dark solid maroon ribbons that visually dominate small boxes.
Fix: Use coral outline + translucent coral fill (fill: color-mix(in oklab, var(--coral) 22%, transparent)), not a solid color. Reads over any background and matches the CocoIndex accent palette.
Asymmetric padding inside a container
Symptom: Content hugs the left side of an App/ProcessingComponent container with a big empty gap on the right. Or the first row hugs the top while the last row has a large gap below.
Cause: Hardcoding APP.w / APP.h to a round number and then placing content starting at a small APP_PAD. The content fits but leaves whatever remainder on the right/bottom.
Fix: Derive the container size from the content, not the other way around:
const APP_PAD_X = 24;
const APP_W = FILE_W + GAP + PC_W + APP_PAD_X * 2; // left pad == right pad
const APP = { x: 140, y: TOP_Y, w: APP_W, h: TOP_H };Any downstream sibling (Target System, Drive Folder right) must then be positioned relative to APP.x + APP_W, not a hardcoded x. Same principle vertically: compute row y-centers so top-pad == bottom-pad.
See layout-patterns.md "Balanced padding on all sides" for the full idiom.
State class on a container cascades into its children
Symptom: A ProcessingComponent with pcState='updated' paints every nested .dg-box (chunks, embeds, vectors) gold — even the ones explicitly left at state='idle'.
Cause: The state CSS rules were written with the descendant combinator: .dg-state-updated .dg-box { stroke: gold; }. The container's state class matches, and its descendant .dg-box elements inherit the style.
Fix: Use the direct-child combinator > so the rule only applies to the box element on the same wrapper <g> that owns the state:
.dg-state-new > .dg-box { ... }
.dg-state-updated > .dg-box { ... }
.dg-state-removed > .dg-box { ... }
.dg-state-changed > .dg-box { ... }Each shape primitive (built on ShapeGroup) has its own wrapper <g> with its own state class and its own direct-child .dg-box. With >, states never cascade.
New state visuals that don't read against a stateful container
Symptom: A state="new" chunk inside a state="updated" PC looks identical to its sibling idle chunks — palm-green stroke is invisible against gold-tinted PC background.
Cause: Default stroke-width (1.4) makes colored state strokes too thin to pop against a tinted parent.
Fix: The dg-state-* rules in diagrams.css bump stroke-width to 2.2 for all delta states, and the hover dg-delta-pulse keyframes push it to 4.2 for extra visibility. If you add a new delta state, match the pattern.
Always-on animations distract on static pages
Symptom: A spinning icon (refresh badge) keeps spinning even when the user isn't looking at that diagram — makes the page feel busy.
Fix: Every animation in diagrams.css is idle by default, active only on `.dg-root:hover`. Includes dg-flow, dg-pulse, dg-delta-pulse, dg-spin (refresh badge), dg-check-draw (cache-ready check). When adding a new animation, gate it the same way — and register it in the @media (prefers-reduced-motion: reduce) override at the bottom of the stylesheet.
Reporting complete without looking
Symptom: "The build succeeded, diagram done." Then user screenshots show overlaps / black boxes / faded content.
Fix: Always run scripts/preview.sh and Read the PNG before reporting. A clean npm run build proves only that the Astro components compile.
Preview-and-verify workflow
Diagrams are visual artifacts. "The build compiled" is not sufficient evidence that they render correctly — always screenshot and look.
The base-path gotcha
The docs site configures base: '/docs' in astro.config.mjs. As a result, the built HTML references CSS at /docs/_astro/.... If the local preview server is rooted at dist/ without mirroring that prefix, the CSS returns 404, all CSS variables are undefined, and every SVG fill: var(--cream) resolves to black.
Symptom: All diagram boxes render as solid black rectangles, but text labels are visible. This is NOT a code bug. Fix the server, not the diagram.
The scripts/preview.sh script handles this correctly by rsyncing the dist/ output into a docs/ subdirectory before serving.
Running the preview
scripts/preview.sh <docs-slug> [crop-y-top]
# Example: scripts/preview.sh programming_guide/core_concepts
# Example: scripts/preview.sh programming_guide/core_concepts 3300The script:
1. Runs npm run build inside docs/. 2. Rsyncs docs/dist/ → a temp directory under docs/. 3. Kills any stale server on port 8765, starts fresh on next free port. 4. Screenshots the page with headless Chrome at 1400x5200, scale 1. 5. Saves full-page PNG + an optional crop. 6. Prints the paths.
Read the PNGs back with the Read tool. Claude Code is multimodal — it can see and critique the rendered output.
Locating a specific diagram in the page
Full-page screenshots are tall. To find your diagram:
1. Start with a wide crop covering a plausible range:
magick /tmp/dg-preview/full.png -crop 1400x500+0+3300 /tmp/dg-preview/crop.png2. Read the crop. If the target diagram isn't there, adjust the y-offset (e.g. +2500, +3500, +4000). 3. For layout scrutiny, crop tight and omit surrounding prose.
Iteration loop
1. Edit the .astro file. 2. Run scripts/preview.sh <slug>. 3. Read the PNG. 4. Compare with intent; identify specific issues (overlap, opacity, wrong shape, wrong color). 5. Edit again, repeat.
Expect 2–4 cycles for any non-trivial diagram. First render almost always surfaces issues invisible from code (e.g., labels overlapping icons).
When to skip preview
Minor text/label edits that don't affect layout (e.g. fixing a typo in a label string) can skip preview if the diagram otherwise passed a recent review. Everything that touches coordinates, shape choices, or new primitives needs a preview.
Cleanup
The preview script writes to /tmp/dg-preview/ and starts a local HTTP server. Both clean up on next run. If a port gets stuck:
lsof -ti:8765 | xargs kill -9#!/usr/bin/env bash
# preview.sh — build the CocoIndex docs site, serve it at the correct
# /docs base path, screenshot a page with headless Chrome, and print
# paths to PNG outputs.
#
# Usage:
# preview.sh <docs-slug> [crop-y-top] [crop-height]
# preview.sh programming_guide/core_concepts
# preview.sh programming_guide/core_concepts 3300 600
#
# Environment assumptions:
# - Run from the cocoindex repo root (cwd doesn't matter — paths resolve
# from this script's location).
# - Google Chrome installed at the standard macOS path.
# - ImageMagick (magick) and rsync available.
set -euo pipefail
SLUG="${1:-}"
CROP_Y="${2:-3300}"
CROP_H="${3:-700}"
if [[ -z "${SLUG}" ]]; then
echo "usage: preview.sh <docs-slug> [crop-y-top] [crop-height]" >&2
echo " example: preview.sh programming_guide/core_concepts" >&2
exit 1
fi
# Resolve the cocoindex repo root relative to this script.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
DOCS_DIR="${REPO_ROOT}/docs"
PREVIEW_DIR="/tmp/dg-preview"
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
if [[ ! -d "${DOCS_DIR}" ]]; then
echo "error: expected docs/ at ${DOCS_DIR}" >&2
exit 1
fi
echo "▶ building docs site…"
(cd "${DOCS_DIR}" && npm run build >/dev/null)
echo "▶ mirroring dist → ${PREVIEW_DIR}/docs/ (base path matters)"
mkdir -p "${PREVIEW_DIR}/docs"
rsync -a --delete "${DOCS_DIR}/dist/" "${PREVIEW_DIR}/docs/"
# Pick a free port.
PORT=8765
while lsof -ti:${PORT} >/dev/null 2>&1; do
PORT=$((PORT + 1))
done
echo "▶ serving on :${PORT}"
(cd "${PREVIEW_DIR}" && python3 -m http.server "${PORT}" >/tmp/dg-preview-srv.log 2>&1) &
SERVER_PID=$!
trap 'kill ${SERVER_PID} 2>/dev/null || true' EXIT
sleep 1.5
# Astro's default output layout is `<slug>/index.html`. The older
# `build.format: 'file'` output was `<slug>.html`. Support both.
URL="http://localhost:${PORT}/docs/${SLUG}/"
if ! curl -sfI "${URL}" >/dev/null 2>&1; then
URL="http://localhost:${PORT}/docs/${SLUG}.html"
fi
echo "▶ screenshotting ${URL}"
FULL_PNG="${PREVIEW_DIR}/full.png"
"${CHROME}" \
--headless=new \
--disable-gpu \
--hide-scrollbars \
--virtual-time-budget=3000 \
--window-size=1400,5200 \
--force-device-scale-factor=1 \
--screenshot="${FULL_PNG}" \
"${URL}" 2>&1 | grep -v 'ERROR:net/cert' | tail -1 || true
if [[ ! -s "${FULL_PNG}" ]]; then
echo "error: screenshot empty or missing" >&2
exit 1
fi
CROP_PNG="${PREVIEW_DIR}/crop.png"
magick "${FULL_PNG}" -crop "1400x${CROP_H}+0+${CROP_Y}" "${CROP_PNG}"
echo ""
echo "✓ rendered"
echo " full: ${FULL_PNG}"
echo " crop: ${CROP_PNG} (y=${CROP_Y}, h=${CROP_H})"
echo ""
echo "Read the crop with the Read tool; if the target diagram is not in"
echo "view, rerun with a different crop-y-top (e.g. 2500, 3500, 4000)."