diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index ce36204..c07d7f1 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -14,7 +14,7 @@
If this changes what any renderer draws:
-- [ ] Gallery regenerated (`go run ./cmd/blazon gallery -o docs/gallery -readme README.md`),
+- [ ] Gallery regenerated (`go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md`),
and a second run produced no diff
- [ ] I looked at the image diff
diff --git a/.github/workflows/gallery.yml b/.github/workflows/gallery.yml
index 1f72ec9..75ebf6f 100644
--- a/.github/workflows/gallery.yml
+++ b/.github/workflows/gallery.yml
@@ -34,7 +34,7 @@ jobs:
cache: true
- name: regenerate
- run: go run ./cmd/blazon gallery -o docs/gallery -readme README.md
+ run: go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md
- name: the committed gallery must match
shell: bash
@@ -46,7 +46,7 @@ jobs:
echo "The committed gallery does not match what the code renders."
echo "Run this and commit the result:"
echo
- echo " go run ./cmd/blazon gallery -o docs/gallery -readme README.md"
+ echo " go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md"
echo
git diff --stat
exit 1
@@ -56,7 +56,7 @@ jobs:
run: |
# A non-idempotent generator would make this job fail on every push
# even when nothing changed.
- go run ./cmd/blazon gallery -o docs/gallery -readme README.md
+ go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md
if ! git diff --quiet; then
echo "The gallery generator is not idempotent: a second run changed files."
git diff --stat
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7171bb9..c5dff07 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -128,10 +128,14 @@ looks like now, and why the new value is still a useful bound.
Numbers say whether a renderer passes. A contact sheet is how you find out
whether it is any good.
-7. Regenerate the gallery in the same pull request:
+7. Describe it in one row of the table in `docs/renderers.md`, above the
+ gallery markers — what it draws, and whether the version can be read back
+ out of it.
+
+8. Regenerate the gallery in the same pull request:
```
- go run ./cmd/blazon gallery -o docs/gallery -readme README.md
+ go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md
```
Run it twice. The second run must produce no diff. CI regenerates the
diff --git a/README.md b/README.md
index 6cd28be..6e1f12d 100644
--- a/README.md
+++ b/README.md
@@ -15,23 +15,52 @@
-Identicons for version numbers, in Go.
-
-Not a general-purpose hash-to-picture library pointed at a version string. The
-version's structure is the input: major, minor and patch feed separate entropy
-streams, so how much two neighbouring releases resemble each other is a
-decision you make, not an accident of the hash.
-
-And distinguishability is a test, not a claim. Every renderer is measured
-across a corpus of 184 versions, and a change that makes patch bumps harder to
-tell apart turns the build red.
+**Give every version of your software a face.** blazon turns a version number
+into a small picture that a person recognises at a glance, without reading it.
+
+## Why
+
+Version numbers are hard to see. `1.4.12` and `1.4.2` differ by one character in
+the middle of a string, and every mistake that follows from mixing them up looks
+like a different bug. A picture fails differently: two versions either look the
+same or they do not, and the eye answers in one glance.
+
+- **Spot the odd one in a list.** A release table, a deploy dashboard, a fleet of
+ agents, a row of build artifacts — one mark per row and the outlier stops
+ hiding among digits.
+- **Show the version without showing a version.** A mark in a corner, a favicon,
+ a splash screen, a login footer: users never see a number, but a screenshot in
+ a bug report still tells you which build it came from, and two screenshots
+ from "the same version" stop being a guess.
+- **Confirm what shipped.** The mark on the artifact, on the running service's
+ health page and in the release notes either match or they do not. No diffing
+ strings across three tabs.
+- **Make releases memorable.** A changelog entry, a tag, a launch post — the mark
+ is what people remember the release *as*.
+
+Two properties make that work, and both are enforced by tests rather than
+claimed:
+
+1. **Neighbours are never confusable.** A patch bump visibly changes the mark. A
+ change that makes patch bumps harder to tell apart turns the build red.
+2. **Family resemblance is deliberate.** The version's structure is the input —
+ major, minor and patch feed separate entropy streams — so all `1.x` marks read
+ as relatives while `2.0.0` starts over. How closely neighbours resemble each
+ other is [a setting](docs/how-it-works.md#policy-how-related-should-neighbours-look),
+ not an accident of a hash.
+
+And if you would rather a person be able to *read* the version back out of the
+mark, three of the renderers encode the number itself instead of hashing it.
+
+## Use it
+
+Hosted, for a README or a web UI — no build step:
+
+```html
+
+```
-**No dependencies.** Standard library only — including the rasteriser and the
-font. Those are the two things that decide what the pixels are, and a library
-whose whole premise is that a version always produces the same mark cannot
-outsource them: a dependency that sharpened its anti-aliasing would change
-every mark ever generated, arriving as a routine version bump. A test enforces
-it.
+In Go:
```go
import "github.com/timzifer/blazon"
@@ -41,245 +70,30 @@ png, err := blazon.PNG("1.4.2", blazon.Options{Renderer: "flowfield", Size: 512}
txt, err := blazon.Text("1.4.2", blazon.Options{Renderer: "bishop"})
```
+From a terminal or a build script:
+
```
go install github.com/timzifer/blazon/cmd/blazon@latest
-blazon 1.4.2 # terminal
+blazon 1.4.2 # draw it in the terminal
blazon 1.4.2 -r flowfield -o x.svg
-blazon dist -r superformula # the distance histogram behind the thresholds
-```
-
-## How it works
-
-Three stages. Each one is a seam you can cut at.
-
-**1 — Entropy.** Not `sha256("1.4.2")` in one piece. Four separated streams:
-
-```
-h0 = hash(major) archetype
-h1 = hash(major, minor) variant
-h2 = hash(major, minor, patch) detail
-h3 = hash(full version) prerelease
-```
-
-Fields are length-prefixed, so `1.23.4` and `12.3.4` cannot collide.
-
-**2 — Parameter mapping.** A `Renderer` draws its parameters from those streams
-and decides what the mark *means*. This is the stage that determines whether
-the library is useful. Not every renderer hashes: `cistercian`, `dial` and
-`orbit` encode the number itself, so the version can be read back out of the
-mark. Those are marked `Ordered` and are held to uniqueness rather than to the
-distance thresholds — being systematic is the point of them.
-
-**3 — Render primitives.** A `canvas.Canvas` turns drawing calls into SVG,
-pixels or terminal cells. This stage decides only how it looks. Every renderer
-works on every back end.
-
-## Policy: how related should neighbours look?
-
-An API choice, not a design decision:
-
-| Policy | Effect |
-| --- | --- |
-| `PolicyFamilyAmplified` (default) | Major sets the archetype, minor the variant, patch the detail — and each renderer routes two or three of its highest-contrast parameters through the patch stream, so adjacent patches stay in the family but are never hard to tell apart. |
-| `PolicyFamily` | The plain cascade. Patch changes only fine detail. |
-| `PolicyIndependent` | Every stream comes from the full version. Neighbours look unrelated: maximum distinguishability, no family information. |
-
-A prerelease never changes the core streams: `1.0.0-rc.1` still reads as a
-`1.0.0`, marked by a small band of dots.
-
-## The testable invariant
-
-This is the part that separates blazon from any identicon port.
-
-Marks are rendered across a version corpus, reduced to a 128-bit perceptual
-hash — a DCT hash plus a difference hash, because either alone confuses shapes
-the other separates — and the Hamming distances between related versions are
-held to per-renderer thresholds:
-
-```go
-blazontest.Suite{
- Renderer: myRenderer,
- Thresholds: blazontest.Thresholds{MinPatch: 6, MinMinor: 9, MinMajor: 20, Floor: 2},
-}.Run(t)
-```
-
-The suite checks:
-
-- **patch, minor and major neighbours** clear their minimum distances;
-- **no pair anywhere** in the corpus falls below the global floor;
-- **family cohesion** — versions sharing a major really are closer to each
- other than to other majors, so the family policy is not a lie;
-- **prereleases stay close** to their release, bounded from above rather than
- below;
-- **uniqueness** — no two versions render to the same bytes, exactly rather
- than perceptually;
-- **determinism** — the same version renders identically, byte for byte where a
- renderer declares it can.
-
-Measurement is monochrome on purpose. A luminance-based hash scores two marks
-differing only in hue as identical, so measuring in colour would credit colour
-for distinctness the form does not have. Judging form alone makes the guarantee
-hold in print, in a monochrome terminal, and for a colour-blind reader.
-
-Thresholds are calibrated from each renderer's own histogram, not guessed:
-
-```
-blazon dist -r truchet
-```
-
-## Renderers
-
-| Name | What it is |
-| --- | --- |
-| `superformula` | Gielis' superformula, drawn from curated shape families — star, flower, gear, crystal, leaf, shield. The default. |
-| `flowfield` | Streamlines through a Sherlock–Monro orientation field with fingerprint cores and deltas. Arch, loop and whorl come from the major version; minutiae emerge from the ridge spacing rule. |
-| `truchet` | Truchet tiles. Arcs meet at cell edge midpoints, so the marks join across the grid into loops and labyrinths. |
-| `polar` | A bitmap in rings and sectors instead of rows and columns, mirrored across a fold from the archetype stream, with a centre device in the hole and a radial profile that varies ring by ring. |
-| `cistercian` | Medieval numerals: one stave per version component, four digits per stave. Legible — you can read the version back out of it. |
-| `dial` | One quadrant per component, the value written in binary as radial bands. The separator is the angle: the spokes are the dots of the version string. |
-| `orbit` | One ring per component, major innermost, the value written in binary as cells running clockwise from noon. The separator is the radius. |
-| `bishop` | The drunken bishop walk from OpenSSH randomart, for the terminal. |
-
-## Output
-
-- **SVG** — plain string building, deterministic number formatting.
-- **PNG** — anti-aliased by exact area coverage: each edge deposits the signed
- area it sweeps per pixel column, and a running sum along the row gives
- coverage. Filling a shape and summing the alpha returns its geometric area,
- which is how the anti-aliasing is tested.
-- **Terminal** — half-block characters give each cell two square pixels, so
- vector renderers arrive as pictures rather than as ASCII art. Falls back to a
- density ramp under `NO_COLOR`.
-
-Colour is derived in OKLCH with gamut mapping, held in a lightness band that
-keeps contrast on both a white page and a dark terminal.
-
-## Hosted marks
-
-If all you want is a mark in a README, there is an endpoint for it:
-
-```
-https://api.blazon.build/v1/mark/1.4.2.svg
-https://api.blazon.build/v1/mark/1.4.2.svg?renderer=flowfield&bg=dark
-https://api.blazon.build/v1/mark/1.4.2.png?renderer=bishop&size=128
-```
-
-`/v1/mark/{version}.{svg|png|txt}`, with `renderer`, `size`, `padding`,
-`policy`, `palette`, `bg`, `cols` and `nocolor` spelled exactly as the CLI
-spells them. `/v1/renderers` lists what exists and the current size ceilings;
-`/v1/healthz` reports which release is deployed. A mark is a pure function of
-its URL, so the answers are cacheable forever and are served that way.
-
-It runs the same code this repository publishes — the deploy compares every
-response against a `go run` of the same version, byte for byte, and refuses to
-ship on a disagreement. So the marks are the marks. What differs is the budget.
-
-`size` runs from 16 up, with a ceiling per format: **1024 for SVG, 128 for
-PNG**. The difference is not arbitrary. SVG emits geometry, and its cost is
-whatever the renderer's own maths costs — the requested size only ever reaches
-the `viewBox` attribute. PNG rasterises and deflates, so its cost grows with
-the square of the edge length.
-
-That is a limit of one small deployment, not of the library. `blazon.PNG` has
-no ceiling, no renderer is excluded, and rendering locally costs a few
-milliseconds:
-
-```go
-data, err := blazon.PNG("1.4.2", blazon.Options{Renderer: "flowfield", Size: 1024})
```
-Fetching a mark that has already been drawn is not rate limited; drawing a new
-one is, per address. Asking for a few hundred distinct marks in a burst will
-meet a `429`, and the answer to that is a `for` loop against the library rather
-than against the endpoint.
-
-The service is best effort and carries no availability promise. Anything that
-matters should render its own marks — that is the whole point of a library
-with no dependencies.
-
-## Gallery
-
-Generated by `blazon gallery` and verified in CI: if the committed images stop
-matching what the code renders, the build fails.
-
-
-
-
-
-### bishop
-
-Terminal renderer on a 17×10 character grid.
-
-
-
-Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
-
-
-
-### cistercian
-
-
-
-Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
-
-
-
-### dial
-
-
-
-Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
-
-
-
-### flowfield
-
-
-
-Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
-
-
-
-### orbit
-
-
-
-Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
-
-
-
-### polar
-
-
-
-Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
-
-
-
-### superformula (default)
-
-
-
-Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
-
-
-
-### truchet
-
-
-
-Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
-
-
-
-
-
-## Contributing
-
-A new renderer needs an entry in the threshold table and has to pass
-`blazontest.Suite`; see [CONTRIBUTING.md](CONTRIBUTING.md) for how to calibrate
-one.
+**No dependencies** — standard library only, including the rasteriser and the
+font. A library whose premise is that a version always produces the same mark
+cannot outsource the code that decides what the pixels are.
+[Why that matters](docs/how-it-works.md#no-dependencies).
+
+## Docs
+
+- **[Renderers and gallery](docs/renderers.md)** — the eight marks, and what
+ every version looks like in each.
+- **[How it works](docs/how-it-works.md)** — entropy streams, family policy, the
+ output back ends, and the distinctness test suite.
+- **[Hosted service](docs/service.md)** — the endpoint, its parameters and its
+ limits.
+- **[Contributing](CONTRIBUTING.md)** — a new renderer needs an entry in the
+ threshold table and has to pass `blazontest.Suite`.
## License
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
new file mode 100644
index 0000000..ecd69ac
--- /dev/null
+++ b/docs/how-it-works.md
@@ -0,0 +1,104 @@
+# How it works
+
+Three stages. Each one is a seam you can cut at.
+
+## 1 — Entropy
+
+Not `sha256("1.4.2")` in one piece. Four separated streams:
+
+```
+h0 = hash(major) archetype
+h1 = hash(major, minor) variant
+h2 = hash(major, minor, patch) detail
+h3 = hash(full version) prerelease
+```
+
+Fields are length-prefixed, so `1.23.4` and `12.3.4` cannot collide.
+
+## 2 — Parameter mapping
+
+A `Renderer` draws its parameters from those streams and decides what the mark
+*means*. This is the stage that determines whether the library is useful. Not
+every renderer hashes: `cistercian`, `dial` and `orbit` encode the number
+itself, so the version can be read back out of the mark.
+
+## 3 — Render primitives
+
+A `canvas.Canvas` turns drawing calls into SVG, pixels or terminal cells. This
+stage decides only how it looks. Every renderer works on every back end.
+
+## Policy: how related should neighbours look?
+
+An API choice, not a design decision:
+
+| Policy | Effect |
+| --- | --- |
+| `PolicyFamilyAmplified` (default) | Major sets the archetype, minor the variant, patch the detail — and each renderer routes two or three of its highest-contrast parameters through the patch stream, so adjacent patches stay in the family but are never hard to tell apart. |
+| `PolicyFamily` | The plain cascade. Patch changes only fine detail. |
+| `PolicyIndependent` | Every stream comes from the full version. Neighbours look unrelated: maximum distinguishability, no family information. |
+
+A prerelease never changes the core streams: `1.0.0-rc.1` still reads as a
+`1.0.0`, marked by a small band of dots.
+
+## The testable invariant
+
+Distinguishability is a test, not a claim. This is the part that separates
+blazon from any identicon port.
+
+Marks are rendered across a corpus of 184 versions, reduced to a 128-bit
+perceptual hash — a DCT hash plus a difference hash, because either alone
+confuses shapes the other separates — and the Hamming distances between related
+versions are held to per-renderer thresholds:
+
+```go
+blazontest.Suite{
+ Renderer: myRenderer,
+ Thresholds: blazontest.Thresholds{MinPatch: 6, MinMinor: 9, MinMajor: 20, Floor: 2},
+}.Run(t)
+```
+
+The suite checks:
+
+- **patch, minor and major neighbours** clear their minimum distances;
+- **no pair anywhere** in the corpus falls below the global floor;
+- **family cohesion** — versions sharing a major really are closer to each
+ other than to other majors, so the family policy is not a lie;
+- **prereleases stay close** to their release, bounded from above rather than
+ below;
+- **uniqueness** — no two versions render to the same bytes, exactly rather
+ than perceptually;
+- **determinism** — the same version renders identically, byte for byte where a
+ renderer declares it can.
+
+Measurement is monochrome on purpose. A luminance-based hash scores two marks
+differing only in hue as identical, so measuring in colour would credit colour
+for distinctness the form does not have. Judging form alone makes the guarantee
+hold in print, in a monochrome terminal, and for a colour-blind reader.
+
+Thresholds are calibrated from each renderer's own histogram, not guessed:
+
+```
+blazon dist -r truchet
+```
+
+## Output
+
+- **SVG** — plain string building, deterministic number formatting.
+- **PNG** — anti-aliased by exact area coverage: each edge deposits the signed
+ area it sweeps per pixel column, and a running sum along the row gives
+ coverage. Filling a shape and summing the alpha returns its geometric area,
+ which is how the anti-aliasing is tested.
+- **Terminal** — half-block characters give each cell two square pixels, so
+ vector renderers arrive as pictures rather than as ASCII art. Falls back to a
+ density ramp under `NO_COLOR`.
+
+Colour is derived in OKLCH with gamut mapping, held in a lightness band that
+keeps contrast on both a white page and a dark terminal.
+
+## No dependencies
+
+Standard library only — including the rasteriser and the font. Those are the two
+things that decide what the pixels are, and a library whose whole premise is
+that a version always produces the same mark cannot outsource them: a dependency
+that sharpened its anti-aliasing would change every mark ever generated,
+arriving as a routine version bump. A test enforces it.
diff --git a/docs/renderers.md b/docs/renderers.md
new file mode 100644
index 0000000..ea4875e
--- /dev/null
+++ b/docs/renderers.md
@@ -0,0 +1,97 @@
+# Renderers
+
+Every renderer works on every back end — SVG, PNG and terminal. Pick one with
+`Options{Renderer: "name"}` or `blazon -r name`.
+
+| Name | What it is | Readable? |
+| --- | --- | --- |
+| `superformula` | Gielis' superformula, drawn from curated shape families — star, flower, gear, crystal, leaf, shield. The default. | no |
+| `flowfield` | Streamlines through a Sherlock–Monro orientation field with fingerprint cores and deltas. Arch, loop and whorl come from the major version; minutiae emerge from the ridge spacing rule. | no |
+| `truchet` | Truchet tiles. Arcs meet at cell edge midpoints, so the marks join across the grid into loops and labyrinths. | no |
+| `polar` | A bitmap in rings and sectors instead of rows and columns, mirrored across a fold from the archetype stream, with a centre device in the hole. | no |
+| `cistercian` | Medieval numerals: one stave per version component, four digits per stave. | yes |
+| `dial` | One quadrant per component, the value written in binary as radial bands. The separator is the angle: the spokes are the dots of the version string. | yes |
+| `orbit` | One ring per component, major innermost, the value written in binary as cells running clockwise from noon. The separator is the radius. | yes |
+| `bishop` | The drunken bishop walk from OpenSSH randomart, for the terminal. | no |
+
+The readable ones encode the number itself rather than a hash, so the version
+can be read back out of the mark. They are marked `Ordered` and held to
+uniqueness rather than to the perceptual distance thresholds — being systematic
+is the point of them. See [how it works](how-it-works.md).
+
+## Gallery
+
+Generated by `blazon gallery` and verified in CI: if the committed images stop
+matching what the code renders, the build fails.
+
+
+
+
+
+### bishop
+
+Terminal renderer on a 17×10 character grid.
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
+### cistercian
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
+### dial
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
+### flowfield
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
+### orbit
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
+### polar
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
+### superformula (default)
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
+### truchet
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
+
diff --git a/docs/service.md b/docs/service.md
new file mode 100644
index 0000000..11547ad
--- /dev/null
+++ b/docs/service.md
@@ -0,0 +1,49 @@
+# Hosted marks
+
+If all you want is a mark in a README or a web UI, there is an endpoint for it —
+no build step, no asset pipeline:
+
+```
+https://api.blazon.build/v1/mark/1.4.2.svg
+https://api.blazon.build/v1/mark/1.4.2.svg?renderer=flowfield&bg=dark
+https://api.blazon.build/v1/mark/1.4.2.png?renderer=bishop&size=128
+```
+
+```html
+
+```
+
+`/v1/mark/{version}.{svg|png|txt}`, with `renderer`, `size`, `padding`,
+`policy`, `palette`, `bg`, `cols` and `nocolor` spelled exactly as the CLI
+spells them. `/v1/renderers` lists what exists and the current size ceilings;
+`/v1/healthz` reports which release is deployed. A mark is a pure function of
+its URL, so the answers are cacheable forever and are served that way.
+
+It runs the same code this repository publishes — the deploy compares every
+response against a `go run` of the same version, byte for byte, and refuses to
+ship on a disagreement. So the marks are the marks. What differs is the budget.
+
+## Limits
+
+`size` runs from 16 up, with a ceiling per format: **1024 for SVG, 128 for
+PNG**. The difference is not arbitrary. SVG emits geometry, and its cost is
+whatever the renderer's own maths costs — the requested size only ever reaches
+the `viewBox` attribute. PNG rasterises and deflates, so its cost grows with the
+square of the edge length.
+
+That is a limit of one small deployment, not of the library. `blazon.PNG` has no
+ceiling, no renderer is excluded, and rendering locally costs a few
+milliseconds:
+
+```go
+data, err := blazon.PNG("1.4.2", blazon.Options{Renderer: "flowfield", Size: 1024})
+```
+
+Fetching a mark that has already been drawn is not rate limited; drawing a new
+one is, per address. Asking for a few hundred distinct marks in a burst will meet
+a `429`, and the answer to that is a `for` loop against the library rather than
+against the endpoint.
+
+The service is best effort and carries no availability promise. Anything that
+matters should render its own marks — that is the whole point of a library with
+no dependencies.