Skip to content

<!-- Authored per the the-loop:writing skill. -->

Design: a logo for the-loop — the Ensō, chosen and adopted ​

Phase 2 of 4. Derives from the approved requirements.md. The visual artifacts themselves live in design/ per reference/design-artifacts.md; this document says how they are made and why. Round 4 — rounds 1 and 2 (five structures; five personalities), round 3 (the Ensō in colourways) and this document's earlier versions are in git history; the owner's reviews that turned it, and the pick, are recorded under Review comments. The chosen colourway is 5 — dusk · sage · ink; § Adoption says where it went.

Overview ​

One mark, one brush, ten colourways. The owner chose the Ensō from round 2 — three brush circles, each left open where the hand lifted: the outer loop, the inner loop, and the one between — and asked for colour and gradient options. Every file is produced by design/generate.py, a stdlib-only script: the three strokes' geometry is fixed (the round-2 Ensō, byte for byte in its original colours) and only the paint varies. Nothing is traced, filtered or font-dependent, so a colourway renders the same in a browser, a README, a favicon and an image tool, and a different pairing is a line in one table and a re-run (R3.3).

#ColourwayOuter · middle · innerKind
1ink · dusk · clayink · dusk · clayflat — the round-2 original
2inkink · ink · inkflat — one colour, follows the ground's ink
3ink · ink · clayink · ink · clayflat — one warm centre
4clay · ochre · inkclay · ochre · inkflat — warm outside, ink centre
5dusk · sage · inkdusk · sage · inkflat — cool outside, ink centre
6sage · ochre · claysage · ochre · clayflat — earth, no ink
7mauve · dusk · claymauve · dusk · clayflat — the quieter trio, no ink
8dusk → clay, along the strokeeach ring dusk where the brush lands, clay where it liftsgradient travelling with the brush
9ink → accent, running dryink → dusk · ink → clay · ink → ochregradient travelling with the brush
10dusk → clay, across the markone linear gradient, top-left to bottom-rightspatial gradient

Architecture ​

There is no runtime. Two scripts and their outputs, all under docs/specs/issue-398/design/:

FileRoleSource or output
generate.pythe generator and its own checkersource
enso-{1..10}-*.svgthe ten standalone colourwaysoutput
logo-options.htmlthe self-contained gallery (R4.1)output
screenshots.mjsrenders the evidence with the Chromium Playwright drives; --assets renders the adopted mark's rasters; --site captures the built docs sitesource
screenshots/*.pngthe rendered evidence (R4.2, R5)output
../../../assets/the-loop-logo{,-light,-dark}.svg, ../../../public/{favicon,logo-light,logo-dark}.svg, ../../../../ui/public/favicon.svgthe adopted mark, written outside the design folder by the same generator and pinned by the same --checkoutput
../../../assets/the-loop-logo-1024.png, ../../../public/apple-touch-icon.pngthe adopted mark as rasters (screenshots.mjs --assets)output

The generator is the canonical artifact; the SVGs and the gallery are what it makes, and --check refuses to pass when the files on disk differ from a fresh render. Editing an SVG by hand is therefore a lint failure, which is the intent: iteration happens in the parameters (reference/design-artifacts.md — "edits to the checked-in artifact, not new copies"). Each round was exactly that; round 3 changed the paint layer and left the brush and the geometry alone.

Components & interfaces ​

The mark (generate.py, top half):

  • RINGS — radius, the angle the brush lands at, and the base width of each circle (100 / 65 / 33; 118° / 250° / 20°; 16 / 13 / 9.5), each drawn over ARC = 315° of its circle; the gap is where the hand lifted. ring_curves() builds the three circle curves and their seeded Wobble (0.6 units — a hair of warmth) in a fixed order, so every colourway has the identical hand.
  • enso_brush(base) — the one-breath profile: down heavy at the start, a slight press, then thinning to a dry tail over the second half; a flat nib at 15° adds a little thick/thin.
  • trace, edges, outline — samples, the left/right edges of the stroke (half-width clamped to 0.85 × the local radius of curvature so an offset never folds back), and the whole-stroke outline as Catmull-Rom Béziers, filled nonzero.

The paint:

  • flat_ring(…, fill) — one outline, one fill; "ink" becomes currentColor, so it follows the ground (color on the SVG root, swapped by the dark-scheme rule).
  • travelling_ring(…, c0, c1) — the same stroke in CHUNKS (20) pieces. The pieces are cut from one set of edges along the stroke's own Bézier segments (segment, piece), with a straight cut across and a one-sample overlap, so their edges coincide exactly and no seam shows. Piece k is filled with mix(c0, c1, (k+½)/20) — a blend in OKLab (to_oklab / from_oklab / mix), so the ramp is perceptually even and never muddies through grey. A piece whose light colour comes from the ink also gets a dark-scheme colour from the slate ink, carried by a class the file's stylesheet maps under prefers-color-scheme: dark (and the gallery's .board.slate).
  • The sweep — one <linearGradient gradientUnits="userSpaceOnUse"> from (36, 36) to (220, 220), referenced by all three strokes as fill="url(#…)".

Variants: VARIANTS — ten Variant(slug, name, note, rings, sweep) rows; rings is a paint per ring (a colour, "ink", or a (start, end) pair for a travelling gradient), sweep a (start, end) pair for the spatial one. Adding a colourway is one row.

Output and checker: svg_file(v) (standalone, currentColor ink, one dark-scheme rule plus the travelling pieces' dark fills, <title>/<desc> with ids unique per variant); svg_symbol/svg_use for the gallery's one-definition-many-instances (the sections carry -card ids so no id is shared); gallery(variants); check(files) with svg_problems() — the parsed-tree allowlist, extended for round 3 by linearGradient and stop and the attributes a gradient needs — see Error handling. Round 4 adds target(name), which leaves the design folder only when REPO_MARKER (.the-loop/harness-config.yaml) sits at the resolved root, and png_problems(), the two rasters' structural check.

UI/UX design ​

The visual artifacts, per design.uiArtifacts (dir: design, format: html, selfContained: true, screenshotEvidence: true). No Figma: the generated SVG is the source (agent-led work — reference/design-artifacts.md § Figma ↔ code).

ArtifactTypeLocation / linkCoversStatus
Colourway 1 — ink · dusk · claysvg (generated)design/enso-1-ink-dusk-clay.svgR1–R3not chosen (kept as the record)
Colourway 2 — inksvg (generated)design/enso-2-ink.svgR1–R3draft (round 3)
Colourway 3 — ink · ink · claysvg (generated)design/enso-3-ink-ink-clay.svgR1–R3draft (round 3)
Colourway 4 — clay · ochre · inksvg (generated)design/enso-4-clay-ochre-ink.svgR1–R3draft (round 3)
Colourway 5 — dusk · sage · inksvg (generated)design/enso-5-dusk-sage-ink.svgR1–R3, R5approved — the owner's pick (review comment, 2026-09-21); adopted, § Adoption
Colourway 6 — sage · ochre · claysvg (generated)design/enso-6-sage-ochre-clay.svgR1–R3draft (round 3)
Colourway 7 — mauve · dusk · claysvg (generated)design/enso-7-mauve-dusk-clay.svgR1–R3draft (round 3)
Colourway 8 — dusk → clay, along the strokesvg (generated)design/enso-8-dusk-to-clay.svgR1–R3draft (round 3)
Colourway 9 — ink → accent, running drysvg (generated)design/enso-9-running-dry.svgR1–R3draft (round 3)
Colourway 10 — dusk → clay, across the marksvg (generated)design/enso-10-sweep.svgR1–R3draft (round 3)
The galleryhtml-prototype (generated, self-contained, no script)design/logo-options.htmlR4.1draft (round 3)
Rendered stillspngdesign/screenshots/ — gallery-light.png, gallery-dark.png, enso-N-*-paper.png, enso-N-*-slate.pngR4.2, R3.2, R3.4evidence (round 3)
Round 2 — loop, Knot, Flick, Clip (and the Ensō in its first file)svg (generated)git history of design/ before round 3R1–R3superseded — the Ensō chosen, "remove all others" (review below)
Round 1 — Gimbal, One line, Coil, Rings, Planetarysvg (generated)git history of design/ before round 2R1–R3superseded — the round-1 review
  • Flows & states: no flow — one static mark in ten colourways, each shown in the states a logo has: on paper, on slate, at 96 / 48 / 24 / 16 px, and in a lockup beside the name.
  • Design system / tokens: one palette (R2.2, R2.3) — warm ink #3f3a33 (on slate #e9e3d7), sage #8a9c84, dusk #7f93a5, clay #c48a6c, ochre #c7a86b, mauve #9d8a9f, paper #f5f0e6, slate #22252a; every accent is used by at least one colourway. Flat ink is currentColor; accents are mid-toned so they read on either ground without changing; a travelling gradient that touches ink carries a second, dark-scheme set of piece fills. A consumer that inlines a file into a page (rather than embedding it as an <img>) drops the file's <style> and sets color (and, for colourway 9, the piece fills) itself — as the gallery does — since the rules are written for the file as a document.
  • Accessibility & responsiveness: every SVG carries <title>/<desc> and role="img" (R3.4). Contrast: ink on paper 9.9:1, ink-on-slate on slate 12.0:1; the accents sit at 2.0–2.8:1 on paper and 4.8–6.8:1 on slate — decorative loops, never text; the ink-free colourways (6, 7, 8, 10) rely on the accents alone and are the ones to weigh against a dark ground. The gallery is fluid (auto-fit grid) and follows the OS scheme.
  • Evidence: design/screenshots/, produced by design/screenshots.mjs (testing-plan.md T5).

Adoption ​

One generated source, every surface. The chosen colourway is written by generate.py as brand assets outside the design folder — the same script, the same --check — so the adopted mark cannot drift from the option that was picked. Rasters, for the two surfaces that take no SVG, come from screenshots.mjs --assets and are held by --check to a structure — a plain PNG of the expected size, the three critical chunks and nothing more — rather than a hash, since a Chromium render is not byte-identical across builds.

FileInkFor
docs/assets/the-loop-logo.svgcurrentColor + the dark-scheme ruleanything that follows the OS scheme; the canonical asset
docs/assets/the-loop-logo-light.svg / -dark.svgfixed #3f3a33 / #e9e3d7GitHub's <picture> sources in the root README
docs/public/favicon.svgas the canonicalthe docs site's favicon (head)
docs/public/logo-light.svg / logo-dark.svgfixedthe site's nav logo and hero image, switched by VitePress's own theme toggle
docs/public/apple-touch-icon.png (180 px)raster on paperthe site, for Safari and home screens
ui/public/favicon.svgas the canonicalthe control-plane dashboard (ui/index.html)
docs/assets/the-loop-logo-1024.pngraster on paper, 8 % insetthe Slack app icon (uploaded by hand — Slack's manifest carries no icon) and the CLI's PyPI README
SurfaceChangeWhy this form
README.mda centred <picture> before the title: dark source for prefers-color-scheme: dark, light <img> otherwiseGitHub's documented way to show an image per theme; a self-switching SVG would follow the OS, not GitHub's theme
docs sitehead: favicon + apple-touch icon (base-prefixed by hand — VitePress does not prefix head); themeConfig.logo and hero.image with light/dark filesthe site's toggle, not the OS, must drive the mark
cli/README.mdthe 1024 px raster from main, 128 px widePyPI renders no SVG from a repository
ui/index.html<link rel="icon" href="favicon.svg">, relativeresolves under the dashboard's /the-loop/ui/ base and in dev alike
docs/guide/slack.mdthe icon-upload step under 1. Create the Slack appthe one adoption a human must do; the manifest format has no icon
docs/capabilities/documentation.md, channels.mdThe mark section; history rowsthe organized view of the specs

Why light/dark files and not the self-switching SVG everywhere. The standalone SVG's media rule reads the operating system's scheme. GitHub and the docs site each let a reader pick a theme independently of the OS, so on those surfaces a fixed-ink file per theme, chosen by the surface, is the only form that always agrees with the page. Where nothing but the OS decides (a favicon tab, an <img> on a plain page), the self-switching file is the right one and needs no pair.

Not in scope of the adoption: the plugin manifests (neither format has an icon field), the Slack manifest's background_color (pinned by tests and mirrored in the guide; a cosmetic later touch), and any wordmark type.

Data models ​

None persisted. The geometry is three rows of numbers and the paint is one table:

RingRadiusLands atBase widthArc
outer100118°16315°
middle65250°13315°
inner3320°9.5315°

Brush: enso_brush (nib 15°, contrast 0.2); wobble 0.6 units, seed 398_5; 200 samples per stroke; travelling gradients in 20 pieces. Paint per colourway: the VARIANTS table in generate.py (and the Overview above). All on a 256×256 viewBox.

Error handling ​

python3 generate.py --check is the only failure surface, and it fails closed:

ConditionResult
a generated file is missing or differs from a fresh renderFAIL <file>: differs … (re-run it), exit 1
an SVG is not well-formed XML, or lacks <title> or <desc>FAIL, exit 1
an SVG's parsed tree holds an element or attribute outside the allowlist (svg, title, desc, style, path, linearGradient, stop; viewBox, color, role, aria-labelledby, id, d, fill, class, gradientUnits, x1, y1, x2, y2, offset, stop-color), a url( reference that is not a fragment, or a stylesheet that imports or references anythingFAIL, exit 1
the gallery contains <script, <foreignObject, <image, <iframe, @import, src=, javascript: (case-insensitively), or an href/url( that is not a #fragmentFAIL, exit 1
an SVG exceeds 64 000 bytesFAIL, exit 1
a raster is missing, not a PNG, not the expected square, carries a chunk beyond IHDR/IDAT/IEND, or has bytes after IENDFAIL, exit 1
the script runs from a copy outside the-loop's tree (no .the-loop/harness-config.yaml at the resolved root) and would write beyond its folderSystemExit naming the missing marker, before any write
two renders in one process differ (non-determinism)FAIL generate.py is not deterministic, exit 1

screenshots.mjs fails loudly when Playwright or Chromium is absent (an import error / launch error); it never writes a partial set silently. With --site it exits (code 2) before launching a browser unless the URL's host is loopback.

Security design ​

No trust boundary exists to enforce: no input, no runtime, no network. The one mechanism is the checker above, which pins the requirements' abuse case — an SVG embedded by a consumer executes and loads nothing — as a repeatable command rather than a reading of the files; it reads each SVG's parsed tree, not its text, so a construct it does not know fails whatever its spelling. Round 3 adds two constructs: <linearGradient>/<stop> (elements the allowlist now admits, with only the attributes a gradient needs) referenced by fill="url(#…)" — an internal fragment, which is what the checker's reference rule admits and everything else it refuses — and class attributes on the travelling pieces, mapped to fills by the file's own stylesheet (which may not @import or reference anything outside the file). screenshots.mjs renders local files (file:// and data: URIs built from them) and, with --site, the docs preview on a loopback host only; it is run by a person, never by CI or the daemon; Chromium's sandbox is disabled only when CHROMIUM_NO_SANDBOX=1 is set. No secret, hostname or personal datum can appear in the outputs because the generator has no inputs; the screenshots show only the generated content. Round 4's review (evidence/security-review.md) added the root guard on target() and the rasters' structural check: the adopted files outside the design folder are written only into the-loop's own tree, and the two PNGs that ship as icons carry nothing beyond image data.

Testing strategy ​

There is nothing to unit-test in the classical sense and nothing to integrate: the proof is (a) the checker — determinism, well-formedness, self-containment (a parsed allowlist, probed with planted constructs), titles, size — run as a command; (b) the rendered evidence, captured by the screenshot script in both schemes and at the sizes R3.2 names; and (c) the owner looking at the rendering (reference/design-artifacts.md — "render, don't read"), which three rounds have shown is the test that matters. The lint the repository already runs (markdownlint on the spec, ruff on the generator by hand since the hook scopes to cli/) is the rest. Rows and commands in testing-plan.md.

Trade-offs & decisions ​

  • Pieces, not a gradient element, for colour that travels along a stroke. SVG has no gradient that follows a path (no conic gradient either), so a travelling ramp is the stroke cut into pieces, each one shade further along. Cost: a 48–53 kB file instead of 42 kB and twenty paths per ring instead of one. The pieces are cut on the stroke's own Bézier segments so the seams are invisible — the first cut (a fresh outline per piece) stepped at every seam, the second (a closed spline per piece) rounded every corner; both are in the self-review.
  • OKLab for the blends. A straight sRGB blend between dusk and clay passes through a dull grey; OKLab keeps the ramp perceptually even and the mid-tones alive.
  • A real <linearGradient> for the sweep, because a spatial gradient is what the element is for; it is portable everywhere the files are expected to render, and the checker's allowlist was widened by exactly the two elements and seven attributes it needs.
  • Ink stays currentColor; ink in a gradient gets explicit dark fills. A blend cannot be a currentColor, so colourway 9 carries a second, dark-scheme colour per piece through classes — the same mechanism the gallery's slate boards use. The ink-free colourways need neither.
  • Filled outlines, baked geometry, confidence over wobble, <symbol>/<use> in the gallery — unchanged from rounds 1 and 2 and for the same reasons.
  • Not delivered here, on purpose: raster exports, a favicon set, a wordmark typeface, the draw-on animation the Ensō invites (a <mask> revealing each stroke in turn). All belong to adoption, after the pick (requirements.md § Out of scope).

No durable decision beyond this work item — nothing under docs/decisions/.

Open questions ​

One, and it is the deliverable's purpose: which colourway? Raised on the ticket and the PR with the gallery and the SVGs (R4.3). The answer — or another pairing — lands as a ticket or PR comment and becomes one row in VARIANTS and a re-run.

Review comments ​

  • 2026-09-20 · @MadaraUchiha-314 (owner, designer) · PR #403 review — "I think you over stressed on the hand drawn part. there's no character to these logos." Folded in as round 2: the wobble and the sketch overdraw gave way to a brush with pressure and nib contrast; five marks with a personality each replaced the five structures; the size budget rose to 64 kB. Round 1's marks are in git history, marked superseded above.
  • 2026-09-21 · @MadaraUchiha-314 (owner, designer) · PR #403 review comment on option-5-enso.svg — "I am leaning towards this. Remove all others. Present some more colors and gradient options for this." Folded in as round 3 (this version): the Ensō is the mark; the other four round-2 marks are removed (git history keeps them); the generator's option layer became a paint layer — seven flat palettes, two gradients travelling along the strokes, one sweep across the mark — with the geometry unchanged. requirements.md records the change to R1 (one mark, colourways, not three-to-five concepts).
  • 2026-09-21 · @MadaraUchiha-314 (owner, designer) · PR #403 review comment on enso-5-dusk-sage-ink.svg — "I choose this. Update all documentation and the github README to have this. The slack app also must have this as it's logo." The pick, and the adoption — round 4 (this version): colourway 5 marked approved in the inventory; the generator writes it as brand assets under docs/assets/, docs/public/ and ui/public/; the README, the docs site, the PyPI README, the dashboard and the Slack guide carry it (§ Adoption); requirements.md gains R5. The Slack icon upload is the owner's manual step, described in the guide.

Released under the MIT License.