Rebuild the interface on Picotron: palette, pixel verification, flat surfaces, a real framebuffer - #1
Merged
Conversation
… screenshots The pixel-matching tool had a flaw that would have invalidated every measurement taken with it. Three of the five reference screenshots come through a capture pipeline that darkens every channel by up to 9 - #fff1e8 arrives as #f6eee6 - so a pixel-perfect reproduction scores 48.8% against them, and the failure looks exactly like a broken renderer. Snapping each colour to its nearest palette entry is the obvious fix and a worse bug: it fails silently on a few pixels rather than loudly on all of them. Measured against real capture pairs it reads #6f478e as #654688 when it is really a shifted #754e97 - those purples sit 23.3 apart and the shift moves colours up to 15.6, so the nearest entry is the wrong entry. Both are window chrome, so the corruption lands where the matching matters. So the transform is applied rather than guessed at. Seventeen colours appear in both an accurate and a shifted capture, giving a measured true -> captured channel map that is a function and monotonic, so it inverts. The tool now derives a shifted twin of the palette, detects which profile fits each image, and matches within it. All seventeen pairs recover correctly, and a shifted reference against its canonical twin scores 100%. Inverting that map also recovered the rest of the palette. A palette-file thumbnail in one reference turns out to be a 5x5 grid that is not an arbitrary set of colours but five ramps of five, dark to light, with PICO-8's sixteen scattered through them. Twenty-five ramp entries plus seven confirmed colours whose ramp position was never shown is thirty-two, the size Picotron documents - so the set is complete rather than as much as could be found. Adding the sixteen recovered entries dropped one reference's palette fit from 3.10 to 0.19, which is the independent check that they are right. The ramps matter more than the list. A ramp is how a surface gets its states: a face is a step, hovered is the step above, pressed is the step below. That makes "one step lighter" a rule the whole interface shares instead of forty hand-picked colours that only nearly agree. The data and the clamping live in support/ with no `lumen:` import so the tests can reach them, leaving palette.gs holding only the part that genuinely needs the engine. That split immediately earned itself: it caught `steps.length` used as a property where Ghost wants `length()`, which would have crashed on the first repaint. 97 assertions pass; the linter is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
…uild There is now a check that can see a pixel. tools/verify.sh renders verify.gs through Xvfb at Picotron's 480x270 framebuffer, reads the frame back, and compares eight chrome regions against crops of Picotron's own screenshot. All eight match exactly. The gate is real rather than decorative: changing the corner chamfer from 2px to 3px fails five of the eight tiles and exits non-zero. That was tested by doing it. It earned itself immediately. The first render came back at 88.8% on the top-left corner, and the reason was a bug no test would ever have found: the chamfer was applied to all four corners of the title bar, so its last row was inset by one where Picotron's is square, leaving a one-pixel notch against the rule below. `chamfered()` now takes [topLeft, topRight, bottomRight, bottomLeft]. Per-corner control is not a refinement here, it is the difference between right and wrong. Two findings behind the geometry, both measured rather than assumed: Picotron's corner is a chamfer, not a curve - a straight 45-degree cut giving back exactly one pixel a row, profile [2, 1] on every window in every reference. It gets its own helper rather than a flag on cornerInsets(), because the two shapes disagree from radius 3 upward and neither should be bent into approximating the other. The surface is a flat fill inside a one-pixel outline, with the inner shape deflated by one and cut one less deeply - which is what keeps the outline an even thickness along the diagonal instead of pooling at the corner. There is no bevel anywhere in any reference, and adding one is the single change that would most obviously mark this as an imitation. The outline colour is a theme role, not a derived rule. One window uses #ff77a8 against a #ffacc5 title, exactly one ramp step apart, which looked like a law worth having; another uses black against the same title fill. Two windows, same fill, different edges - so the ramp relationship is offered to themes rather than imposed on them. 103 assertions pass, the linter is clean, and CI now runs the pixel job too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
Picotron uses two icon languages, and reading them as one is why the old sheet could not have worked. Control icons - tools, toolbar buttons, arrows - are monochrome 7x7 silhouettes in a single tint, drawn on the toolbar's own ground with no outline. File icons - folder, document, cartridge - are full-colour 15x16 pictures with a 1px #1d2b53 outline and two fill tones. One is tintable and one is not, so they are now two sheets. The size mattered as much as the style. Picotron's control icons are 7x7 in an 8x8 cell against a 12px row; the old sheet used 16x16 cells, so every icon was taller than the row containing it. No amount of redrawing would have fixed that, and it is most of why the interface read as messy. The pointer is reproduced exactly rather than approximated: Picotron's is a hollow outline, not a filled arrow with a border, so every interior pixel is whatever is behind it. Measured off the reference and confirmed at 100% by pixelmatch. The grid toggle is likewise Picotron's own, pixel for pixel. The art now lives in tools/make-icons.py as ASCII and the PNGs are build output, so changing an icon is a readable diff instead of a binary blob nobody can review. 103 assertions pass, the linter is clean, and all eight pixel tiles still match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
The full-resolution capture of the purple desktop measures the same as the two already recorded: eleven rows of title fill, then a one-pixel rule, in every window in every reference. Only the colours move. That is the finding worth writing down. One window draws a #ff77a8 edge against a #ffacc5 title, exactly one step apart on the red ramp, which looked like a law worth deriving; two others draw black, one against that same fill. Same fill, different edge - so the ramp relationship is offered to themes rather than imposed on them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
…a real framebuffer Studio now draws into a magnified low-resolution framebuffer rather than at the window's own resolution, which is the change everything else depended on. Picotron's 12px rows and 7x7 icons only mean anything if a drawn pixel is several screen pixels wide; at 1:1 on a modern display they are not chunky, they are microscopic. The magnification is chosen rather than fixed. logicalSize() picks the integer scale putting the logical height nearest 270 and divides the window by it, so 1920x1080 gives exactly Picotron's 480x270 at 4x while 1440x900 gives 480x300 at 3x. A wider monitor buys workspace instead of margins, which matters because Studio is an editor inside a window and not the whole operating system: at a hard 480x270 a sprite plus a palette, timeline and two toolbars leaves almost no canvas. Whole numbers only - a fractional scale resamples every drawn pixel to a different width, which is the one rule that cannot bend. The theme sources every colour from palette.step() or palette.named(), so none can drift off the thirty-two. A hex two off a real entry looks fine alone and wrong beside everything else, which is exactly the kind of mistake that accumulates into an interface that reads as messy without any one thing being identifiably broken. raised/sunk/panel/well are reimplemented on the flat surface, which converted all thirty-two call sites without touching a widget. Losing the bevel sounds like losing vocabulary and is not: with a ramp behind it, pressed is one step darker and hovered one step lighter, and that reads better at 12px than a one-pixel highlight ever did. The groove went the same way - a dark rule with a light one under it did more for the "real tool" feel than any other detail in a bevelled interface, and at a 12px row it is a thick black bar. Rendering the gallery is what found the rest. The heading colour was the light purple Picotron paints title bars with, which vanishes as text on toolbar grey - invisible in a swatch, obvious the moment twenty controls sit together. It also surfaced a bug that had been shipped and never run: Scrollbar.thumbRect assigned a local named `span`, which shadows this.span() for the whole call, so painting a scrollbar raised "is a number, which cannot be called". Same family as a method name shadowing an import - Ghost's lookup reaches the enclosing scope before the class. The linter now checks for it, and was confirmed against the original bug. 113 assertions pass, the linter is clean, and all eight pixel tiles still match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
main.gs now draws into the magnified framebuffer with the Picotron theme, and `lumen . --shot` renders one frame, saves it and quits - the only way to look at the interface on a machine with no display, and the only way a build can check that the thing still draws at all. Doing that immediately showed two faults that no test could have. The canvas was not square. A 32x32 sprite opened at a fixed 12x zoom, which was a reasonable default when the interface drew at window resolution and is badly wrong at 480x270: 384px of artwork in a region 170px tall, so what the canvas showed was a horizontal slice through the middle of the sprite. It read as a stretched canvas rather than a zoom fault, which is why it took a screenshot to find. The viewport now fits the document the first time it is given a size, and never again - once someone has zoomed, a resize must not throw that away. The transparency checker was a 16px square, Aseprite's default and correct when 16px meant 16 screen pixels. Here every framebuffer pixel covers three or four of them, so it landed as a 48px chessboard that read as the subject of the canvas rather than its background. It is a theme metric now, and Picotron's is eight. Both are the same underlying mistake: a constant tuned for one resolution surviving into another. The linter cannot see those and neither can the test suite; only rendering the app can. 120 assertions pass, the linter is clean, and all eight pixel tiles still match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
…tte claim The icon browser screenshot shows Picotron's file-type set at 1:1 on a 480x270 screen - a 16x16 grid at pitch 40 - so the glyph sheet is now its art rather than my transcription of it. Every colour resolves to a palette entry at distance nought, which is the check that the copy is exact and not merely close. Transparency is flood-filled from each cell's border rather than matched on colour, because the paper inside a document is #fff1e8, the same as the browser's own page; matching on colour hollows out every icon, which it duly did on the first attempt. The same screenshot showed that a claim in the last commit was wrong. This palette is not complete at thirty-two. That number came from twenty-five ramp entries plus seven others, and thirty-two is a size Picotron documents, so the arithmetic looked like independent confirmation - it was a coincidence of the screenshots to hand. The browser uses an orange and a magenta that the set cannot explain at any distance, and Picotron's system palette is sixty-four, so more will turn up. Both are recorded now, and the fit scores are what confirm them rather than any count: adding the two dropped the icon browser from 0.41 to 0.04 and the file browser from 0.19 to 0.01. The test that asserted a total has been replaced - it encoded the wrong claim - and detect_profile() reporting a poor fit is how the next missing entry will announce itself. 119 assertions pass, the linter is clean, all eight pixel tiles still match, and the app still renders. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
The Window widget was the last thing still painting through the old bevelled path. It now draws the measured Picotron window: a flat body inside a one-pixel outline cut [2, 1], a title bar of eleven rows, a one-pixel rule under it, and the title centred - which is what every window in every reference does. The bar is cut on its top corners only; cutting all four leaves a notch of body showing at each end, the same fault that took the chrome from 100% to 88.8% the first time it was rendered. `lumen . --shot run:app.preferences` runs a command before the frame is taken, so a screen reachable only by clicking can be looked at on a machine with no display. Those are exactly the screens least likely to have been looked at, and rendering this one found four faults at once: The scrim was solid black, so opening a dialog blanked the workspace entirely rather than putting it out of reach. It is the one colour in this theme that cannot come from the palette, because a scrim is a compositing operation and not an entry. The dialog was 320x200, which was reasonable at window resolution and is two thirds of a 480x270 framebuffer. That is not a dialog, it is a takeover. At 210x126 it fits its content with room to spare, which took two goes - the first was short enough to clip its last row. Picotron was missing from the theme picker, so the running theme was the one theme that could not be selected and every radio read as off. 119 assertions pass, the linter is clean, and all eight pixel tiles still match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
Three faults that only rendering could show, all in the same family: a widget that reads fine on its own and wrongly in place. The slider drew its label in one colour across a bar that is filled on one side and empty on the other, so the text went muddy over the fill. It is now drawn twice, clipped to either side of the fill edge - light where it crosses the bar, dark where it crosses the track. Neither colour alone can do both, and picking a fill pale enough for dark ink would leave the bar barely visible against the track it sits in. The seam falls mid-glyph and reads as intended. The current-colour chips were anchored to the bottom of the colour bar, which is docked down the full height of the window - so the two colours being edited sat several hundred pixels from the swatches they are chosen from, with a rule floating above them marking a boundary between nothing and nothing. They now sit directly under the palette. Every palette swatch had a fringe of its own colour leaking past its border on the right and bottom. outline() draws with line primitives and pixel snapping while fill() draws a rectangle, and the two disagree by a pixel. Swatches and chips are surfaces now, which are spans only and cannot drift. Fixing that made a fourth fault obvious: the selected swatch was marked with a bevel plus an accent line drawn in the gap above it, which reads as a smudge rather than a selection. Selection is the colour of the swatch's own border now - unambiguous on a black swatch and a white one alike, where a tick would need to be two different colours and is unreadable at 8px regardless. 119 assertions pass, the linter is clean, and all eight pixel tiles still match - in CI too: the pixel job built Lumen, rendered through Xvfb and compared every tile at 100%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
Measured off two Picotron controls: a button is a flat #c2c3c7 fill on the #fff1e8 body with one pixel off each corner and NO BORDER OF ANY KIND, and a tab strip is a flat #83769c with white text on it. Only windows are outlined. This kit outlined everything - every button, cell, field, tab and track carried a one-pixel black border. That is the largest single difference between an interface that looks like Picotron and one that merely uses its palette: forty black lines around things Picotron leaves as flat tone against flat tone, which reads as heavy and busy without any one control being identifiably wrong. It is why it survived this long. Controls also take a smaller corner than windows do - one pixel against two - so the two cuts are separate metrics now rather than one shared number. Rendering the change found the other half of the rule. With no border to fall back on, a control is read entirely by its tone against its ground, and button.face matched the panel it sat on, so every button simply vanished. Picotron's button is the darker of its pair because it sits on the white body; ours mostly sit on grey toolbars, so ours is the lighter one. The rule is that there is a step, not which direction it goes in. Two things keep their borders, and both are measured rather than kept out of habit. A checkbox is 9x9 with a 1px border: it earns one by being small, where a 9px flat fill one tone from its ground is a smudge and a 40px button is plainly a button. And a scrollbar thumb is solid #83769c on a light track, which is not the button tone - taking that left the thumb the same colour as its own groove. 119 assertions pass, the linter is clean, and all eight pixel tiles still match: the window chrome is the one thing this did not change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
The Preferences dialog's - and + buttons disappeared. They sit on the white window body, and button.face had been moved to the light tone so that buttons would read on the grey toolbars - so on a light ground there was no step left and, with no border to fall back on, nothing to see. The fix is not a second button colour. It is that Picotron does not put filled buttons on toolbars at all: its file browser draws view and navigation controls as bare #83769c icons directly on the #c2c3c7 bar with no button shape behind them, and a filled button - #c2c3c7 on the #fff1e8 body - only ever appears on a light ground. A toolbar item and a button are different things, not one thing on two grounds. So Button gains bare(), which draws no fill until hovered, pressed or selected, and the toolbar uses it. button.face goes back to the darker neutral, which is the tone Picotron actually fills a button with. One fill now serves the whole interface because the two cases were separated rather than averaged. Also records the no-outline rule in docs/picotron.md, which was the spec the work is measured against and was missing the most important thing in it. 119 assertions pass, the linter is clean, and all eight pixel tiles still match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
docs/tutorial.html builds the pre-Picotron design: bevelled surfaces with a highlight-and-shadow pair, circular corner profiles, a 16px icon grid, free hex per theme, and rendering at the window's own resolution. The code now does none of those - flat fills into a magnified 480x270 framebuffer, corners cut rather than rounded, windows outlined but controls not, every colour indexed to a fixed palette. Eighty passages in it are now wrong. Docs that are quietly wrong are worse than docs that are visibly old, because someone following along would build something the repository no longer contains and conclude they had made a mistake. The notice says which half is still true: the structural reasoning - the widget tree, the dock, capture-based input, commands, the keymap - and the Ghost papercut reference are unaffected, because none of that changed. The chapters on painting and theming are the ones that did. Rewriting it against the current design, and reordering it so the widget playground comes first and Studio is the final chapter, is outstanding work rather than work quietly dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM
kaidesu
marked this pull request as ready for review
September 2, 2026 15:09
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.
Rebuilds Studio's interface on Picotron: the palette, a pixel-verification tool that survives real screenshots, the art redrawn from measurements, and the widgets moved onto a magnified low-resolution framebuffer.
The comparator was broken in a way that would have invalidated everything
Three of the five reference screenshots come through a capture pipeline that darkens every channel by up to 9 —
#fff1e8arrives as#f6eee6. A pixel-perfect reproduction scores 48.8% against them, and the failure looks exactly like a broken renderer.Snapping each colour to its nearest palette entry is the obvious fix and a worse bug, because it fails silently on a few pixels rather than loudly on all of them. Measured against real capture pairs it reads
#6f478eas#654688when it is really a shifted#754e97— those purples sit 23.3 apart and the shift moves colours up to 15.6, so the nearest entry is the wrong entry. Both are window chrome, so the corruption lands precisely where the matching matters.So the transform is applied rather than guessed at. Seventeen colours appear in both an accurate and a shifted capture, giving a measured
true → capturedchannel map that is a function and monotonic, so it inverts. The tool derives a shifted twin of the palette, detects which profile fits each image, and matches within it. All seventeen pairs recover correctly; a shifted reference against its canonical twin scores 100%.The palette is five ramps of five — and is not complete
Inverting that map also recovered most of the palette. A palette-file thumbnail in one reference turns out to be a 5×5 grid that is not an arbitrary set of colours but five ramps, dark to light, with PICO-8's sixteen scattered through them.
The ramps matter more than the list: a face is a step, hovered is the step above, pressed the step below. That makes "one step lighter" a rule the whole interface shares instead of forty hand-picked colours that only nearly agree.
An earlier revision of this PR claimed the set was complete at 32. That came from 25 ramp entries plus 7 others, and 32 is a size Picotron documents, so the arithmetic looked like independent confirmation. It was a coincidence of the screenshots to hand: a later reference — Picotron's icon browser — uses an orange and a magenta the set cannot explain at any distance. Picotron's system palette is 64, so more will turn up.
What confirms an entry is the fit score, not a count. Adding those two dropped the icon browser from 0.41 to 0.04 and the file browser from 0.19 to 0.01, and a poor score is how the next missing colour will announce itself.
There is now a check that can see a pixel
tools/verify.shrenders through Xvfb at Picotron's 480×270 framebuffer, reads the frame back, and compares eight chrome regions against crops of Picotron's own screenshot. All eight match exactly, in CI as well as locally. The gate is real, not decorative: changing the corner chamfer from 2px to 3px fails five of the eight and exits non-zero — tested by doing it.It earned itself immediately. The first render came back at 88.8% on the top-left corner because the chamfer was applied to all four corners of the title bar, leaving a one-pixel notch where Picotron's is square. No test would ever have found that.
Controls are not outlined, which was most of the problem
Measured off two Picotron controls: a button is a flat
#c2c3c7fill on the#fff1e8body with one pixel off each corner and no border of any kind, and a tab strip is flat#83769c. Only windows are outlined.This kit outlined everything — every button, cell, field, tab and track carried a 1px black border. That is the largest single difference between an interface that looks like Picotron and one that merely uses its palette: forty black lines around things Picotron leaves as flat tone against flat tone, which reads as heavy and busy without any one control being identifiably wrong.
The corollary is that a control is read entirely by its tone against its ground. A toolbar item and a button are therefore different things rather than one thing on two grounds — Picotron's toolbars carry bare icons with no button shape, and filled buttons only appear on the light body. Two things keep borders, both measured: a checkbox (9×9, 1px border — it earns one by being small) and a scrollbar thumb (solid
#83769con a light track).Geometry and art, measured rather than assumed
[2, 1]on every window in every reference, 1px on controls.#ff77a8against a#ffacc5title, exactly one ramp step apart; two others use black, one against that same fill.Icon art lives in
tools/make-icons.pyas ASCII with the PNGs as build output, so changing an icon is a readable diff rather than an unreviewable blob.The framebuffer
Studio draws into a magnified low-resolution framebuffer rather than at the window's own resolution. Picotron's 12px rows and 7×7 icons only mean anything if a drawn pixel covers several screen pixels.
The magnification is chosen rather than fixed:
logicalSize()picks the integer scale putting the logical height nearest 270 and divides the window by it, so 1920×1080 gives exactly 480×270 at 4× while 1440×900 gives 480×300 at 3×. A wider monitor buys workspace instead of margins, which matters because Studio is an editor inside a window and not the whole operating system. Whole numbers only — a fractional scale resamples every drawn pixel to a different width.What rendering found that nothing else could
lumen . --shot run:app.preferencesrenders one frame of any screen, including those only reachable by clicking, and quits. Every one of these was invisible to the test suite and the linter:Scrollbar.thumbRectdeclared a local namedspan, which shadowsthis.span()for the whole call — painting a scrollbar raised "is a number, which cannot be called". Shipped and never run. The linter checks for it now, verified against the original bug.button.facematched the panel it sat on.outline()draws with line primitives and pixel snapping whilefill()draws a rectangle, and the two disagree by a pixel.Checks
119 assertions, a clean linter, and all eight pixel tiles matching — CI runs the pixel job alongside test, lint and the parse sweep.
Known gaps
ui.fonttakes one.docs/tutorial.htmldocuments the interface this PR replaced and says so at the top. Its structural half and the Ghost papercut reference are unaffected; the painting and theming chapters are not. Rewriting it is outstanding.🤖 Generated with Claude Code
https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM