Skip to content

๐Ÿ“ TikZ figure style: layout, typography, and the pre-flight checklist

Status: reference.

python -m chitragupta figure check measures the parts of this standard that geometry, colour and text can decide, and reports each by a rule id (figure-checks/README.md). It is an aid, not a gate: nothing here blocks a draft.

This is the TikZ-specific half of WRITING-STANDARDS.md ยง10's figure contract: that section owns the two-form (TikZ + ASCII) pair, the marker syntax, and originality. This document is what makes the TikZ half of the pair good, before it is kept.

Written for the figure-drawer skill, which survey-writer, tutorial-writer, thesis-chapter-writer and textbook-chapter-writer hand a figure to, and for anyone drawing one by hand. FIGURE-DRAWER.md says how the skill applies this document. deep-research does not draw ยง10 figures at all, so it has no reason to link here.

Not covered here: the two-form pair contract, the marker syntax and originality, all of which stay in WRITING-STANDARDS.md ยง10; the ASCII form's own 7-bit alphabet and column limit, also ยง10.

๐Ÿ— Commit to a layout metaphor before you draw

TikZ figures out of this pipeline sprawl when nothing asks for a plan before the first \node: boxes placed near other boxes, arrows added as needed. Committing to one metaphor before placing a node turns "place these nine things" into a constrained problem, which deals with sprawl at its source. Each metaphor maps onto a TikZ idiom to use:

Metaphor TikZ idiom
Pipeline a chain, via the positioning library
Map matrix
Layered stack stacked fit layers
Control loop cyclic edges with bend
Branching tree tree
Hub-and-spoke network a star
Zoned spine fit plus backgrounds
State machine circles (cgstate) with bend and loop
Comparison two fit panels, matched parts on shared rows
Sequence thin lifeline nodes, with level messages between them
Timeline ticks placed by fraction along one chained axis, eras as banded cards

(The first six are PaperBanana's planner supplement, read and not copied; INSPIRATION.md carries the credit.)

The zoned spine is one left-to-right path that carries the argument, with each zone card a tinted region behind a group of members and its title outside the card's top-left corner. Reach for it when the figure has one storyline and several parallel commentaries on points along it. The card is a fit node drawn on background layer, so it never paints over a member and re-wording a member widens the card instead of escaping it. If nothing comments on the path, it is a pipeline.

The comparison is two lettered panels side by side, made of the same parts, with each part on the same row as its match. Reach for it for a before and an after, or two approaches to one job, where the point is the part that differs: that part is the cgkey, and a part one side lacks keeps its row as cgnone. Panel (b)'s first part sets its column, and every other part is placed at (<column> |- <match>) rather than beside or below a neighbour, so re-wording a part cannot move it off its match's row. Nothing is drawn between the panels; if the two sides share a pair of named axes instead, it is a map.

The sequence is parties side by side with time running down the page, and messages passing between them. Reach for it for a protocol, a handshake or a request and its replies, where the order of the exchanges is the claim. Each party hangs a lifeline: a thin, undrawn node with a dotted line down its middle, so every message is a connector between two lifelines that figure check measures. Each message is level, drawn at a named moment ((a-l.east |- t1) -- (b-l.west |- t1)), and a reply is dashed. c4-attach and c5-transit are not evaluated for it (see below). If each party only hands work on to the next and nothing comes back, it is a pipeline.

The timeline is one time axis with each event a thin tick at its own fraction of it, ($(t0)!<f>!(tend.west)$), so a gap on the page is a gap in time. Reach for it when when things happen, and how far apart, is the claim: an incident, a simulation clock, a release history. The axis is drawn tick to tick, so each piece is a connector and no rule is waived; a single line under the ticks would cross every one. Labels hang below their ticks turned to read bottom to top (rotate=90, anchor=east), and an era is a card fitted between two times in a band below, its title level and inside it. The one event the figure is about takes cgfocus, an accented tick, since a tick cannot take the cgkey card. If only the order matters, it is a pipeline.

There is a starting file for every row of that table, in assets/tikz/. One per metaphor, named for it, each compiling on its own and each reporting no finding from python -m chitragupta figure check. Copy the one whose metaphor fits and re-label it; the choice above exists to hand you a file rather than a blank tikzpicture. assets/ is copied into a project by chitragupta init, so they are already beside a scaffolded draft and there is nothing to download. assets/tikz/README.md says what each one is for.

Choose the pattern before the layout. What a figure argues (a bottleneck, a lifecycle, a trust boundary, a comparison) is not the same choice as how it is laid out, and several patterns share one layout. The figure-drawer skill's .claude/skills/figure-drawer/references/patterns.md routes a pattern to the nearest layout, and each layout has one reference file, .claude/skills/figure-drawer/references/layouts/<layout>.md (the layout's name as its scaffold spells it), with its anti-patterns and the reference ranges measured from its scaffold and exemplars. A new pattern never adds a layout; only a new way of placing nodes does.

Every scaffold places its nodes relative to one another, and none of them places a node at a coordinate in millimetres. That is the property to keep when you edit one. A figure laid out in hand-computed absolute millimetres cannot express "do not collide": re-wording one label, or setting the figure at a different type size, re-opens every adjacency in the picture at once and each of them has to be re-checked by eye. Relative placement and fit layers make most of those collisions impossible, not merely detectable.

The millimetre rule governs composition, not objects. Every node in a figure is placed relative to another node, and that does not change. Inside a pic definition, explicit millimetre coordinates are correct and expected: a pic is a fixed object drawn once, whose internal geometry does not reflow when a sibling's label changes. The boundary is the pic, not the authoring method: a shape generated by a derivation gets the same carve-out as a hand-drawn one. Hand-placing a whole figure is still the defect the rule exists to prevent.

Name every node you draw, in whichever idiom. A finding names a node by its (name); an unnamed node is shown only by its centre in millimetres, which is hard to find in the source. A picture that names no node at all is reported as nothing-measurable. Both spellings count: \node (a) and child { node (a) ... }. That includes the well a pic is drawn into: draw a pic only at a named cgwell (see House shapes).

The tree idiom has one cost that is not visible in the output: child draws its edges internally rather than as \draw statements, so the edge list and the budget-edges count are empty for such a figure, even though the connector rules still measure the lines it drew. Confirm a tree's wiring from the source's own nesting.

Every idiom in that table needs a \usetikzlibrary line, and the figure file carries it at the top, above the tikzpicture:

1
2
\usetikzlibrary{positioning}
\begin{tikzpicture}[thick,x=1mm,y=1mm]

The renderer collects those lines and loads the union in the preamble (chitragupta/render_output/_tikz_libraries.py, #781), so by the time your figure is \input the library is already there and your own line is a no-op. Keep the line anyway: it is what lets the same file compile in the two documents this pipeline does not write, which are thesis-chapter-writer's fragment inside a user's own thesis and the minimal probe document WRITING-STANDARDS.md ยง10 tells you to build around one figure. Without any load at all, a picture that reaches for below=4mm of store does not fall back; it fails the whole render with a message that names neither the library nor the figure:

1
! Package PGF Math Error: Unknown operator `o' or `of' (in '4mm of a').

Write nothing else about loading. No clearing of \tikz@library@<name>@loaded, no saving or restoring of \tikz@node@reset@hook, no \ifdefined guard around the load. Those workarounds are themselves the bug. \usetikzlibrary is legal in the document body, but a figure file is \input inside a figure float, and a float is a group: the load defines the library's macros locally while setting the loaded flag globally, so the next float skips the load and finds no macros. Clearing the flag to force a reload is worse: positioning appends its placement transform to a global hook on every load, so the Nth figure shifts every node N times. Measured on a three-float document: 71.26pt, then 128.17pt, then 185.07pt, with pdflatex exiting 0 and nothing but Overfull \hbox in the log. That is how figures that fit in their single-chapter PDF spilled off the page of this project's own assembled book. figure check reports one as loads-library-by-hand.

A load while TeX is setting a line prints as blank width. A library file's own line ends are spaces. Load it where the figure is set in a box (\sbox, \hbox, \resizebox), inline in running text, or as the body of a standalone preview, which boxes its content, and those spaces become glue ahead of the picture: 135pt for the house line on the render's page (12pt, LuaLaTeX), about 30% of its 453pt text width, and 147pt with matrix or trees added. Loaded in the preamble, the same spaces fall in vertical mode and are dropped. So is a load at the top of a figure float, which is why the renderer's figures never show it. Ending the figure's line with % or \ignorespaces does not help, since the spaces come from inside the library files. The fix is the same hoist: a document that sets a figure in a box loads the figure's libraries in its preamble, as the documents figure check and figure look compile do. tests/test_figure_stray_width.py pins the in-box half on every scaffold, and figure check's size-stray the preloaded one.

Before you reach for a library:

  • A missing name takes the whole call down. \usetikzlibrary{} fails fatally on the comma list rather than skipping the name it cannot resolve, so one typo reads exactly like several missing packages. Probe a library by compiling a two-line document that loads it, not with kpsewhich tikzlibrary<name>.code.tex, which only sees tikz-layer files and so misses every pgf-layer library (arrows.meta is pgflibraryarrows.meta.code.tex, and resolves fine).
  • shapes.geometric, not shapes.geometry. The second is not a PGF library at all.

texlive-pictures, which scripts/install_full_pipeline.sh already installs, ships all of these; there is no extra package to add for any of them.

๐Ÿ”  Panels in one figure, and the letters they need

A figure that shows the same thing under two or three conditions is one figure with panels, not two or three figures. However many panels it has, it stays one figure, one figure: marker and one .tex/.txt pair. One marker per panel would give you separate floats with separate numbers, which is a row of figures rather than a panelled one. Nothing here is specific to two: letter as far as the figure goes.

Every panel carries a label node reading (<letter>) <short title>, and the letter is the panel's position in reading order: (a) for the first panel, (b) for the second, (c) for the third, and on through the alphabet. Reading order is left to right, then top to bottom, so it is the panel's place in the picture that decides its letter, not the order you happened to draw the nodes in: move a panel and its letter moves with the position, not with the panel. Draw the label as an ordinary node under the panel it names; a panel with a bold title and no letter cannot be pointed at from the prose, which is what the letter is for. Prose then refers to Figure~\ref{fig:x}(b) for the second panel, with the letter typed, since these letters are drawn rather than counted by LaTeX.

Put the same letters in the ASCII twin. Every format except tex/pdf (md, html and docx alike) renders the .txt and never sees the picture, so for three of the five formats the ASCII form is the only place the sub-captions exist. Letter it by taking the four columns from the gap to the left of each title rather than by inserting them, or every title after the first slides off the panel it labels; the letters then overhang their panels by four columns, which is the intended look and keeps ยง10's ~70-column cap intact.

When a row stops fitting, wrap into another row; never scale the picture. Three panels of about 48mm each overflow an ordinary text block by 71.8pt, and pdflatex only warns: Overfull \hbox, in a log nobody reads, with the figure in the margin. Four of the same panels as a 2x2 grid fit with no warning at all. Reaching for \resizebox or scale= instead would shrink the node text below the document's own size, which "Type and line weight" below forbids for exactly this reason.

Where the figure is captioned, write the caption in the draft, not in the figure file. A Markdown draft writes its caption directly below the figure: marker (WRITING-STANDARDS.md ยง10), and the renderer, not the figure file, wraps the marker in a figure float with \caption/\label, so LaTeX's own counter numbers it. The figure file itself stays a bare tikzpicture fragment, same shape as assets/tikz/'s own scaffolds, and copying one does not include wrapping it in a float by hand.

thesis-chapter-writer's .tex fragment is the exception, because it carries no marker for the renderer to wrap: it hand-authors its own \begin{figure}...\caption{}...\label{fig:<id>}...\end{figure} around the inline \input. The one change there too: never write \renewcommand{\thefigure}{N.M}. The figure counter it would override is exactly what makes \ref agree with the consuming thesis's own numbering; a hand-typed number is wrong the moment that thesis renumbers a chapter.

Do not reach for subcaption, subfig or subfigure. The package is installed on a typical TeX stack, so this is a choice rather than a limit. \usetikzlibrary is legal in the document body and \usepackage is not (! LaTeX Error: Can be used only in preamble.), so a figure file can load its own TikZ library but never its own package, and the preamble that would have to load it is not always ours. thesis-chapter-writer's fragment is \input into the user's own thesis, whose preamble we never see, so a figure needing subcaption compiles here and fails there. Drawn letters work in every genre and change no preamble. The whole cost is \subref and automatic "Figure 1a" numbering, which is one typed letter per reference.

Record the convention, don't re-decide it. Which letters a venue wants ((a), (i), A, or titles alone) is a house-style decision in WRITING-STANDARDS.md ยง8's sense, and belongs in the dossier's scope.md beside the dialect. draft-reviser reads that file before every edit; a convention agreed in chat is gone by the next session.

A three-panel figure, relative placement throughout, verified on this host: it compiles, every node it names comes back measured, and figure check reports no overlap and no connector finding:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
\usetikzlibrary{positioning,fit}
\begin{figure}
\centering
\begin{tikzpicture}[thick,
                    box/.style={draw,align=center,
                                minimum width=17mm,minimum height=8mm}]
  \node[box] (senseA) {sensor};
  \node[box,below=6mm of senseA] (storeA) {store};
  \draw[->] (senseA) -- (storeA);
  \node[fit=(senseA)(storeA),draw=none] (panelA) {};
  \node[below=2mm of panelA] (labelA) {(a) polled};

  \node[box,right=12mm of senseA] (senseB) {sensor};
  \node[box,below=6mm of senseB] (storeB) {store};
  \draw[->] (senseB) -- (storeB);
  \node[fit=(senseB)(storeB),draw=none] (panelB) {};
  \node[below=2mm of panelB] (labelB) {(b) pushed};

  \node[box,right=12mm of senseB] (senseC) {sensor};
  \node[box,below=6mm of senseC] (storeC) {store};
  \draw[->] (senseC) -- (storeC);
  \node[fit=(senseC)(storeC),draw=none] (panelC) {};
  \node[below=2mm of panelC] (labelC) {(c) buffered};
\end{tikzpicture}
\caption{One reading path under three delivery modes.}
\label{fig:delivery-modes}
\end{figure}

tests/test_tikz_subcaptions.py reads that example out of this file and compiles it, so it cannot drift into something that no longer works. The empty fit nodes are what give each panel an extent to hang its label under; they are grouping boxes, which figure check excludes from its overlap check by design, and any other way of finding the same point is equally fine. What the rule asks for is the label and its letter.

โœ… Check the drawn figure against this list

Concrete defects, not taste. Where figure check reports an item, the item links to the page that explains its rule.

  • Occlusion and overlap: no two nodes' boxes intersect (node-overlap).
  • A fill erases everything under it, not just the line you aimed it at. Filling a label white and drawing it last is the standard way to make a line break at the text instead of striking through it, and it works, but the fill is a rectangle the height of the whole node and the width of the whole label, and it paints out every arrowhead, border and rule that rectangle happens to cover. A one-line label 139mm wide, filled to interrupt one vertical arrow at x=24, also deleted an arrowhead and the top edge of a box at x=70: the edge rendered as a stub going nowhere and the box as three sides. Ask what else is in the band before reaching for fill=white, and note that shrinking the band rarely rescues it: inner ysep=0pt bought 0.06mm in that case, and breaking the label over two lines makes the band taller, not narrower.
  • One arrow is one \draw. Two colinear segments that meet, each carrying ->, render as an arrowhead where they join as well as at the end, a second head pointing at nothing in the middle of the line. If a line needs to be built in pieces, put the -> on the last piece only. This one is mechanically checked, where the pieces meet at a bare coordinate: chaining head-to-tail through a named node is how a pipeline is normally drawn and is not a defect, because TikZ clips each path at the node's boundary so the head points at the node rather than at empty space (stranded-arrowhead).
  • Chaotic routing: arrows should not cross unnecessarily or form spaghetti loops. The parts of this that geometry decides are the connector rules c1 to c7 (connectors): slanted lines, a tag on its line, lines that share a stretch, an off-centre attach point, a line through a box, a covered tag and an arrow that lands on nothing. Whether the routing as a whole reads clearly stays a human judgement.
  • Illegible or inconsistent type, and low contrast: one font size per role, held consistent across every node in the figure. See "Type and line weight" below for what size that should be. Text below the body size, and lines or words too pale for their ground, are reported (colour and type).
  • Inefficient non-rectangular composition: LaTeX treats a figure as a rectangular box, so anything protruding above the main block forces the surrounding text to wrap around the highest point, wasting vertical space. Keep the picture's bounding box close to the union of its node boxes (content-protrusion).
  • Conciseness: a node whose text runs past 15 words is too long for a box; cut it or split the node. Shrinking the font to fit more words in is not a fix (see "Type and line weight" below) (node-text-overload).
  • The figure's main point resting on colour alone. Colour is house-standard here and carries meaning freely (see "The house palette" below). What it may not do is carry the whole point on its own, because two readers get none of it: the ASCII twin is 7-bit, and md, docx and html render only that form; and a black-and-white print greyscales the TikZ form, where cgFlow and cgAlt land at similar lightness. Position, arrow direction and node labels should still deliver the argument; colour should make it fast. The test is quick: read the .txt twin and ask whether the point still arrives. A secondary distinction living only in colour is fine and expected.
  • Literal copying: a box-ified copy-paste of prose, with no visual abstraction, is not a figure. This is WRITING-STANDARDS.md ยง10's originality rule applied to figures, and it matters more than it looks: a figure can launder borrowed wording past every detector in PLAGIARISM.md, because a citekey is already kept out of figure files and the gate does not follow \input.

๐Ÿ”ค Type and line weight

Two conventions, both about a figure looking like a native part of the document rather than something pasted in:

  • Every word in a figure is set at the document's body size. Nothing in a picture is smaller than the paragraph beside it, and nothing is larger either: node names and zone titles are told apart from glosses, tags and notes by weight and colour (bold names, muted glosses), not by size. Set the size explicitly as a picture option (\begin{tikzpicture}[cg] does it), rather than inheriting it, so a picture that lands inside a float, a table cell or a \small group does not quietly drop below it. Names and titles are not set a step up: at \large\bfseries a figure reads louder than the text around it, and a zone title grows past its zone into the next one. figure check reports text below the body size as type-below-floor.

Shrinking type is never the fix for a box with too many words in it: that is the conciseness defect, and the fixes are fewer words or a wider box. Two exceptions, both automatic and both correct: a mathematical subscript or superscript set by TeX's own script style, and the \caption, which is the document's, not the figure's. tests/test_tikz_scaffolds.py holds every file in assets/tikz/ to this by reading each glyph's printed size back out of the compiled PDF, not by reading the source, because the size that breaks it usually arrives from somewhere the figure file cannot see. - Lines are distinctly thicker than TikZ's own default. \pgflinewidth defaults to 0.4pt, TikZ's thin key (verified with \typeout{\the\pgflinewidth} on this host). Draw node borders and arrows at thick (0.8pt, double the default, same probe) instead; a figure left at the bare default reads as faint next to a document's normal text weight. Set it as a picture option, not a restated style: \begin{tikzpicture}[thick], which the house block's [cg] already includes. Do not reach for \tikzset{every node/.style={thick}} to do this: /.style= replaces whatever every node already held, so a figure with its own node style (draw, circle, a fill colour) silently loses all of it the moment this line runs after that one. Verified on this host: a circular node's bounding box, forced through exactly that sequence, came out identical to a plain undecorated node's; the circle and the border were both gone. \begin{tikzpicture}[thick] cannot clobber anything, because it never touches every node at all.

๐ŸŽจ The house palette

Figures here are in colour, and they use one palette. Five named colours with a role each, and four more that a zoned figure needs. A figure that invents its own hues reads as imported from somewhere else, which a house palette exists to prevent. figure check reports one as colour-off-palette, and a card filled outside the band below as tint-outside-band (colour and type).

1
2
3
4
5
6
7
8
9
\definecolor{cgInk}{HTML}{1A1A1A}     % borders, labels, default strokes
\definecolor{cgFlow}{HTML}{0072B2}    % the primary path through the figure
\definecolor{cgAccent}{HTML}{D55E00}  % the one thing the figure is about
\definecolor{cgAlt}{HTML}{009E73}     % a second class of node or edge
\definecolor{cgAux}{HTML}{56B4E9}     % auxiliary, secondary, "also happens"
\definecolor{cgWarm}{HTML}{E69F00}    % a third zone
\definecolor{cgRose}{HTML}{CC79A7}    % a fourth zone
\definecolor{cgPaper}{HTML}{FAFAF8}   % card ground, one step off white
\definecolor{cgMute}{HTML}{8A8A8A}    % muted ink, as a name

(Inside assets/tikz/cg-figstyle.tex each of these lines ends its } with a %, so the block sets no stray space; see "The house figure style" below.)

cgWarm and cgRose are one hue per zone. They are Okabe-Ito's remaining qualitative members, so the extension is the same palette rather than a second one: a zoned figure needs one hue per zone, and five roles do not stretch to six zones without reusing one, which reads as a claim the figure is not making. cgMute is a named grey because the house styles take a colour as an argument and paste it into a tint (fill=#1!11): cgInk!55 passed in becomes cgInk!55!11, which xcolor rejects with "Undefined color". Every role that needs muted ink therefore needs a name. cgPaper is the card ground, one step off white for the same reason cgInk is not black.

The values are Okabe-Ito's colourblind-safe qualitative set (Okabe & Ito, 2008), the usual choice for this purpose, and it costs nothing to adopt. cgInk is a near-black rather than black, because a pure black border next to body text set in the same ink reads heavier than the text does.

Each colour has a role. Use cgAccent for one thing per figure: if two things are accented, neither is. cgFlow carries the spine; cgAux is for what happens off to the side, usually dashed as well as coloured. cgAlt exists so a figure with two node classes does not have to reach outside the palette.

A palette name fills; a Line or Text name draws. fill=cgFlow!8 is a zone wash that text still reads through; fill=cgFlow is a block of solid blue with your label lost inside it. Between 6 and 12 is the band the house block's cards and zones use. Strokes and coloured text do not take the palette colour at all, because several hues are too light to be seen as a line on their own tint (cgWarm on cgWarm!12 is 1.9:1, where WCAG 2 asks 3:1 of a line) and none but cgFlow reaches the 4.5:1 that text needs. So each hue, and cgMute, has two darker shades mixed towards cgInk:

Hue Border, edge, rule Coloured text
cgFlow cgFlowLine (100%) cgFlowText (95%)
cgAccent cgAccentLine (100%) cgAccentText (75%)
cgAlt cgAltLine (95%) cgAltText (70%)
cgAux cgAuxLine (75%) cgAuxText (55%)
cgWarm cgWarmLine (75%) cgWarmText (55%)
cgRose cgRoseLine (90%) cgRoseText (65%)
cgMute cgMuteLine (95%) cgMuteText (70%)

Each percentage is the share of the hue in the mix, and is the largest multiple of 5 that clears the threshold over white, cgPaper and every tint the block fills with, of any hue, since an edge can cross another zone. tests/test_figure_contrast.py recomputes every one from the hex values above, and fails a stroke or label in the block or a scaffold that draws in a bare palette name. Every house style already draws this way, so the rule only binds what you write yourself: draw=cgAltLine, not draw=cgAlt; text=cgAccentText, not text=cgAccent. A style that takes a colour argument (cgzone=cgAlt) takes the palette name and finds the shade itself.

๐Ÿ“Ž The palette travels inside the figure

The palette travels as part of the house block (next section), which every figure carries whole. Not in a shared file the figure \inputs, and not in a preamble: the renderer injects \usepackage{tikz} and nothing else, and thesis-chapter-writer's fragment is \input directly into the user's own thesis, which has never heard of this project. A figure that depends on a colour defined elsewhere compiles here and fails there, which is the worst of the two orders to fail in.

This is the same discipline the \usetikzlibrary line already follows, for the same reason, and it means the definitions are duplicated across figures on purpose. The eleven scaffolds in assets/tikz/ all carry the block, so copying one gets it for free.

๐Ÿงฑ The house figure style

assets/tikz/cg-figstyle.tex is the palette grown into the rest of the vocabulary a figure needs to look drawn rather than assembled, so a figure stops re-deciding corner radius, rule weight, tint depth, arrowhead and label structure from nothing each time:

Group Keys
Palette the nine colours above, and the Line and Text shade of each hue and of cgMute
Picture default cg: thick, round caps and joins, font=\normalsize, ink colour, node distance=6mm
Node roles cgbox cgalt cgaux cgwarm cgrose cgplain cgkey cgnone cgopt
Shadow cgsoft (on in every filled role) and cgflat (cancels it; cgnone and cgopt use it)
Zone card cgzone=<colour> (3mm inner sep) and \cgzonelabel{<fit node>}{<colour>}{<text>} (1.5mm above the card)
States cgstate (a circle, 18mm, bold name), cgaccept (double ring), cgkeystate (the accented state), cgstart (the initial arrow); pure TikZ, no automata library
Edges cgedge cgweak cgback cglead=<colour>
Annotation cgtag (a word beside an arrow, never on it), cgnote
Badges cgbadge=<colour> and \cgstep{<n>}{<node>}{<colour>}
Legend cgswatch=<colour>, \cglegenditem{...}
Label ramp \cglab{<name>}{<gloss>}
Icon well cgwell: a named, invisible, fixed-size node a pic is drawn into

It also defines five house pics, each drawn into a cgwell; see House shapes.

Its spacing is compact by default. Two members of one zone sit 6mm apart, which cg's node distance gives you for right=of. Two zones sit 8mm apart card to card, which is 14mm member to member once each card's 3mm inner sep is counted; say that distance where a placement crosses zones. Stacked zones need the lower card's title in the gap as well, about 5mm more (assets/tikz/layered-stack.tex works the sum). The type is not part of this: the floor in "Type and line weight" above is what stops a compact figure becoming a squashed one.

An edge tag sits beside its line. cgtag is unfilled and placed with auto, on the left of the direction of travel, 1.5mm off the segment's centre (about 1.35mm clear of the house stroke); add swap to put it on the right. A tag filled over its line breaks the line exactly where the eye is following it. At 1mm a tag's glyphs read as touching the stroke at print size (#1113). Set it beside the shaft, not beside the arrowhead, which is 2.1mm wide: figure check's c2-tag-clearance measures from the head's outline too.

The block needs one library line, at the top of the figure with the picture's own:

1
\usetikzlibrary{arrows.meta,positioning,fit,backgrounds,calc,shadows.blur}

All six ship with texlive-pictures. shadows.blur comes from the pgf-blur package but is reached by \usetikzlibrary, not \usepackage, which is what lets the soft shadow travel into a thesis preamble this project never sees: a figure file cannot load a package.

The block travels between its two marker comments, and python -m chitragupta figure sync keeps it current. The command stamps the block into a figure file that lacks one, above its first \usetikzlibrary or tikzpicture line, and replaces a block from an older release with the installed one. Everything outside the markers stays byte for byte as it was. With no paths it walks every content/drafts/**/figures/*.tex; --check writes nothing and exits 1 if any file is not current. chitragupta draft render reports a stale or missing block as a [figure] warning naming the command, and renders anyway: it never rewrites a figure itself.

Never edit inside the markers. A style one figure needs goes in that picture's own options, or in a \tikzset below the end marker. figure sync recognises every block this project has released by its digest, so a region that matches none of them was edited by hand. It leaves that region alone and prints the diff against the house block, for a person to move the edit out. A style that three or more figures need is a pull request against cg-figstyle.tex, not a local override.

Changing the block means a new version. Bump the v<N> in its first line and add that version's digest to assets/tikz/cg-figstyle.versions.toml, never editing an existing line there. tests/test_figure_block.py fails when the block and the register disagree, so an edit without a version bump cannot reach main. Then run python -m chitragupta figure sync assets/tikz/*.tex assets/tikz/exemplars/*.tex to refresh every scaffold and exemplar, which tests/test_tikz_scaffolds.py still checks byte for byte.

Repeating it once per figure is safe, by the same argument #781 makes for \usetikzlibrary. \tikzset re-defines the same keys to the same values, and every macro is \providecommand, so N figures in one document carrying N copies cost N definitions and change nothing. No global hook is appended to and no loaded flag is set. The one construct without this property is \tikzset{every node/.style=...} (see "Type and line weight" above), and the block touches every node only through .append style inside the cg picture option, scoped to one picture.

๐Ÿงฐ House shapes, and the well they sit in

The block defines five pics: small conventional shapes drawn in the house line weight.

pic Stands for Twin says
cgstore a drum: any persistent store, such as the ledger, the vector store or a forward index [ledger]
cgdocs a stack of pages: a document, or a set of them [PDFs]
cgfunnel any place the set gets smaller: a cap, a top-k, a filter [cap 3 per source]
cgchip anything that computes: a model, an encoder, a reranker [bi-encoder]
cgshield the one blocking check, at most once per figure, if at all [gate]

Each passes the ASCII twin's test by construction. It sits directly above a node whose label already says the same thing, so the shape is a rendering choice and never a proposition, and the twin loses nothing by dropping it.

Draw every pic into a well. A pic is a path, not a node, so it has no name, and two things break quietly without one. No rule measures a pic's paths, so an icon colliding with a label reports clean. And a zone card can fit only named nodes, so an icon placed above a node pokes through the top of its own zone. figure check reports a pic outside a well as unnamed-pic. cgwell is an invisible, fixed-size (10mm), named node that the pic is drawn into:

1
2
\node[cgwell, above=2.5mm of sync] (isync) {};
\pic[cg pic colour=cgFlow] at (isync) {cgstore};

Every house pic fits inside its well, and tests/test_tikz_scaffolds.py measures that, so an edit that makes one overflow fails there. pipeline.tex shows the idiom.

Colour. cg pic colour takes a palette name and applies to that one \pic. Give it the hue of the zone the shape sits in: the same drum is green in the lexical zone and blue in the dense route.

This is not an icon set, and it should not become one by accretion. A shape is added only when a metaphor in the layout table wants it, and only when the node's own label already carries the proposition. Five is the ceiling for now. A sixth needs the same argument made again, in writing, in its pull request, and a row in the table above; tests/test_tikz_scaffolds.py fails if the block and the table name different shapes.

๐Ÿ–จ What the other two forms do with it

Two readers receive no colour at all, and this is accepted rather than designed around:

  • The ASCII twin is 7-bit, so md, docx and html get none of it. The twin still has to work as a figure (that is WRITING-STANDARDS.md ยง10's two-form contract, and it is unchanged), but it is not required to reproduce the colour form's every distinction.
  • A black-and-white print greyscales the TikZ form, where cgFlow and cgAlt land at similar lightness.

So the practical rule is weaker than "never encode meaning in colour" and stronger than nothing: colour may carry meaning, and should not be the only thing carrying the figure's main point. Keep the spine legible through position and arrow direction, which every layout metaphor above already gives you, and let colour do the work of making the figure quick to read rather than possible to read.

๐Ÿ“ Draw for the width it will be printed at

The eleven layout metaphors have no width dimension, and a figure drawn without one in mind is a figure whose type size is decided by accident. A single-column figure in a two-column paper sets at roughly half a page width; the same picture dropped into a thesis sets at nearly double that. Nothing in the picture changes, but the type does, because scale= and the consuming document's own width between them decide the final physical size of every label.

So settle the target width before choosing a metaphor: a hub-and-spoke needs horizontal room a single column does not have, and is the wrong metaphor there however well it fits the content. A layered stack degrades gracefully to a narrow column; a wide map does not.

Never wrap a figure in \resizebox or \scalebox, and never set scale= on the picture. A picture drawn at its natural size and then scaled to fit prints its type at whatever the scale factor happens to be, so the floor in "Type and line weight" becomes a number nobody chose. Settle the target width first, lay the figure out to it, and \input it bare. If it does not fit, change the layout (wrap a row, cut a gloss, drop a stage), never the scale. The row count following from the type size is the right direction for that decision to run.

This is the rule that actually gets broken, because the scale is invisible in the figure file: it lives in the draft that \inputs it. Measured on six hand-written figures in one acmart sigconf paper (9pt body), each wrapped in \resizebox{\textwidth}{!}{...}:

Figure Natural width Scale applied Smallest type printed at
architecture 462.8pt 1.094 6.6pt
rag-stages 548.9pt 0.922 5.5pt
retrieval 519.5pt 0.975 5.9pt
coauthoring 520.8pt 0.972 5.8pt
prompt 425.6pt 1.118 6.7pt
revision 420.6pt 1.180 7.1pt

The six figures printed at six type sizes, none of them chosen. Laid out again to fit 506.3pt unscaled, every label printed at 8.97pt or above. Expect the floor to change layouts, not just sizes: rag-stages went from two rows to three, and retrieval's dense route wrapped. Three of those redrawn figures ship in assets/tikz/exemplars/, laid out to the render's 453pt.

The renderer is already clean here: render_output/_figure_captions.py emits a bare \begin{figure} around the marker with no scale knob, so a pipeline-rendered figure is drawn at natural size. The defect is what a hand-written document does, which is exactly thesis-chapter-writer's case: its fragment is \input into a thesis this project never sees. The eleven scaffolds are each laid out to the article class's 345pt text width, and a test compiles each one there and fails on an Overfull \hbox.

No venue table is bundled here, deliberately. Column widths are per-publisher, differ between initial and revised submission, and go stale; a table checked into this repository would be read as current long after it stopped being. If you keep one, keep it the way a maintained one is kept: a date it was accessed, a source URL per entry, and the standing caveat that a passing check is not compliance. Re-read the publisher's live page before you submit.

๐Ÿงฟ Rules that are not universal

Figure guidance is mostly written for raster images, and several rules that sound authoritative do not transfer to a TikZ picture at all. Named here so nobody spends an afternoon satisfying one:

  • "Line art must be 600 or 1000 dpi." A TikZ figure is vector and has no DPI. There is nothing to set and nothing to check. The requirement that does transfer is physical width, above.
  • "Convert everything to CMYK / everything to RGB." A colour-space question about exported raster assets. It reaches a \input-ed TikZ picture only through whatever the consuming document does at export, which this pipeline does not control and should not pretend to.
  • "Make the figure bigger so the text is readable." Backwards. Enlarging the picture enlarges the type and everything else, so it overflows the column and gets scaled back down. Type size at final scale is the quantity that matters, and \footnotesize inside a picture that is then scaled to 0.8 is the real defect.
  • "Pick a colourblind-safe palette and accessibility is handled." Necessary, and not the end of it. The house palette is already Okabe-Ito, so the hue question is settled and you should not be re-deciding it per figure. What a safe palette does not address is the two readers who receive no colour at all (the ASCII twin's audience and anyone printing in black and white), which is a redundancy question rather than a palette one.
  • "A vector figure is resolution-independent, so size does not matter." True of fidelity, false of legibility, and the second is what a reader notices.

๐Ÿ”Ž Checking a figure against this standard

chitragupta figure check <figure.tex> measures the parts of this standard that geometry, colour and the source can decide: width, overlaps, connector routing, contrast, type size, the palette, the ASCII twin and more, 36 rules in all. It is an aid, not a gate. It exits 0 whatever it finds, and the only gate in this project is the citation gate.

It measures what TikZ drew and never places anything. The fix for a finding is to change what the figure asks TikZ for (a node distance, a row sep, an anchor, a layout), never to nudge a coordinate until the number moves. When a finding and your own eyes disagree about a figure that is right, keep the figure and record why with a % cg-allow: line.

figure-checks/README.md explains how to read a report, lists every rule in one sentence, and links each rule to a page with a faulty and a fixed figure. It also has the cg-allow form and how figure check relates to review figure.