Skip to content

Bring the tutorial up to the interface that exists, and check its code in CI - #3

Merged
kaidesu merged 6 commits into
mainfrom
claude/lumen-aseprite-gui-library-nq5qnc
Sep 4, 2026
Merged

kaidesu merged 6 commits into
mainfrom
claude/lumen-aseprite-gui-library-nq5qnc

Conversation

@kaidesu

@kaidesu kaidesu commented Sep 4, 2026

Copy link
Copy Markdown
Member

docs/tutorial.html had spent two rebuilds describing an interface the repository no longer had. It carried a notice saying so, which is better than lying quietly but is not a fix.

The teaching change

The old Part 04 taught a bevel — a light line on the top and left, a dark one on the bottom and right, swapped to make the same box read as a hole — and called it "the entire 3D vocabulary of this interface".

It is the obvious way to build a retro interface and it is not what Aseprite does. A button there is a flat fill with one pixel off each corner and no border of any kind; only windows are outlined. So the chapter now teaches chamfered, surface, and a tone ramp — and says plainly that the earlier version was wrong, since a reader who saw the old one deserves to know which way round it went.

The Button chapter follows: pressed is no longer a bevel inversion but a step down the ramp, which imposes a rule the bevelled version could ignore — a control is read entirely by its tone against its ground, so there must always be a step between them. Get that wrong and the control does not look flat, it looks absent.

Everything else that had rotted

  • Theme — Catppuccin by named lookup rather than hex literals, with a note on why the palette had to be identified rather than measured (the references were lossy enough that measurement was impossible, but five values survive at distance 0.0, which is enough to name it).
  • Part 00 now teaches the framebuffer, since it is the decision everything else rests on and a 12px menu bar means nothing without it. logical-size.gs is introduced before the entry file that uses it.
  • The page's own mockup of the workspace is flat Catppuccin now, with no inset highlight anywhere in it.
  • Old theme name, old hex values, and the bevel.dark grid colour all corrected. Zero stale references remain outside the page's own CSS.

New Part 09: the playground

Eight parts in, the old order had the reader build widgets and not look at one until Studio arrived at Part 10. That is backwards. Every serious fault in this project was found by rendering something and measuring pixels, never by reasoning about code.

The new part builds playground.gs and the headless --shot flag, and lists three faults that were invisible until a gallery existed to show them — heading text that vanished on toolbar grey, buttons that disappeared when their outlines were removed, and a one-pixel colour fringe caused by line primitives and rectangles disagreeing about pixel snapping.

Parts 09–16 shift to 10–17; ids, labels and every table-of-contents link were renumbered and verified.

A papercut worse than the one beside it

The log gains the one this rebuild found. The existing entry covers a method name shadowing an import. This one is nastier:

class Probe {
  gap() { return 7 }
  first() { gap = 99; return gap }
  second() { return this.gap() }
}
p = new Probe(); p.second(); p.first(); p.second()

prints 7, then raises. A local does not shadow a method for a call — it destroys that method on the object permanently, from any other method. It is time-dependent, so whether a test catches it depends on call order. It shipped twice: the second time after a linter had been written for it, because the first version of that check only looked in the same method as the local.

The code is checked now

tools/check-tutorial.py extracts every whole-file code block and parses it; CI runs it on every commit.

A tutorial whose premise is "type this" fails worst when the code has quietly stopped working, because the reader cannot tell whether they mistyped it or the page is wrong. Prose going stale is hard to catch automatically; code going stale is not, and this catches that half. All 38 whole-file blocks parse, verified against a deliberately broken one. Blocks marked edit are skipped — they are fragments and cannot parse alone.

Note on the branch

PR #2 was squash-merged, so its four commits are not ancestors of main even though their content is (git diff between them is empty). Rather than force-push over that, this branch merges the old head — bringing the history forward without changing a single file — so the push fast-forwards.

Checks

132 assertions, a clean linter, 38 tutorial blocks parsing, all eight Picotron pixel tiles matching.

🤖 Generated with Claude Code

https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM


Generated by Claude Code

The references are Aseprite themed with Catppuccin - the file in them is called
catppuccin.ase - so this is two separable things, and worth naming which is
which. The metrics are measured off Aseprite: a 12px menu bar, an 11px tab, a
17px tool options row, 15px tool slots in an 18px column, an 80px timeline, a
15px status bar, all at 1x with the interface magnified twice on screen. The
colours are Catppuccin, which is a theme someone put on Aseprite rather than
Aseprite's own.

They arrived as lossy WebP: thirty thousand colours in a screenshot of an
interface that uses about thirty. Nothing can be recovered from that the way
Picotron's palette was recovered, because the damage is not a function - the
same true colour comes back differently depending on what surrounds it.

It does not have to be recovered, only identified. Catppuccin is published, and
five of its values survive the compression at distance nought - #1e1e2e,
#eff1f5, #dce0e8, #fe640b, #a6e3a1 - with the rest landing within 1 to 3. What
was uncertain was which palette it is, not what the palette contains.

The framebuffer target is a parameter now rather than 270. Aseprite's chrome
fills 960x540 at 1x and the references magnify it twice; Picotron's filled
480x270. Hard-coding either renders the other at half or double its intended
density, and both themes are kept.

The control icons are redrawn at 16x16 with two tones. Aseprite's are not flat
silhouettes - the pencil has a lit body and a dark tip - and the second tone is
half alpha in the same sheet, so tinting multiplies it down to a shade and one
sheet still serves every theme and state. Four of them were redrawn twice: the
first bucket read as a diamond and the first eraser as a second pencil, which
only rendering the sheet showed.

The linter now refuses a raw hex in a theme that claims a palette. Forty
hand-picked colours that only nearly agree is most of what "messy and
inconsistent" meant, and a colour two off a real entry looks fine alone and
wrong beside everything else. Both listed exceptions are measured facts:
Aseprite paints its transparency checker itself, in the same two greys, under
both the light and the dark reference.

121 assertions pass, the linter is clean, and Picotron's eight pixel tiles
still match - that theme stays first-class and verified, it is just no longer
the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
…rstated

The left panel now runs palette, saturation-value field, hue strip, foreground
and background chips - Aseprite's order, and the right way round: the palette
is what you use constantly and belongs where the eye lands, the picker is for
when the palette has not got the colour, the chips record the last two
decisions.

This is the first thing in Studio that constructs a colour rather than choosing
one from a fixed set, so it is the first that needs HSV. The conversion is a
pure function with tests on all six sector boundaries and on the clamping,
which matters because a drag that runs off the field hands back a component
slightly out of range and unclamped that wraps 256 to black - the picker would
appear to break exactly when the pointer left it.

The field is cached in an off-screen Target. Drawn per pixel per frame it is
six thousand rectangles sixty times a second to produce an image that only
changes when the hue does.

Two rendering faults, neither visible in the code. The Target blit came out a
near-black smear because a blit is multiplied by the current draw colour and
the last thing set was the well's own background. And the marker ring hung two
pixels above the gradient, because full saturation and value puts its centre on
the top-right pixel - which is the default, so it was wrong on first sight.

The important part is the papercut. I described it in an earlier commit as "a
local whose name matches a method of the same class shadows that method for the
whole call". That is wrong twice over, and the narrow rule let a second
instance straight through. Reduced:

    class Probe {
      gap() { return 7 }
      first() { gap = 99; return gap }
      second() { return this.gap() }
    }
    p = new Probe(); p.second(); p.first(); p.second()

prints 7, then raises. A local does not shadow a method for a call - it
destroys that method on the object permanently, from any other method, and the
same call works before the poisoning line runs and fails after. It is
time-dependent, so testing cannot be relied on to surface it.

The linter now flags any local sharing a name with any method of its class,
which found the one this rule was rewritten for and one more: fillRounded
reassigned its `radius` parameter, which would have destroyed painter.radius()
- called by Window and Colorbar for their corners - for the rest of the
session. It is latent rather than live only because the Picotron rebuild left
fillRounded with no callers.

132 assertions pass, the linter is clean, Picotron's eight tiles still match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
…ld them

Each layer row now carries a visibility eye and a lock, to the left of the
name rather than after it - Aseprite's order, and the useful one, because the
eye is what gets clicked most and belongs where the pointer already is instead
of after a name of unknown length. Both are drawn dim when off rather than
hidden: a control that vanishes when inactive cannot be turned back on by
anyone who has not already learnt it is there.

Adding them reintroduced, exactly, the fault that made the old Picotron icon
sheet unusable - an icon taller than the row containing it. A 16px eye in a
12px slot drew four pixels wider than its box and overlapped the lock beside
it. So a timeline row is now derived from the icon rather than from the text
row, and the frame header and cell grid derive from the same number so they
stay aligned with the layer rows instead of drifting by four pixels each.

Knowing the shape of that bug from the last rebuild did not stop me writing it
again; only rendering it did.

The menus are deliberately left as File, Edit, View and Tools rather than
matched to Aseprite's eight. There are no Sprite, Layer, Frame or Select
commands to put in them, and a menu that opens onto nothing is worse than one
that is not there.

132 assertions pass, the linter is clean, Picotron's eight tiles still match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
tools/make-icons.py holds the art as ASCII so that changing an icon is a
readable diff rather than a binary blob nobody can review, and the PNGs under
resources/ are build output. That is only true while the two agree. A
hand-edited PNG would quietly become the real source and leave the ASCII a
decorative lie that still looks authoritative in review.

So CI regenerates and fails on any difference. Verified by committing a PNG
with one pixel poked and watching the check go red - the first attempt at that
test was wrong, because poking the working file and regenerating simply
overwrote the poke, which proves nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
…e in CI

The page had spent two rebuilds describing an interface the repository no
longer had: bevelled surfaces with a highlight-and-shadow pair, circular corner
profiles, free hex colour per theme, and rendering at the window's own
resolution. It carried a notice saying so, which is better than lying quietly
but is not a fix.

The teaching change is the painter's vocabulary. The old chapter taught a bevel
- a light line on the top and left, a dark one on the bottom and right, swapped
to make the same box read as a hole - and called it "the entire 3D vocabulary
of this interface". It is the obvious way to build a retro interface and it is
not what Aseprite does: a button there is a flat fill with one pixel off each
corner and no border of any kind, and only windows are outlined. So the chapter
now teaches chamfer, surface, and a tone ramp, and says plainly that the
earlier version was wrong and how you would tell.

The theme chapter follows: colours come from a named Catppuccin lookup rather
than from anyone's judgement about what shade a toolbar should be, which is
worth a paragraph because the reference screenshots were lossy enough that the
palette had to be identified rather than measured.

Part 00 now teaches the framebuffer, since it is the decision everything else
rests on and a 12px menu bar means nothing without it.

There is a new Part 09: the playground. Eight parts in, the old order had the
reader build widgets and not look at one until Studio arrived at part 10, which
is exactly backwards - every serious fault in this project was found by
rendering something and measuring pixels, never by reasoning about code. That
part builds the gallery entry point and the headless screenshot flag, and lists
three faults that were invisible until a gallery existed to show them.

The papercut log gains the one this rebuild found, which is worse than the
import-shadowing entry beside it: a local does not shadow a same-named method,
it destroys that method on the object permanently, from any other method. It is
time-dependent, so whether a test catches it depends on call order. It shipped
twice - the second time after a linter had been written for it, because the
first version of that check only looked in the same method as the local.

And the page's own mockup of the workspace is flat Catppuccin now, with no
inset highlight anywhere in it.

tools/check-tutorial.py extracts every whole-file code block and parses it, and
CI runs it. A tutorial whose premise is "type this" fails worst when the code
has quietly stopped working, because the reader cannot tell whether they
mistyped it or the page is wrong. Prose going stale is hard to catch
automatically; code going stale is not, and this catches that half. All 38
blocks parse; verified against a deliberately broken one.

132 assertions, a clean linter, all eight pixel tiles matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
PR #2 was squash-merged, so its four commits are not ancestors of main even
though their content is. Merging the old branch head brings the history forward
without changing a single file - git diff between them is empty - which lets
this push fast-forward instead of needing a force.

# Conflicts:
#	.github/workflows/ci.yml
@kaidesu
kaidesu marked this pull request as ready for review September 4, 2026 05:35
@kaidesu
kaidesu merged commit 30fad03 into main Sep 4, 2026
2 checks passed
@kaidesu
kaidesu deleted the claude/lumen-aseprite-gui-library-nq5qnc branch September 4, 2026 05:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants