Skip to content

Merge Aseprite's layout and icons with Catppuccin's colours - #2

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

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

Conversation

@kaidesu

@kaidesu kaidesu commented Sep 2, 2026

Copy link
Copy Markdown
Member

Builds on the Picotron rebuild rather than replacing it: the framebuffer, painter primitives, widget tree and pixel-verification tooling all stay, retargeted.

The references are two things, not one

They are Aseprite themed with Catppuccin — the file in them is called catppuccin.ase — so this PR takes the metrics and layout from Aseprite and the colours from Catppuccin. Worth separating, because Aseprite's own stock theme looks nothing like this and swapping to it later is a palette change, not a rewrite.

Lossy input, and why it did not matter

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

It does not need recovering, only identifying. Catppuccin's values are published, and five survive the compression at distance 0.0 — #1e1e2e, #eff1f5, #dce0e8, #fe640b, #a6e3a1 — with the rest landing within 1–3. What was uncertain was which palette it is, not what the palette contains.

Both flavours ship: Mocha (dark, default) and Latte (light). They share every token name, because Catppuccin runs its greys in opposite directions — text is darkest in Latte and lightest in Mocha — so "text on base" is legible in both with no conditional. Latte is a palette swap, not a second theme.

Measured off Aseprite

element measurement
menu bar 12px
tab 11px
tool options row 17px
tool slot 15px, in an 18px column
timeline 80px
status bar 15px
logical frame 960×540 at 1×, magnified 2× in the references

The framebuffer target is a parameter now rather than a constant. Aseprite's chrome fills 960×540; Picotron's filled 480×270. Hard-coding either renders the other at half or double its intended density, and both themes are kept — Picotron's eight pixel tiles still match exactly, because verify.sh renders that theme explicitly.

Icons redrawn at 16×16, two-tone

Aseprite's tool icons are not flat silhouettes — the pencil has a lit body and a dark tip. The second tone is half alpha in the same sheet: tinting multiplies, so it lands as the tint at 50% over the ground. One sheet, one draw call, no second colour threaded through the theme, and it still recolours per flavour.

Four were redrawn twice — the first bucket read as a diamond and the first eraser as a second pencil. Only rendering the sheet showed that.

A colour picker

The left panel now runs palette → saturation-value field → hue strip → foreground/background chips, which is Aseprite's order and the right way round. This is the first thing in Studio that constructs a colour rather than choosing from a fixed set, so it is the first that needs HSV — a pure function, tested on all six sector boundaries and on clamping, because a drag off the field returns a component slightly out of range and unclamped that wraps 256 to black.

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

A Ghost papercut I had documented wrongly

An earlier commit described it as "a local whose name matches a method of the same class shadows that method for the whole call." Wrong twice, 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. The same call works before the poisoning line runs and fails after, so it is time-dependent and testing cannot be relied on to surface it.

The linter now flags any local sharing a name with any method of its class. That caught the instance it was rewritten for, plus 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. Latent only because the Picotron rebuild left fillRounded with no callers.

What rendering found

  • The Target blit came out a near-black smear: a blit is multiplied by the current draw colour, and the last thing set was the well's own background.
  • The picker's marker ring hung two pixels above the gradient, because full saturation and value puts its centre on the top-right pixel — the default, so it was wrong on first sight.
  • Colorbar never called super.paint, which was harmless until it had a child.
  • The timeline's 16px toggles overlapped in 12px slots — exactly the icon-taller-than-its-row fault that made the old Picotron sheet unusable. Knowing the shape of that bug did not stop me writing it again; only rendering it did.

Checks

132 assertions, a clean linter, Picotron's eight pixel tiles matching. The linter now also 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. Both listed exceptions are measured facts: Aseprite paints its transparency checker itself, in #c0c0c0 over #808080, under both the light and the dark reference.

Known gaps

  • No pixel-exact verification against Aseprite. The lossy WebP makes tile matching impossible — 130 distinct colours in one 16×39 button. Colour conformance is enforced by the linter instead. PNG screenshots would let me build the same eight-tile gate that guards the Picotron chrome.
  • The icons are drawn in Aseprite's idiom, not lifted from it — same reason. Aseprite themes ship a sheet.png; that file would let me take them exactly, as Picotron's icon browser did for the file icons.
  • The menus stay File / Edit / View / Tools. There are no Sprite, Layer, Frame or Select commands to put in Aseprite's other four, and a menu that opens onto nothing is worse than one that is not there.
  • No floating Preview panel yet.
  • docs/tutorial.html still documents the pre-Picotron interface and says so at the top.

🤖 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
@kaidesu
kaidesu marked this pull request as ready for review September 3, 2026 00:57
@kaidesu
kaidesu merged commit f0f1500 into main Sep 3, 2026
2 checks passed
kaidesu pushed a commit that referenced this pull request Sep 4, 2026
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
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