Skip to content

feat: rebase onto upstream v0.8.4 and add five Cockpit fixes - #3

Open
phall1 wants to merge 13 commits into
mainfrom
cockpit/v0.8.4
Open

feat: rebase onto upstream v0.8.4 and add five Cockpit fixes#3
phall1 wants to merge 13 commits into
mainfrom
cockpit/v0.8.4

Conversation

@phall1

@phall1 phall1 commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Brings this fork's main up to upstream v0.8.4 and restacks the Cockpit patches on top, plus five new fixes.

Targets this fork's own main — not vercel-labs/native. Nothing here is proposed upstream.

zig build test exit 0. scripts/gate.sh fast passing (docs-install/docs-check need pnpm, absent on this machine; run manually they pass).

Rebase

Was cockpit/v0.8.3, based on upstream v0.8.3. Now rebased onto upstream main (v0.8.4 + 1). One conflict, in src/runtime/view.zig: upstream added drag fields and Cockpit added secondary-gesture fields at the same struct offset. Resolved as the union of both — neither side's fields were dropped.

New in this branch

fix(macos): enable font smoothing so glyphs render at full weight. Every glyph the AppKit host draws lands in a CGBitmapContext, and font smoothing — macOS's stem darkening — is off by default on the transparent backing all three of those contexts use. CGContextSetShouldSmoothFonts appeared zero times in the repo, so all text rendered systematically thin.

Measured with the consuming app's harness against the bundled JetBrains Mono NL, 13pt:

solid stem pixels
scale 2 2341 → 3164 (+35.2%)
scale 1 635 → 940 (+48.0%)

An opaque per-cell ground under the glyphs was considered and rejected: on the same harness it moves solid stem pixels by −0.3%, so it buys no weight while adding a full-screen fill to every frame. The CPU reference renderer never touches CoreText and blends coverage itself, so it does not share the defect and no reference screenshot can catch a regression here — re-measure rather than eyeball.

feat(ui-app): web_panes takes a ChromeContext, like build_window. ⚠️ Breaking for implementors. Panes are reconciled per window, so a signature that could not say which window it was being asked about had to answer with the whole app's pane set every time — and every other window's rebuild then resolved that pane's anchor against a tree without it and logged a warning on every rebuild. All in-repo implementors and examples are updated in the same commit.

fix(automation): drop closed windows from the snapshot's window list. A closed window keeps its runtime slot until re-creation, so the snapshot published ghost window @wN lines with no views under them — which would quietly pass any smoke test asserting a window count.

fix(automation): pair widget-key's press with its release. The harness emitted only key_down, so the shortcut latch swallowed every repeated chord — driving cmd+G twice did nothing the second time. Paired rather than adding a release verb: real hardware has no press without a release, and a new verb would have made correctness opt-in while leaving every existing script driving half a keystroke.

feat(canvas): split decorative from hidden in widget semantics. hidden suppressed rendering, not just the accessibility node — which is why a search field's magnifier and caret painted nothing until it was removed from both. hidden keeps its meaning (and still reserves layout, which downstream tabAttentionMarker relies on); decorative paints and hides from accessibility instead.

feat(effects): fx.openUrl(url). The platform seam already reached [[NSWorkspace sharedWorkspace] openURL:] end to end — the gap was purely that nothing app-facing was wired to it, so the only callers were the webview bridge and a runtime path gated on webview link policy. No ObjC changes were needed. Validated against a scheme allowlist (http, https, mailto) with control bytes and whitespace refused whole rather than trimmed, so a https://ok\0javascript:… splice is rejected rather than accepted as its prefix.

🤖 Generated with Claude Code

phall1 and others added 13 commits August 10, 2026 22:14
* feat(canvas): add context menu policy

* fix(canvas): retain secondary gesture ownership

* fix(canvas): preserve full terminal viewport

* fix(canvas): admit complete terminal fonts

* fix(canvas): cost underlined terminal runs safely

* chore: follow history-driven changelog workflow

* fix(canvas): preserve context menu invariants

---------

Co-authored-by: phall <phall@noreply.github.com>
A terminal is the densest widget the toolkit hosts, and the per-view
frame budgets were sized for desktop chrome. A terminal row costs one
display-list command per contiguous same-background run plus one per
contiguous same-foreground run, so a styled 200-column row runs 30-60
commands where a whole three-pane view runs a few hundred. Against the
old 2,048-command budget (1,792 after the widget chrome reserve) a
60-row viewport had ~30 commands per row to spend, and the painter
degraded from the BOTTOM in silence: a realistically colored 200x60
screen (syntax highlighting, htop meters, a colored build log) painted
34 of its 60 rows and the rest showed bare background, with nothing in
any log to say a budget had eaten it.

Measured on a 200x60 grid at the widget tier (terminal_grid_tests.zig
prints both):

  realistic styling (50 fg runs/row):  34/60 rows -> 60/60
  truecolor (distinct fg+bg per cell):  4/60 rows ->  9/60

Changes:

- canvas_limits: max_canvas_commands_per_view 2048 -> 4096 and
  max_canvas_text_bytes_per_view 32 KiB -> 64 KiB (a 300x100 viewport
  is ~30 KB of presented text before chrome, and a split holds back a
  share for its sibling). Both host retained-command caps pin the new
  number (appkit_host.m, gpu_surface_renderer.cpp), and
  canvas.max_display_list_text_bytes / the new
  canvas.max_display_list_commands stay in lockstep by test.
  Measured cost: ~696 B per command slot across the view's retained
  mirrors, so RuntimeView 3.44 -> 4.83 MiB and the 32-slot Runtime
  110.0 -> 154.5 MiB of fixed-capacity address space.

- Truncation is never silent. terminal_grid.paintReport returns the
  rows painted, the rows handed over, and the store that stopped it;
  paint() keeps its exact signature and every paint records budget
  stops on the builder (canvas.DisplayListDegradation), which the
  runtime turns into one teaching log line on the EDGES of a
  degradation rather than once per frame.

- The widget emit path's frame scratch moves off the stack into the
  per-thread pool the frame planner already uses. At the new command
  budget the display list, the chrome copy store and the diff output
  no longer fit a stack frame under the widget emit recursion — a
  measured segfault inside the button emitter.

What this does NOT fix, deliberately: a viewport with a distinct
foreground AND background per cell merges nothing and wants two
commands per cell — ~24,000 for 200x60, ~60,000 for 300x100. At ~696 B
a slot that is 500 MiB and 1.3 GiB across the view slots, and a trial
raise to 8,192 alone (255 MiB) crashed runtime construction. That
density is not a number in canvas_limits; it needs a packed cell-grid
command the host renderers expand themselves. The budgets now carry
realistic styling and say so out loud when they cannot.

Also: widening a clip no longer leaves stale pixels behind. The render
planner erases push_clip/pop_clip into a per-command clip field, so the
retained packet baseline holds no key for a clip and the refined dirty
rect could not name pixels a growing clip revealed over unchanged
content — the frame kept whatever the host last drew there (the stale
columns a terminal pane showed after a split collapsed back to full
width). The baseline now carries its clip rects and the next frame adds
the difference, in both directions. Frames whose clips did not move,
including tweens that move content under a stationary clip, keep their
region-scoped patches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A terminal is the one surface whose content scales with AREA rather than
with design. Painting it as display-list shapes cost one background
command per contiguous same-colour run plus one text command per
contiguous same-foreground run, which merges into nothing on a styled
screen: a 200x60 truecolor viewport wanted ~24,000 commands against a
per-view budget of 4,096 and painted nine rows of sixty, the rest bare
background. No budget raise reaches that shape — one command slot costs
~700 B across the view's retained mirrors, so 60,000 commands is 40 MiB
per view before a pixel exists.

`CanvasCommand.cell_grid` (src/primitives/canvas/cell_grid.zig) carries
the screen instead: a cols x rows lattice of 20-byte cells, each with its
own background, cluster offset, foreground, and style, which every
renderer expands itself. Geometry is implied by the index, so nothing
per-cell is stored but content.

Measured (terminal_grid_tests.zig prints both):

  200x60 truecolor, distinct fg+bg per cell:
      before   9/60 rows, ~1,600 commands
      after   60/60 rows,      4 commands, 12,000 cells,  36 text bytes
  300x100 truecolor, distinct fg+bg per cell:
      after  100/100 rows,     4 commands, 30,000 cells, 585 KB, 36 text

The command count is now a constant, not a function of styling. Cluster
bytes are INTERNED, so a screen drawn from a 36-character alphabet costs
36 bytes however many cells it has.

Three bug classes close by construction:

- Reflow is safe. A screen is ONE retained key replaced wholesale, so a
  row that loses a run cannot orphan a per-run command keyed by its old
  start column. That was the stale-column bug — a split collapsing back
  to full width left a duplicated prompt line and a truncated hostname
  in the revealed columns. Verified gone by running the consuming app
  against this commit and driving split/close twice.
- Cell geometry is exact. A cell's position is its index, so a combining
  mark or a wide cluster can no longer advance its neighbours out of
  their columns whatever the face does.
- Every SGR attribute has somewhere to live. `TerminalCell` gains bold,
  italic, strikethrough, overline, six underline styles and an underline
  colour; `TerminalCursor` gains `blinking` and `wide`, and
  `TerminalCursorShape` gains `block_hollow` so an emulator-requested
  hollow block stops colliding with the focus-driven outline. All
  additive: the consuming app builds against this commit unmodified.

The reference CPU renderer is the oracle and is complete: two passes
(every background, then every glyph and decoration, so a neighbour's
background cannot erase an overhanging glyph), each cell's ink through
the same `drawGlyphBox` path a text run takes. Decoration geometry lives
in `CellDecoration` so a future host encoder reads the same source.

The GPU packet layer marks `cell_grid` unsupported, which routes terminal
frames to the CPU pixel path — the reference renderer — on every host.
That is correct everywhere from day one and needs no wire-format change;
it is also slower than a native encoder, and closing that is the next
step. Deliberately not shipping an unverified one: automation screenshots
render through the reference path, so a host encoder cannot be validated
here and a wrong one would diverge silently on the user's glass.

Also fixed, all found by the size increase:

- `paintInto` in the terminal tests returned a `Builder` BY VALUE, which
  left every builder-owned slice aimed at a dead stack frame. Text runs
  were small enough to survive it; a 38-cell grid was not.
- `Builder.initAt` and `CanvasDisplayListScratch.reset` replace
  whole-struct assignment on the widget emit path. Both structs carry a
  frame's inline storage, so `x.* = .{}` built a megabyte-scale stack
  temporary and overflowed the thread.
- `runtimeViewInfo` took a multi-megabyte `RuntimeView` by value.

Memory: RuntimeView 4.83 -> 5.45 MiB (the retained cell array), so the
32-slot Runtime goes 154.5 -> 174.5 MiB of fixed-capacity address space.
The command budget could now come back down to 2048 and give ~45 MiB of
that back, since the terminal is what drove it up; that is a separate
change with its own measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The packed cell grid fixed what a terminal could DRAW and broke how it
PRESENTS. `cell_grid` was unsupported by the GPU packet layer, so one
command dropped the whole view to the CPU pixel path: every frame became
a full-surface upload (~2.8 MB at 1x, 11 MB at 2x) and incremental
dirty-region patching turned off entirely.

Measured on the running app at 1100x640, through the automation
snapshot:

                          before        after
  gpu_present_path        pixels    ->  packet
  present_mode            none      ->  patch
  present_patch_bytes     0         ->  789 (1 upsert on a shell prompt)
  present_fallback        unsupported_command -> none (0 frames)
  gpu_input_latency_ns    19,392,000 -> 5,360,000 (budget 16,666,666)
  budget_exceeded         1         ->  0

Three pieces.

ONE GRID COMMAND PER ROW, not per screen. A retained command is the unit
of CHANGE: a screen-wide grid makes a keystroke re-encode and re-upload
every cell, which is the full-surface cost merely moved from the
rasterizer to the wire. A row is the granularity a terminal actually
changes at. Rows are bounded by `max_rows`, so a 300x100 truecolor
screen is 103 commands where per-run painting wanted ~60,000.

Cluster interning moved from per-SCREEN to per-ROW for the same reason,
and this one was measured the hard way: a blob shared across rows put
every row's fingerprint on every other row's characters, so one new
letter re-encoded the screen — 31 upserts and 8,449 bytes per frame.
Per-row it is 1 upsert and ~400-800.

WIRE FORMAT v6 plus the AppKit decoder. `cell_grid` is command kind 14,
its payload implied by the kind (the flag byte is full). Cells encode as
a delta stream — a tag byte per cell whose low bit means "same style as
the previous cell" — so a plain row costs about a byte a column and only
a genuinely per-cell-styled row pays the full 15. Clusters ride inline
per cell, so a row decodes without the rest of the screen.

The host renderer mirrors the reference renderer deliberately, including
its two-pass order (every background, then every glyph and decoration —
one pass lets a neighbour's background erase an overhanging glyph) and
its exact decoration geometry: all six underline styles, underline
colour, strikethrough, overline, wide cells. Stated rather than hidden:
glyph RASTERIZATION differs (CoreText vs the engine's outline filler),
as it already does for every draw_text command; `bold` and `italic` are
carried but not synthesised, matching the reference renderer rather than
getting ahead of it.

Windows moves to v6 and refuses only packets containing a cell grid (an
unknown kind fails validation), so every non-terminal frame keeps the
retained Direct2D path. Not verified on a device — no Windows here.

Also:

- `TerminalCursor.blinking` is real now instead of documented-only. The
  runtime arms the same looping opacity animation a text caret uses,
  keyed on the new `terminal_grid.cursorCommandId`. A painter has no
  clock; blinking is time, so it was never the painter's to stamp.
- The command budget returns to 2048 (the terminal was the only reason
  it went to 4096 and now costs ~100 commands), handing back ~45 MiB of
  the Runtime's fixed address space. Both host retained caps follow.
- Grid command ids move to `0x60_0000 + row`, out of
  `reserved_id_offset` (0x62_0000) where callers layer their focus ring
  — the previous commit put the grid and the ring on the same key.

Suite: 2970 pass / 14 skip / 2984 total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bold` and `italic` reached `CellFlags` and every renderer and then
changed no pixels: `drawCellGrid` built its run with the grid's single
`font_id`. So `\x1b[1m` and `\x1b[3m` were carried honestly and drawn
identically to regular.

A cell grid now carries a FONT FAMILY — the regular face plus bold,
italic, and bold-italic companion ids — and both renderers pick per CELL
off the style flags. One row mixes weights freely.

Where the faces come from: the APP registers them
(`Runtime.registerCanvasFont`, ids >= 64) and names them in
`DesignTokens.typography.mono_bold_font_id` /
`mono_italic_font_id` / `mono_bold_italic_font_id`. The SDK bundles only
GeistMono-Regular and does not presume to ship a consumer's type; an app
that supplies nothing still gets visible weight, because:

SYNTHESIS IS THE FALLBACK, and this commit implements it. Stated plainly
rather than dressed up as real faces: a missing companion is faked — bold
by drawing the glyph a second time offset by max(1, size/14) px, italic
by shearing 0.2 about the baseline. Both renderers read the SAME rules
(`cell_grid.CellSynthesis`) because a bold run that renders bold on the
oracle and regular on the host is worse than no bold at all. A half
family is used for the half it covers: a real bold face with no
bold-italic is sheared rather than double-faked.

Measured through the reference renderer, "mono" at one size:
regular 135 inked pixels, bold 189 (+40%), italic 124 (different shape,
not a second pass). Rendered a four-row sheet (regular / bold / italic /
bold-italic) and looked at it: four visibly distinct weights, columns
aligned identically across all four.

CELL GEOMETRY DOES NOT MOVE, which is the invariant the packed cell
rests on and the thing most likely to break here. A bold face has
different advances; in a lattice that must change nothing, because a
cell's position is its INDEX. Pinned by a test that compares every cell
rect, the cell width, the baseline, and the command's raster extent
across regular/bold/italic rows. Faux bold thickens ink inside the cell
and faux italic shears about the baseline — neither touches the pen.

Italic overhang is real ink and is protected by the existing two-pass
order (all backgrounds, then all glyphs). Pinned by rendering an italic
'H' beside a bright background cell and asserting the lean survives.

Wire format v7 carries the three companion ids. Windows follows the
version and still refuses only packets containing a cell grid.

Also: the terminal cursor blink is checked BEFORE the focus-visible
gate. A text caret blinks once focus is VISIBLE (the keyboard ring); a
terminal cursor blinks whenever the terminal holds focus, however it got
it — a click-focused terminal would otherwise sit steady while the
program that asked for `\x1b[1 q` waited.

Suite: 2975 pass / 14 skip / 2989 total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…creen verb

Two things a multi-window terminal app cannot do without.

=== A SECONDARY WINDOW CAN PAINT ITS TERMINALS ===

`rebuild` runs the app's `chrome.build` through `installChromeDisplayList`
— the path that produces every terminal cell. `rebuildWindowSlot`, the
secondary-window path, instead published the widget layout and emitted
with `WithChrome(.{})`: a chrome prefix of ZERO. So a second window
opened, laid out correctly, drew its tab strip, its split divider and
its widget bounds, and painted no cells at all. `ChromeOptions.build`
also took no window label, so even reaching it could not have helped —
it could not know which window it was painting.

- `ChromeOptions.build_window` is the per-window builder, taking a
  `ChromeContext` (canvas label, window id, that window's size, tokens,
  is_main). A struct rather than more parameters so the next thing a
  window needs to know does not break every caller again.
- `installChromeDisplayList` is parameterized by window — label, size,
  and the tree-currency flag — instead of reading the main canvas's.
- `rebuildWindowSlot` takes that branch, and so do per-window terminal
  sizing (`applyTerminalLayout`) and web panes, which were also
  main-canvas-only.
- `handleWindowSlotFrame` calls `on_frame` with the slot's own
  `gpuSurfaceFrame`, so each window has a viewport/PTY pump.

ADDITIVE: `build` is untouched and still works. The slot path is gated
on `build_window` being set, not merely on `chrome` — a builder that
cannot name a window would paint the MAIN window's content into a
secondary one, which is a different wrong answer, not a fix. An app that
does not migrate sees no change. Migration is one line:

    .build = view.buildChrome,
    // becomes
    .build_window = myBuildWindowChrome,   // (model, builder, context)

Verified by running it: a scratch `windows_fn`/`window_view` spike in the
consuming app, built against this commit, opened a second window and
BOTH windows painted live zsh prompts simultaneously — read from the two
automation screenshots. The spike was reverted.

Also fixed on the way: the install path re-emitted with a zero chrome
prefix right after `rebuildWindowSlot`, which would have erased the
chrome it had just installed.

=== THE FULLSCREEN VERB ===

`PlatformServices` had focus/close/minimize/show and no way to enter or
leave fullscreen, so an app could be told it was fullscreen and never ask
to be. Worse, `WindowState.fullscreen` existed but nothing ever filled
it — even the window-state store persisted a constant false.

- `set_window_fullscreen_fn` on `PlatformServices`, `Runtime.setWindowFullscreen`,
  `Effects.setWindowFullscreen(label, bool)` and `toggleFullscreenWindow`.
  SET rather than toggle so the call is idempotent and an app can restore
  a remembered layout without computing parity; macOS compares the style
  mask and only calls `toggleFullScreen:` when it differs.
- The READ half now exists: `WindowInfo.fullscreen`, filled from the
  window's style mask on every macOS frame emit, so a transition the USER
  started from the green button reports exactly like one the app asked
  for. `WindowInfo.state()` finally carries it into `WindowState`.

Chose (a) over (b), deliberately. (b) — appending the stock Enter Full
Screen item when an app supplies custom menus — is a real bug, but the
fix changes "you supplied a menu bar" from "you own it" to "you own it
plus items we inject", which is the wrong default for a framework whose
doctrine is explicitness, and it leaves an app still unable to drive
fullscreen from anything but a menu item it does not control. With (a)
in place a custom-menu app binds its own item in one line, and the
capability also serves shortcuts, buttons, and launch-state restore. (b)
remains worth doing as an opt-outable documented policy; it is not this
commit.

Suite: 2979 pass / 14 skip / 2993 total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every glyph the AppKit host draws lands in a CGBitmapContext, and font
smoothing - macOS's stem darkening for text - is off by default on the
transparent backing all three of those contexts use. The result is that
ALL text renders systematically thin, which is what the faint-text
report was about.

Measured with phux-cockpit's scripts/measure-glyph-smoothing.m against
the bundled JetBrains Mono NL, 13pt:

  scale 2   solid stem pixels  2341 -> 3164   (+35.2%)
  scale 1   solid stem pixels   635 ->  940   (+48.0%)

Filling an opaque ground under the cells was also considered and is NOT
included: measured on the same harness it moves solid stem pixels by
-0.3% (2341 -> 2334), so it buys no weight while adding a full-screen
fill to every frame.

The CPU reference renderer never touches CoreText and blends coverage
itself, so it does not share the defect and no reference screenshot can
catch a regression here. Re-measure rather than eyeball.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A closed window keeps its runtime table slot until its label or id is
re-created -- `removeWindowAt` runs at re-creation, not at close -- so
`self.windows[0..window_count]` still carries every window the app has
ever declared, with `info.open = false` on the dead ones. The snapshot
walked that span unfiltered and published a `window @wN` line for each,
while `listViews` and `appendAutomationWidgets` correctly found nothing
under them: a ghost window with no views.

That misleads exactly the assertion a smoke test wants to make. A test
that opens a settings window, closes it, and asserts the window COUNT
returned to one passed against a window that is not on screen, and a
test asserting the reconciled window set matched saw closed windows in
it forever.

Filter on `info.open` and advance the output index only for the windows
that survive, so the published slice is the windows that are actually up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`widget-key` dispatched a `key_down` and nothing else, so every
key-lifetime latch it armed stayed armed. Those latches exist because a
physical chord's release carries different modifier flags than its press
(Cmd is often already up by the time G comes back), so the
classification is latched on the view at the down and retired at the up
-- `consumeCanvasWidgetTabInputFocusEntry`,
`consumeCanvasWidgetTerminalPasteKeyLifetime`, and any app-level
shortcut latch built the same way. With no release ever arriving, the
SECOND drive of the same chord found the latch still held from the first
and was swallowed as the missing release: `widget-key canvas cmd+g`
twice in a row did the work once.

Fixed by synthesizing the release, not by adding an explicit release
action. Real hardware has no press without a release, so a harness able
to emit an unpaired one is a harness able to reach states no user can;
and pairing keeps every existing automation script's wire format AND its
meaning intact -- one `widget-key` line is still one keystroke. A new
release verb would instead have made correctness opt-in and left every
script written before it driving half a keystroke.

This is the discipline `widget-drag` (down/drag/up) and `widget-pinch`
(begin/change/end) already follow, down to sharing one timestamp across
the events because press and release are one gesture. The release
carries the chord's modifiers but never `text`: `key_up` is deliberately
barren in both text paths (`canvasWidgetTextEditEventFromGpuInput` and
the target-less commit fallback), so it cannot double-insert what the
press already typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ChromeOptions.build_window` takes a `ChromeContext` naming the window it
is painting; `Options.web_panes` took only the model. But panes are
reconciled PER WINDOW -- `applyWebPanes` runs from the main rebuild, from
every secondary window slot's rebuild, and from the presented-frame
ride-along -- so a hook that could not be told which window it was being
asked about had no choice but to answer with the whole app's pane set
every time.

A webview belongs to exactly one window. Every OTHER window's rebuild
therefore resolved that pane's anchor against a widget tree that does not
contain it and logged

    webview pane '<name>': no canvas widget carries semantics label ...

Behaviour was never wrong (the pane is found and snapped in the window
that owns it), but the warning fired on every rebuild of every other
window, which is the kind of noise that trains people to stop reading the
log.

Pass the same `ChromeContext`, built the same way `installChromeDisplayList`
builds it, so an app switches on one context shape whichever per-window
hook it implements: return the panes this window owns, and 0 for a window
that hosts none.

Changed in place rather than added alongside, unlike the `build` ->
`build_window` migration: `build` is a required field that every chrome
app already implemented, while `web_panes` is optional and its every
in-repo implementor is updated here (the ui-app preview test, and the
canvas-preview, workbench, and split-collapse examples). One hook with a
context beats two hooks with a precedence rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`semantics.hidden` was the only way to say "keep this out of the
accessibility tree", and it also suppresses painting. Authors reaching
for it on chrome — a search field's magnifier glyph, a rendered caret —
got a blank gap where the glyph should be, and no way to tell the two
intents apart.

Add `WidgetSemantics.decorative`: the widget and its subtree leave the
semantic tree (and with it automation snapshots) and can never take
focus, while paint, layout, hit-testing, and event routing are
untouched. It is the `aria-hidden` / `role="presentation"` counterpart.

`hidden` is unchanged and now documented for what it actually is: a
VISIBILITY flag that keeps the layout box — space reserved, siblings not
reflowed — while dropping paint, hit-testing, focus, and announcement.
That is exactly what an empty fixed-width slot wants (room held for an
affordance that is not currently showing) and what `Ui.nav`'s retained
inactive pages want, so every existing call site keeps its behavior:
the two engine uses that STAMP the flag (nav retain, anchored-tooltip
visibility), the paint/hit-test/routing/dismissal walks that read it,
and app-side spacers alike.

Both flags now answer one predicate, `WidgetSemantics.concealedFromAccessibility`,
so the announcement side cannot drift. The a11y audit's `nodePainted`
becomes `nodeAnnounced` and skips decorative subtrees — a glyph declared
as decoration must not then be reported as an unnamed image — and
`widgetSemanticsEqual` gains the field, so flipping `decorative` marks
semantics dirty without a repaint.

The field fits existing padding: `@sizeOf(Widget)` stays 776.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`native-sdk.os.openUrl` was a webview bridge command and
`openExternalURLIfAllowed:` a webview external-link policy decision, so a
Zig core with no web content had no way to ask the OS to open a URL —
the platform verb existed on every host (`PlatformServices.openExternalUrl`,
NSWorkspace on macOS) with nothing app-facing wired to it.

`fx.openUrl(url)` closes that. It follows `showNotification`, the
channel's other fire-and-forget platform-service effect: validated on the
loop thread, one synchronous platform call, no terminal Msg (the OS owns
whether a handler launched, so a success Msg would over-promise), inert
under fake execution and session replay.

The URL is treated as hostile, because cores build them from terminal
output, fetch bodies, and pastes. `validation.validateOpenUrl` refuses
whole — never trimmed or coerced into something openable — an empty URL,
one past `platform.max_external_url_bytes`, one carrying a NUL or any
other control byte / whitespace / DEL, and any scheme outside the
allowlist (`http`, `https`, `mailto`, matched case-insensitively so a
shouty scheme is honored and a shouty `JavaScript:` still is not).
`file:` and `javascript:` are refused by not appearing in it.

Unlike `runtime.openExternalUrl`, the effect is not gated on the webview
external-link policy: that policy governs links web content follows,
while this call comes from the app's own `update`.

A test observes it the way the notification effect is observed — bind the
null platform's services and read `lastExternalUrl()`, which records the
request without opening anything; a refused URL leaves it empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
canvasGpuCommandFingerprint decides whether a retained canvas command must be
re-encoded and sent to the GPU host. It hashed kind, bounds, opacity,
stroke_width, cap, id, clip, transform, shape, paint, image, text and effect --
and never read command.cells.

The cell_grid arm of gpu.zig puts ALL terminal content into .cells and leaves
shape, paint, text, image and effect empty; CellGrid.bounds() is purely
geometric. So a cell_grid row's packet fingerprint was INVARIANT under every
possible content change.

Downstream, canvas_frame.zig sets upserts[index]=false on a fingerprint match
and then skips re-encoding, and appkit_host.m calls rasterCacheRemoveKey only
for evicted or upserted keys -- so the host re-blits the raster it already
holds. A terminal emits one cell_grid PER ROW under a stable key, so once a
retained baseline existed a row's glyphs could never change on glass again.
Content only appeared when something else forced a full present.

Reported downstream as: terminal output not appearing when it should, an
occasional prompt landing at random, and typed text never showing. Confirmed
fixed on the real app by the reporter.

The cursor is a separate fill_rect whose bounds and paint ARE hashed, so it
kept moving over stale pixels -- which is why this read as "the text is the
same colour as the background" rather than as a stuck frame.

It also defeated every instrument used to chase it: the CPU reference renderer
re-rasterizes from the content-aware display list, so reference screenshots
always looked correct, and a background colour change alters a fill_rect PAINT,
which IS hashed, so that one did reach the glass.

The fix mirrors render_fingerprints.zig's cellGridFingerprint, which already
hashes the grid identity plus std.mem.sliceAsBytes(cells) over a full screen
every frame, so the cost is known-acceptable.

canvas_frame_patch_tests.zig now covers it: the fingerprint must differ for a
cluster change, a colour change, and a flag change. Seen failing before the
fix (exit 1) and passing after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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