Bring the tutorial up to the interface that exists, and check its code in CI - #3
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
docs/tutorial.htmlhad 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
logical-size.gsis introduced before the entry file that uses it.bevel.darkgrid 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.gsand the headless--shotflag, 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:
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.pyextracts 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
editare 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
maineven though their content is (git diffbetween 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