Skip to content

Take the command palette off the first screen, and fix a grid test race - #96

Merged
ibrahimweng merged 2 commits into
mainfrom
claude/github-repo-review-e4yyb2
Sep 4, 2026
Merged

Take the command palette off the first screen, and fix a grid test race#96
ibrahimweng merged 2 commits into
mainfrom
claude/github-repo-review-e4yyb2

Conversation

@ibrahimweng

Copy link
Copy Markdown
Owner

Two commits. One is an attempt at deferring the drawing engine, which got part of the way and produced a much better answer about the rest. The other is a test race that the first one exposed.

The command palette waits now

It was the last overlay still arriving with the first screen. Its catalogue enumerates every control the forge has, so importing it imported the letter recipes and the part specifications along with it.

It is deferred now, in the same way the seven other overlays already were, and warmed at idle with them. The keyboard shortcut that opens it is a hook, so that stays where it was.

The entry chunk goes from 965 kB to 940, and from 303.7 kB gzipped to 295.7.

Why that is eight kilobytes and not eighty

I have said before that the forge is reached from the always loaded half through five separate edges, and that all five would have to go. That was true, and it was not the useful way to describe it. Four of those edges are hidden behind the fifth.

App.tsx calls useForge() on every render, and forge-store.ts imports twenty-eight document editing functions at the top of the file. While that is true, breaking any of the other edges changes nothing.

This branch demonstrates that rather than asserting it. It moves castOf and its three siblings into a leaf module and turns toTypeface into a dynamic import, and the build does not move by a single byte, because the forge store was pulling both of them in anyway.

useForge() is also not an accident. App watches forge.revision so that a drawing gets written into the session when it changes. Leaving a store out of that list is the bug the comment above revisions describes, where work is silently never saved. So the store cannot simply be deferred.

What would finish it

For whoever picks this up, including me:

  • Move the twelve forge callbacks off Shell and into the palette, which is deferred as of this branch. App holds them today and nothing except the palette calls them.
  • Give the revision counter a module of its own for App to subscribe to, so that watching a drawing change does not mean importing the thing that draws it.
  • forgeStore.refresh() and forgeStore.snapshot() are the only two remaining uses in App. Both are inside handlers and both can be reached with a dynamic import.

That is roughly two hundred lines across four files, and it changes the palette's contract and its tests, which is why it is not here. The prize is seventy eight kilobytes gzipped, measured rather than estimated.

The two changes that bought nothing today are kept, because they are correct on their own terms and they are where that work starts. Reading a cut off a document should not require the drawing engine, and neither should having toTypeface ready for a button nobody has pressed.

A race in two grid tests

The full suite failed once in two hundred and forty three on the first commit. Both grid tests in handles.spec.ts do this:

await panel.getByRole("switch", { name: "Build on a grid" }).click();
await expect(page.locator("[data-forge-cells]")).toBeVisible();
const laid = await outline();

[data-forge-cells] is the grid's editor, and it appears before the alphabet has been redrawn out of cells. Read at that moment, laid was sometimes still the skeleton. Clearing the grid puts the letter back to its skeleton, so the poll on the next line, which waits for the outline to stop being laid, was waiting for a change that had already happened. It could only time out.

The comment above that poll already said "It went flaky on CI before it went red". That was the right observation attached to the wrong line. The poll was never the problem. The read before it was.

Both tests now take the letter before the switch is thrown and wait for it to stop being that, which is what laid was always supposed to mean.

It passed on its own, and it passed with its own file run in parallel. Only the whole suite was slow enough to lose. Deferring the palette shifted the timing of the first screen enough to expose it, which is the honest reason it appeared now, rather than a reason to think it was new.

Checks

  • Type check passes.
  • Biome reports nothing across 560 files.
  • The build is clean.
  • Unit tests: 1925 of 1925 pass.
  • Browser tests: 243 of 243 pass, in 19.3 minutes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ULPrwXh4M5YPRkd54xJKLM


Generated by Claude Code

ibrahimweng and others added 2 commits September 4, 2026 22:26
The engine split, attempted properly rather than argued about. Two things
came out of it: one measured win, and a much sharper answer about the rest.

The win. The command palette was the last overlay still arriving with the
first screen. Its catalogue enumerates every control the forge has, so
importing it imported the letter recipes and the part specifications. It is
deferred now, like the seven overlays already were, and warmed with them at
idle. The shortcut that opens it is a hook and stays. The entry chunk goes
from 965 kB to 940, and from 303.7 kB gzipped to 295.7.

That is eight kilobytes gzipped, and the reason it is not eighty is worth
writing down, because I had the shape of this wrong before.

I said the forge was reached through five separate edges and that all five
would have to go. That was true but it was not the useful description. Four
of them are shadowed by the fifth: App.tsx calls `useForge()` on every
render, and `forge-store.ts` imports twenty-eight document-editing functions
at module scope. Breaking the others changes nothing while that stands, and
this commit proves it -- `castOf` and its three siblings moved into a leaf
module and `toTypeface` became a dynamic import, and the build did not move
by a single byte, because forge-store was pulling both anyway.

And `useForge()` is not an accident. App watches `forge.revision` so the
session gets written down when a drawing changes. Leaving it out is the bug
the comment above `revisions` describes: a store whose work is never saved,
silently. So the store cannot simply be deferred.

What would actually do it, for whoever picks this up:

  - Move the twelve forge callbacks off `Shell` and into the palette, which
    is deferred as of this commit. App holds them today and nothing but the
    palette calls them.
  - Give the revision counter a module of its own for App to subscribe to,
    so watching a drawing change does not mean importing the thing that
    draws.
  - `forgeStore.refresh()` and `.snapshot()` are the two remaining uses in
    App, both in handlers, both reachable with a dynamic import.

That is about two hundred lines across four files and it changes the
palette's contract, which is why it is not in this commit. The prize is
seventy-eight kilobytes gzipped, measured, not guessed.

The two changes that bought nothing today are kept because they are correct
and they are what that work would start from: reading a cut off a document
should not require the engine, and neither should having a `toTypeface`
available for a button nobody has pressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULPrwXh4M5YPRkd54xJKLM
Two tests in handles.spec.ts turn the grid on and then read the letter as
proof of what a grid-built letter looks like:

  await panel.getByRole("switch", { name: "Build on a grid" }).click();
  await expect(page.locator("[data-forge-cells]")).toBeVisible();
  const laid = await outline();

[data-forge-cells] is the grid's editor and it appears before the alphabet
has been redrawn out of cells, so `laid` was sometimes still the skeleton.
Clearing the grid puts the letter back to its skeleton -- so the poll on the
next line, waiting for the outline to stop being `laid`, was waiting for a
change that had already been and gone. It could only time out.

The comment above that poll already said "It went flaky on CI before it went
red", which was the right observation attached to the wrong line: the poll
was never the problem, the reading before it was.

They now take the letter before the switch is thrown and wait for it to stop
being that, which is what `laid` was always meant to mean.

Found by the full suite on the commit before this one, at one failure in two
hundred and forty-three. It passed alone, and it passed with its own file run
in parallel; only the whole suite was slow enough to lose. Deferring the
command palette shifted the timing of the first screen enough to expose it,
which is the honest reason it turned up now rather than a reason to believe
it was new.

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

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
typeforge Ready Ready Preview Sep 4, 2026 11:26pm UTC

@ibrahimweng
ibrahimweng merged commit fba0f6d into main Sep 4, 2026
5 checks passed
ibrahimweng added a commit that referenced this pull request Sep 5, 2026
`src/forge/` is 287 kB of letter recipes, part specifications and
geometry, and it arrived with the first screen of everybody who opened
the application -- including the people who only ever edit a font
somebody else made. Deferring the palette in #96 moved nothing, because
a dozen other things were importing it too.

Static edges from App.tsx into the engine: 20 modules to 5. The five
that remain are `shapedInk`'s own reach, which is on the synchronous
path outlines are resolved on; moving that means making the outline path
async and is a different job.

First load -- the entry chunk plus everything it statically imports:

    before   1194.68 kB   379 kB gzipped
    after     781.54 kB   257 kB gzipped

state/drawn.ts is the new piece: the drawing seen from outside the thing
that draws. Six components wanted one small fact from the forge store --
a family name in the toolbar, whether undo was available, a letter
handed back from the editor, a snapshot for the session -- and each
imported the whole engine to ask. The store now says what it is from the
one place its state is written, and the clicks fetch it when pressed.
The count is owned there rather than mirrored, so it cannot drift from
the store's revision: a second copy kept in step by hand is exactly the
silent no-save failure the note above `revisions` describes.

project/format.ts pulled twelve of the twenty on its own, through two
functions. `hasDrawing` became `worthKeeping` in forge/document.ts,
where the bases it compares against already live, and the forge store is
what asks it. `whole` moved to `forgeStore.restore`: reading a project
no longer needs the engine, and putting one back is already loading it.

The four side panels are deferred and warmed alongside the views they
stand beside. forge/script.ts's `scatterOf` -- a hash function both
engines share, and its own comment says it is not a piece of the forge
-- moved to font/scatter.ts, so the Trace store stops pulling eighteen
hundred lines of brush script to hash a one-character name. And
`baseFor` moved to library/measure.ts, since which base a font lands on
is a reading of the measurement rather than a thing that needs the
twenty bases in hand.

Tests followed the code: worthKeeping's five moved to
forge/keeping.test.ts, older.test.ts goes through the read-then-fill
split the application now uses, and drawn.ts has nine of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULPrwXh4M5YPRkd54xJKLM
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.

1 participant