Architecture, Development: text and boxes on device pixels

- Architecture: how the toolkit puts text and boxes on device
  pixels (crispText, crisp, hairline, the nudge of the stage), and
  what it does not give (subpixel smoothing).
- Development: the run is at 100 %, step 62 alone runs at 150 %.
  The hooks that read the frame as the screen gets it, and the
  deviceScaleFactor option of the harness. The sum is 1656.

Belongs to 32a0af6 in the main repository.
admin committed 2026-10-02 08:42:34 +02:00
1 parent 875594a733
commit f59d7604bf
2 files changed
+25 -1

No files matched your search

+15
@@ -25,6 +25,21 @@
own edges. The toolkit only folds; which groups are folded is kept by the editor
(`editor/sectionFolds.ts`, one entry in `localStorage`). Every title of the inspector goes through
`addTitle` or `sectionHeader` in `inspector/host.ts`, so a new section gets the band by itself.
**Text and boxes sit on device pixels** (`ui/pixelGrid.ts`). The layout counts in whole CSS
pixels, and on a screen scaled to 125 % or 150 % most of those fall between two device pixels:
the GPU averages both, text goes soft and a 1 px line becomes a pixel and a half of mixed colour.
So every text of the interface is made with `crispText()` instead of `new Text()` — it sets
Pixi's `roundPixels`, which rounds the corners of what is drawn to device pixels — and a
`Graphics` that holds nothing but upright boxes is passed through `crisp()`, which does the same
where the scale is not a whole number and nothing at 100 % or 200 %. A curve, an icon or anything
of the game must *not* go through it: rounding its points bends it. A line is drawn `hairline()`
wide, one device pixel as a browser draws `1px`, and flush with the edge it belongs to
(`h - hairline()`, not `h - 1`); `drawControlBox`, `drawHeaderBar` and `drawSectionBand` do both
already. At 150 % every odd pixel is an exact half, and which way the GPU rounds a tie is
decided by the last bit of its arithmetic — one band came out a row shorter than its neighbours —
so the stage is nudged by 1/32 px (`tieBreakFor`), which sends every tie the same way.
What this does not give is the subpixel smoothing a browser applies to its own text: Pixi draws
a text into a transparent picture first, and there only greyscale smoothing is possible.
Double-clicks are counted in `clicks.ts`, not read off the event: Pixi only puts a click count
on the `click` it synthesises and leaves `detail` at 0 on `pointerdown`, so every press handler
that trusted `e.detail` never saw a second click. Presses are counted per key rather than per
+10 -1
@@ -18,7 +18,7 @@ php tests/php/accounts.test.php # the API's accoun
# has and the web server's container has not (SKIP there)
php tests/php/snapshots.test.php # the snapshot store (#231), the same way
php tests/php/releases.test.php # the release store (#266), the same way
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1648 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1656 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
@@ -44,6 +44,15 @@ gets no WebGL context with them at all, so the app never renders.) Every run als
in the log (`renderer: …`) and stops if that is a software one such as llvmpipe or SwiftShader:
under WSL2 a missing GPU driver does not fail by itself, llvmpipe still offers WebGL 2 there.
**The run is at 100 %, one step is not.** The harness sets `deviceScaleFactor: 1`, where a layout
pixel is a device pixel and nothing can sit between two. What a screen scaled to 150 % shows is
therefore invisible to every step but 62 (`62-device-pixels.mjs`), which switches the scale over
CDP, reloads the page and reads the frame as the screen gets it: `__gm.deviceColumn()` gives the
device pixels down a column, `__gm.edgeContrast()` how hard the edges in a rectangle are, and
`__gm.pixelGrid()` the scale and the width of a hairline. It switches back and reloads again, so it
can stand anywhere in the order. A probe of your own takes the scale as an option:
`runChecks({ projectId, baseUrl, deviceScaleFactor: 1.5 }, body)`.
**Leave the full run to CI.** Every push runs the whole proof on the runner (see *Continuous
integration*), so locally take only what the change needs: the gate, plus the step that covers it —
`GM_STEPS` keeps that to seconds. The whole suite works here too and costs about four minutes and