Commit Graph

13 Commits

Author SHA1 Message Date
Chris Tate 8fc933b9db fix(windows): accelerate gpu surface presentation (#258)
* fix(windows): accelerate gpu surface presentation

- Render retained binary canvas packets through Direct2D and DirectWrite.
- Preserve dirty-region updates and GPU effects with a safe software fallback.
- Wire and validate the renderer across supported Windows build paths.

Co-authored-by: Omer Shatzberg <131801941+oshtz@users.noreply.github.com>

* fix(windows): harden Direct2D presentation

* fix(sdk): separate GPU backend request types

* fix(windows): honor GPU surface fallback contracts

* fix(windows): scale transformed blur kernels

* fix: harden Windows GPU surface presentation

* fix(windows): harden gpu blur and caption sampling

* fix(runtime): bypass packets for software surfaces

* fix(windows): preserve precise gpu surface updates

* fix(windows): reconcile GPU presenter state

* fix: preserve retained canvas resources

* fix: preserve Windows canvas packet fidelity

---------

Co-authored-by: Omer Shatzberg <131801941+oshtz@users.noreply.github.com>
2026-08-02 15:59:44 -05:00
Chris Tate 2209f022ee Live <terminal> sessions, and examples/workbench: a terminal beside a browser (#198)
* Bring libghostty-vt back behind a consumer-safe terminal_vt seam

- The framework module imports terminal_vt everywhere: a ghostty-vt wrapper where the dependency is safe to traverse, a stub (enabled=false) elsewhere
- Root builds resolve a lazy ghostty pin gated on being the build root, so consumers running this build script as a dependency never touch ghostty's graph
- Apps opt in via addAppArtifacts .ghostty_vt with their own pin; scaffolded builds keep the stub and stay free of harfbuzz/translate_c

* Live <terminal> sessions: the runtime-owned emulator behind a bound pty key

- terminal_session.zig: per-pty-key libghostty-vt sessions publishing resolved TerminalGrid snapshots, with the example tier's lossless outbound ring, query-answer write-back, key encoding, and theme-derived palette promoted into the runtime
- The effects engine grows a pty delivery tap (live drain and replay feed alike), so emulator state derives from exactly the journaled stream; outbound bytes ride the journaled ptyWrite verdict path
- The app loop installs the builder's terminal grid lookup, reconciles cols/rows from each element's laid-out frame into ptyResize, applies the scrollback source-wins echo, routes focused keys/IME text and wheel scrollback to the session, and dispatches on-terminal states

* Terminal session store tests: the element contract at the store seam

- Lookup-driven session creation, fed output as resolved cells, stdin-ordered write-back (keys, text, DSR answers), retained-ring flush, resize reconcile, scrollback source-wins, respawn reset, and wide-cell spacer backgrounds
- Fix the snapshot sizing multiply: @min against a comptime bound refines its result type, so the product needed explicit widening

* The terminal emulator is an app-owned pin, opted into per app

- Replace AppOptions.ghostty_vt with terminal_sessions: bool; the framework resolves the app's own lazy ghostty pin with the app module's target/optimize and the safe flags, so an app asks for live <terminal> sessions with one field instead of threading a module
- Drop the ghostty pin from this package's build.zig.zon: a pin here materializes into every consumer's package directory even when lazy and unused (measured 27 packages / 585 MB in a scaffolded app's package dir, now zero), so the toolkit's own builds always carry the terminal_vt stub
- Add src/terminal_session_tests_root.zig so an emulator-wired app build can run the session store's tests, which skip in this package's own suite

* examples/workbench: a live terminal beside a browser in one split

- Add the workbench example: a <split> with a <terminal pty={key}> pane and a browser pane (back, forward, reload, address bar), app-owned navigation history, and a hidden-inset titlebar with per-pane drag bands
- The app spawns a shell with fx.ptySpawn and binds the key in markup; no emulator wiring, key encoding, or grid plumbing lives in the app
- Register test-example-workbench, whose build also runs the runtime session store's tests since this build wires the emulator

* Document live terminal sessions and how a build opts in

- Terminal docs: the runtime owns the emulator behind a bound pty key, plus an "Enabling live sessions" section covering the app-owned lazy pin, terminal_sessions = true, and the terminal_sessions_enabled flag apps can gate tests on
- Add the changelog fragment for the live sessions, the opt-in emulator, and the workbench example

* Workbench: the shell owns the keyboard when the window opens

- Mark the terminal element autofocus, so typing lands in the live session without a click first — a terminal window's keyboard belongs to its shell

* Terminal runs measure the face they actually ink with

- Derive the cell width from a 16-glyph mono probe divided by its length — the advance a merged run walks — and leave it unrounded, so glyphs stay on the cells the painter's backgrounds, cursor, and selection draw from
- Carry the measurement seam on each run: a command's raster extent is its own declared bounds, and the estimator's 0.6 em mono pitch falls short of a wider host face (macOS resolves the mono id to the system monospaced face at 0.618 em when Geist Mono is absent), which sheared a full-width row's last cell to a two-pixel sliver
- Cover both with a painter test that paints a full-width row against a wide-pitch provider and asserts every run's bounds hold its ink

* Workbench README: what the divider drag does to focus

- `stty size` named as the visible proof that the pty re-grids with the pane

- the divider keeps the keyboard after a drag; clicking the terminal returns it
2026-07-24 23:17:56 -05:00
Chris Tate 87fa3f92d0 Video playback: AVFoundation through the media surface, replayable end to end (#184)
* Add the video playback tier: platform seam, effects channel, journal v9

- One video channel mirroring audio end to end: loadVideo with the local-then-URL cascade and surface claim, transport verbs (play/pause/stop/seek/volume/mute/loop), key-stamped events, honest failed/rejected degrades, and automation-snapshot mirrors
- Pixels never enter the core: the platform decoder pushes RGBA8 through a copyable VideoFrameSink into the media-surface texture channel the load claimed
- Journal format v9: the .video effect-result kind (code 13) and platform-event tag (code 25) append the video fields after the v8 channel fields and journal every event verbatim for byte-identical replay with no producer attached

* Cover the video tier: lifecycle, frames, teardown, replay identity

- Fake and real executor batteries mirror the audio suite: request capture, event round trips, transport mirrors, rejection classes, cascade order, staged-host degrade, straggler swallowing, and the quit-while-playing stop hook
- Sink coverage proves decoded frames reach the claimed surface, replace/stop release the claim, and a stale sink push lands inert
- A recorded playback replays byte-identical into a decoder-less host with no producer attached, fingerprint and model equal; journal codecs round-trip the video event and effect shapes

* Export the video effect types and decline video on the embed host

- native_sdk.EffectVideo/EffectVideoEventKind/EffectVideoSource ride the SDK roots like their audio twins
- The mobile embed host declines video_playback until a shim registers a real decoder, the audio honesty rule

* Decode video on macOS with AVFoundation; teach on Windows and Linux

- One AVPlayer in the AppKit host: AVPlayerItemVideoOutput frames fitted to the sink's pixel budget, BGRA-to-RGBA vImage swizzle, a 1/60s run-loop frame pump plus the audio tier's 0.5s position clock, loop wraps without a completion, and paused seeks still paint their frame
- The frame sink crosses the C ABI as a callconv(.c) trampoline over the runtime's VideoFrameSink; a released claim answers 1 and the host stops its pump
- Windows and Linux stage the capability honestly: video_playback reports false and the load verbs answer a named teaching plus error.UnsupportedService; the CEF host stubs the video C ABI like audio

* Add Cmd.videoLoad and videoCtl to the TypeScript tier at opcodes 0x17/0x18

- videoLoad claims the named media-surface, resolves the local-then-URL cascade, and bakes autoplay/loop/muted into one flags byte; videoCtl drives play/pause/stop/seek/volume/muted/loop by verb ordinal with an f64 value
- The event arm is the seven-field record matched by NAME with the exact five-member state union both directions, the audio arm convention; refusals teach with the NS1027/NS1029/NS1030 vocabulary
- The host routes events on the TSVI key namespace, parks loads under the fake executor like audio, and covers the wire byte-for-byte in the package, host, and e2e suites

* Declare video from markup: the <video> element, house chrome, reconciler

- Element code 68 with flag attrs controls/autoplay/loop/muted at codes 82-85 (src rides the existing attr name; registry law keeps names unique); a leaf on all three surfaces — validator, interpreter, compiled engine — rejecting children like image
- ui.video composes the playback surface on the framework-owned surface id plus runtime-consumed transport chrome (ghost play/pause, proportional scrub slider, clipped time readouts in the built-in bar's register); presence IS playback: the ui-app reconciler loads on src change, applies loop/muted deltas, and stops when the element leaves the view
- Handler-less playback stays honest under replay: platform video events steer the channel mirrors while journaled effect records remain the only Msg source, so house-chrome sessions replay with live readouts and identical fingerprints

* Scope declarative video ownership to the playback it started

- The reconciler stops or retunes the channel only while the active key is the declaration's own: an update handler that loaded its own playback owns the single player, and a departing <video> element must not kill it

* Add the video-player example: house chrome and custom controls

- Player screen is one declarative ui.video with the house transport chrome; Custom screen composes its own bar (transport, +/-10s, proportional scrub, volume, mute, loop) from the command vocabulary over a bare media surface
- No bundled media: the launch argument or the source field names a local clip or http(s) URL, so a live macOS check is one command away
- Headless tests drive the whole transport with the fake executor's synthetic events, the automation widget path, and the null decoder behind the declarative screen

* Document the video element across the docs surfaces

- Components page, catalog entry, native-ui element table row, and the LSP/vocab doc strings for src/controls/autoplay/loop/muted
- Deterministic preview scenes render the placeholder with the house chrome (regenerated video webps only; the untouched catalog keeps its committed renders)
- Changelog fragment states the new element, the command vocabulary, the staged platforms, and the journal v9 break

* Resolve video attribute docs in the markup LSP hover path

- attributeDoc consults the video scoped table so controls/autoplay/loop/muted hover like every registry attribute; the coverage pin now names the video composite

* Make the video-player example's docs and status line honest

- README run instructions use the real CLI verbs: native dev for build-and-run, or native build plus the zig-out binary with the clip argument (the argument passthrough shape)
- The status line now matches each screen's ownership model: the declarative Player screen shows a static teaching (its transport state lives in the runtime-owned chrome, so an event-fed status there could only lie or stall), while the Custom screen keeps the event-fed loading/dimensions/finished/failed line it owns; tests pin both across load, playback, pause, and completion
- Direct-SDK examples handle the runtime's video event in their exhaustive event switches

* Stage video events non-lossily and deliver fed terminals under replay

- Video events leave the lossy pending ring for their own non-lossy stage (the image/channel discipline): a loop-side .rejected or .failed is its load call's only terminal and a fed event is one recorded delivery, so a burst past the ring's capacity must never evict one — pinned by a 40-rejection burst test
- Fed events capture the handler at feed time and deliver their journaled values verbatim when the channel no longer resolves them: under replay the platform .failed event that follows the record applies the channel reset first, and delivery-time resolution silently dropped the Msg the recording dispatched — pinned by a recorded mid-playback failure replaying fingerprint-identical
- loadVideo's deterministic refusal classes extract to the pure videoLoadRejected, one source of truth a caller-side validator can consult

* Refuse an invalid video_load before it re-routes the bridge entry

- The engine keeps the current playback when it rejects a load, but the bridge re-keyed its single routing entry optimistically first: a refused replacement left the surviving stream's events and transport verbs answering to the refused key and arm
- The bridge now consults the engine's own videoLoadRejected gate before committing the entry and stages the rejection Msg to the refused arm directly (stageLoopMsg, the channel-admission precedent), leaving the entry and the engine untouched
- Pinned: a rejected replacement delivers its rejection while the live stream keeps its events and its wire-key gate

* Drive the house video chrome from keys and rebuild it in every window

- Keyboard activation (Enter/Space) of the transport's play/pause control now drives the video channel exactly like the pointer release: the control advertises Play/Pause to focus and accessibility, so the intent must act instead of being consumed silently — pinned beside the pointer-toggle test
- Runtime-consumed control paths and handler-less video events rebuild through one helper that follows dispatch's discipline: the main canvas against ITS window (a control event from a secondary window used to target that window with the main canvas label and error) and the window slots after it, so a <video controls> declared in a secondary window's tree repaints from the moved mirrors

* Fail a macOS video load whose conversion buffer cannot allocate

- The frame tap's reusable BGRA-to-RGBA conversion buffer failing to allocate used to degrade to a silent zero-frame pump while the load still acknowledged LOADED: playback reported positions forever and could never deliver a pixel
- videoAttachOutputForItem now reports the failure and the status hop answers with the FAILED terminal instead of the acknowledgment - the honest degrade; an audio-only item (no video geometry) keeps its documented honest-absence path

* Journal handler-less video terminals and quarantine stale fed events

- Loop-side video terminals now stage and journal with no Msg handler bound (the image arm's rule): a declarative playback binds no handler, but its synchronous .failed is executor truth — the record is what replays the channel reset, and the staged delivery's wake re-renders the chrome
- A fed event whose staged key no longer names the live playback (replaced before its drain, or a replayed platform .failed already applied the terminal) delivers its staged values verbatim and leaves the live channel alone — applying a replaced stream's terminal would reset the replacement
- A journal-fed video record claiming a millisecond or dimension scalar at or past 2^53 refuses replay as a damaged record at the gate (no recorder writes one, and the TS tier's exact-integer widening would trap on it) — pinned by a hand-patched journal

* Route every video event by the key of the load that produced it

- The bridge's engine key now carries the issuing load's event-arm tag in its low byte (videoKeyForTag): a staged synchronous .failed that delivers AFTER a replacing load re-keyed the single entry still routes the arm of the load it answers, instead of handing the old stream's failure to the replacement's arm
- videoEventMsg routes by the event's own key tag, never the mutable entry's; the wire-key gate on transport verbs is unchanged
- Pinned: a replaced load's straggling terminal routes its own arm while the replacement's stream keeps its own; the request-key pins move to the tagged shape

* Reconcile <video src> from every window's tree and repaint Msg-less failures

- Secondary-window builds now feed the video reconciler: Ui.video promises that declaring the element IS the playback in every window's tree, but slot builds discarded the declaration — the main canvas wins when both declare (one player, one owner), the first declaring slot keeps ownership until it stops declaring or its window closes (reconcile-close included)
- The main build stamps the video mirrors it rendered; drainEffects compares that stamp after a Msg-less drain and re-renders the chrome when they moved — a handler-less declarative playback's synchronous failure no longer leaves controls advertising a playback that is gone

* Paint the poster frame for paused macOS video loads

- A load acknowledged while paused (autoplay = false, the documented poster-frame shape) ran no frame timer and never pumped its first decoded frame: the surface held the placeholder until the user played, paused, or seeked
- LOADED now arms a bounded first-frame hunt: the frame timer polls until the first frame pushes (a paused load then stops the timer; a playing one keeps it), surrendering honestly after ~3s if the output never yields one

* Restart the macOS frame timer when buffered playback actually begins

- The poster hunt could stop the pixel clock while AVPlayer was still waiting to play (a remote autoplay stream yielding its poster mid-buffer, or a hunt surrendering past its bound), and nothing restarted it when the waiting phase ended: audio played and positions ticked with no frames flowing
- The timeControlStatus hop now arms the frame timer whenever playback reports rolling (idempotent beside videoPlay's own arm), for local and remote sources alike

* Deliver a staged video terminal past stop instead of panicking

- A Cmd.batch([videoLoad, videoStop]) on a host whose load fails at once stages the .failed before stop retires the bridge entry; the drain then hit the entry gate's panic even though the terminal is the load call's only answer
- videoEventMsg drops the entry gate: every event reaching it was produced by a bridge-issued load and carries its arm tag in its own key (the engine swallows its post-stop stragglers itself), so routing needs no entry at all - pinned by the load-then-stop batch shape

* Retain every window's video declaration and promote on the owner's close

- The reconciler kept only the owning window's <video src>: closing that window (or its declaration vanishing) stopped playback and left another window's mounted video inactive until an unrelated rebuild
- Slot declarations now retain one entry per window (the table matches the window budget); when the owner closes or stops declaring, the next retained declaration promotes inside the same dispatch - pinned by a two-window close-promotes test with no on_close Msg and no rebuild

* Retry the frame wake for identical pushes still awaiting adoption

- A refused frame-wake request leaves pending clear so a retry can land, but the push-boundary fingerprint short-circuit returned before the wake: a static frame pushed again after a transient request_frame_fn refusal short-circuited forever and the staged bytes were never adopted
- An identical push now falls through to the wake while bytes are still staged; only an adopted frame's repeat stays a pure no-op

* Expose videoPlayback through the bridge alias and the TS feature type

- platformFeatureFromString gains the videoPlayback camel-case alias every other feature carries, so window.zero.platform.supports("videoPlayback") answers the platform truth instead of InvalidPlatformFeature
- NativeSdkPlatformFeature adds video_playback/videoPlayback so TS clients can compile the support query

* Stamp every platform video event with the load that produced it

- The engine mints a token per loadVideo, passes it through the load seam, and every host echoes it in each event: takeVideoMsg swallows an event whose token is not the current load's, so a replaced playback's queued terminal can neither reset the replacement, release its claim, nor route through its handler - pinned by a stale-failed-after-replace regression
- macOS hardening on the same theme: the item end/failure notification blocks re-check note.object against the current item (a removeObserver cannot recall a block already enqueued on the main queue), and the host carries the token across its terminal emits' teardown
- The journaled video platform event carries the token, so replay's mirror steering swallows stale recorded events exactly as live (the re-run loads mint the same deterministic sequence); the null platform echoes tokens like the real hosts and its fake position advance saturates instead of trapping on hostile deltas

* Pair replayed video deliveries with their events; token-gate fed entries; stop abandoned players

- A recorded video Msg dispatched synchronously inside its platform event's dispatch, but replay delivered the fed record at the NEXT drain: an update loading the next clip from its completion handler ran too late and the new clip's .loaded event was swallowed against the old load's token - takeVideoMsg under replay now pops the fed head (the journal's contiguity puts each event's record immediately before it) and dispatches it during the same event, pinned by a chained-load playlist session replaying byte-identical
- Pending video entries carry the load token that staged them, and delivery resolves against the live channel only while it still IS that load: the public key alone cannot tell two loads under one app key apart, and a stale fed terminal resolving against a same-key replacement would have reset it
- failVideoChannel silences the platform player it abandons (best effort): a load that succeeded before a later step refused kept its player decoding while the reset channel forgot it, and the inactive channel skipped it at teardown too

* Keep refused and uninstalled declarations out of the video reconciler

- A declared src the engine's own gates refuse (a malformed URL, say) no longer commits the reconciler's tracked ownership: the running playback keeps its identity - so removing the element later stops IT, instead of hashing the refused source and stranding the playback forever; the refused src is remembered separately and taught once, never re-attempted every rebuild - pinned by a refused-declaration ownership test
- A secondary window's declaration is captured only after its build pass and layout succeed and the tree installs: a build whose layout errors never displays, so its declaration never steers the playback either

* Link CoreVideo wherever appkit_host.m builds standalone

- The frame pump's CVPixelBuffer calls are real CoreVideo symbols: the central build gained the framework, but the generated-app template and the standalone example builds still linked only AVFoundation - a generated macOS project failed to link
- The template and every example build.zig that compiles the AppKit host now link CoreVideo beside AVFoundation

* Gate the poster hunt on a real push and clamp millisecond CMTimes

- videoPumpFrame initialized its push result to the success code, so a NULL base address or failed vImage permutation ended the poster hunt and stopped the frame timer with nothing on the surface; a sentinel now keeps the hunt alive until a push actually returns success
- NativeSdkCMTimeFromMs narrowed the u64 millisecond position straight into the signed CMTimeValue, turning absurd seeks negative; the value now clamps at INT64_MAX so AVFoundation clamps to the duration as documented

* Retire the null video player when a non-looping playback completes

- advanceVideo left the fake player loaded after its completion, so post-completion transport calls succeeded where a live host's torn-down player refuses them (and a replayed play could emit a second completion)
- the completion now unloads before the event returns, matching the live hosts' retire-before-emit order; tests pin the unload and the failed-play degrade path

* Stop the custom playback when an empty source commits

- loadCustom returned early on an empty committed source, leaving the previous video rolling under a status line that said "no source"
- an empty commit now stops the playback and resets the transport mirrors, with a test pinning the stopped channel and the honest status line

* Route replayed video records by the journaled load identity

- Every .video effect record now carries the producing load's token; replay feeds through feedVideoRecord, which resolves the token against the live channel or a retired-load park instead of binding the record to whatever the channel holds at feed time.
- loadVideo and stopVideo park the outgoing load's identity under replay, so a synchronous terminal staged inside the very dispatch that then replaced or stopped its playback still delivers the recorded Msg with the recorded identity - never EffectNotFound, never a reset of a same-key replacement the recording kept playing.
- The replay pairing in takeVideoMsg now token-gates fed entries against the platform event, and two session tests pin the batch shapes: load-then-stop and load-then-replace under one app key.

* Check the Core Video lock result before touching the pixel buffer

- A failed CVPixelBufferLockBaseAddress never maps the buffer, so reading the base address was undefined and the unconditional unlock unbalanced the lock count; the frame now drops with the buffer released.
- The poster hunt stays latched across the dropped frame - only an actual push verdict may end it, the converted-but-unpushed rule.

* Document why rate > 0 is transport intent at the playing derivations

- Per the AVPlayer.h contract, a playback waiting in AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate keeps rate at the requested value ("not currently effective but instead indicates the rate at which playback will start or resume"), so a stalled-but-unpaused stream already reports playing=1 + buffering=1 and the transport control offers Pause.
- AVPlayer resets rate to 0.0 on its own only with waits-to-minimize-stalling disabled - the local-file configuration, where playback would not self-resume and offering Play is the honest affordance.

* Keep the controls-bearing video element zero-intrinsic

- The <video controls> wrap column now adopts the media sizing contract (WidgetLayoutStyle.zero_intrinsic): the transport bar's intrinsic size never leaks into the element, so an unsized element in a hug container measures zero like the bare surface instead of rendering a controls-only strip.
- The wrap clips its content, so chrome cannot paint past a box the layout granted nothing; declared width/height stay definite through the frame and min/max bounds.
- A ui test pins both shapes: zero in a hug container, surface-above-bar inside a declared 320x180 box.

* Bound video scalars at delivery, sweep replay parks, republish flag deltas

- Platform video events clamp position, duration, and dimensions into the exact-integer delivery window (max_effect_video_scalar_exclusive, 2^53) at takeVideoMsg, whatever a host or embedder reports - the engine-side guarantee behind replay's damage gate, so an honest recording can never be refused as damage; a past-window readout now records and replays clamped, pinned by test.
- Replay-side retired video identities release at the first drain-pass boundary after parking (any journaled record for them feeds before that pass's event dispatches), so a long replayed playlist parks and releases one entry per clip instead of accumulating them; pinned at the channel level.
- Same-src declarative loop/muted deltas republish the runtime mirror in the same reconcile, so an automation snapshot taken after the flip reports the new value instead of the one published before the rebuild.

* Journal the video cascade's resolved source for replay

- loadVideo now journals a Msg-less .video_load record (the .clock/.env discipline) carrying which source the recording host's cascade resolved - a missing local file that fell through to the url is filesystem truth the replayed fake load cannot re-probe.
- Replay queues each record and the replayed load consumes it by its deterministic token, so videoSnapshot() and the automation mirror report .stream with the optimistic buffering flag exactly as the recording did, handler-less declarative playbacks included.
- Pinned by a record/replay test on an assets-absent host; the handler-less house-chrome pin now expects exactly the one Msg-less record.

* Keep buffering honest across paused streams and widen the fake's loop wrap

- Buffering means an un-paused stream waiting for bytes: a fresh URL load now starts the flag from the autoplay intent (engine mirror and replayed cascade resolution alike), the macOS host derives it purely from timeControlStatus instead of presetting it, and pauseVideo clears it - a conforming host emits no pause acknowledgment that could.
- The macOS time-control observer stays silent when the transition lands on paused: pause emits nothing by contract and position reports are for playback in motion; waiting and playing transitions still emit.
- The null platform's advanceVideo runs its loop wrap in widened arithmetic so a past-u64 delta lands on the exact residue instead of a saturated one; pinned along with the paused-stream and pause-clears-buffering shapes.

* Spill the replayed cascade-resolution queue past its inline capacity

- Loads per dispatch are unbounded by contract and every .video_load record lands before the dispatch's event, so the replay-side queue now grows geometrically past its inline 64 (freed when it empties and at deinit) instead of refusing the 65th record as a false divergence - the pending stages' non-lossy discipline.
- Pinned by a record/replay burst one past the inline capacity on an assets-absent host.

* Cross-check the journaled key on every replayed video pairing

- Reminted tokens pair records with replayed loads by position; the journaled key now proves the load at that position is the one the recording issued - feedVideoRecord refuses a mismatch as divergence, and a cascade resolution consumed under a different key latches one for the finish check.
- Replay now ends with a consistency check (the .finish replay control): a structurally valid journal whose queued cascade resolutions the replayed timeline never claimed fails as ReplayEffectDivergence instead of reporting success.
- Pinned at the channel level: wrong-key feeds refuse, an unclaimed or misclaimed resolution fails finishReplay, and the honest pairing stays silent.

* Copy the captured video declaration and verify the load bijection under replay

- The main-canvas <video src> capture now copies the source out of the build arena into app-owned storage (the slot captures' rule): a later failed rebuild resets the arena the old capture borrowed, and a window-close reconcile could hash or load overwritten bytes.
- Every non-rejected real load now journals exactly one .video_load record - refused cascades included (failVideoLoad) - so the replayed loads and the journaled records form a bijection: a replayed load with no record at its position, and a record whose position was never issued, both latch divergence for the finish check.
- The journal semantic epoch bumps to 2: identical bytes, changed replay meaning - an earlier recording's failed loads would replay as false divergence, so old journals refuse with the re-record teaching. Pinned: the extra-load shape fails finishReplay and the captured src aliases the app-owned buffer.

* Run the replay finish check even when no records were fed

- A latched video-load divergence can exist with zero fed effect records (the recording journaled nothing; the replayed timeline loaded anyway), so the end-of-journal consistency check now runs unconditionally; hookless apps answer ReplayUnsupported, which is honestly nothing-to-check.
- Pinned: a zero-record recording replayed into a build that declares a video fails as ReplayEffectDivergence instead of reporting success.

* Refuse non-http(s) schemes at the videoLoadUrl seam

- The seam documents streaming-only and promises to reject bad arguments before the platform is asked, but forwarded any scheme - a direct caller could hand file:///... to a host whose media stack opens it (AVPlayer does); the scheme gate now holds at the seam for every caller, matching the engine's own load validation.
- Pinned: file:, ftp:, and unparseable URLs refuse as InvalidVideoOptions without reaching the platform; https passes.

* Retire the null platform's video player before the load probes refuse

- The macOS host stops the previous player before the file-existence probe, so a refused load leaves no playback behind; the null platform now models the same ordering in videoLoad and videoLoadUrl instead of returning early with the replaced player still emitting frames and events under its old token.
- Pinned: a VideoSourceNotFound load leaves nothing loaded, no pending acknowledgment, and no position ticks.

* Fail frame-less video loads and keep the seek mirror on platform truth

- An asset with no video geometry (an audio-only file loaded as video) now fails the load instead of acknowledging a playback that can never paint - sound over a permanently blank surface is the same broken promise as the conversion-buffer failure the attach path already refuses.
- seekVideo applies the platform seek before moving the mirror: a player the host already retired (a completed non-looping playback) refuses the call, and the snapshot and house slider must stay on the frame actually on the glass; pinned in the post-completion transport test.

* Refuse post-completion seeks on the fake video channel too

- A non-looping natural end retires the platform player (retire-before-emit), so a later seek refuses live and the mirror keeps the terminal position; the fake executor now latches the completion and refuses identically, so replayed chrome and snapshots land exactly where the recording left them instead of scrubbing a player that no longer exists.
- Pinned by a record/replay parity test: a scrub after the natural end keeps position at the duration on both sides, model and fingerprints identical.

* Pair replayed video deliveries from any stage position; stop cancels the TS stream

- The replay pairing in takeVideoMsg now scans the pending stage for the event's fed entry instead of checking only the head: a regenerated loop-side rejection staged earlier in the same dispatch keeps its own drain-time order and no longer reverses the recorded Msg order; pinned by a record/replay ordering test.
- VideoMsgFn may return null - the adapter tier's cancel gate - and the TS bridge uses it to honor Cmd.videoStop's wire contract (stop CLOSES the stream, no events for the key after this): a staged synchronous terminal from the very batch that stopped the stream drains through the engine but never reaches the app, and reopening the tag lifts the latch.
- The house videoMsg constructor never returns null, so Zig-native apps keep the one-terminal-per-load delivery whole; the bridge pin now asserts the swallow and the reopen.

* Cancel the key's staged video answers inside the engine at TS stop

- Cmd.videoStop's cancel now runs where the answers live: stopVideoCancel removes every staged-but-undrained entry for the key before the channel goes idle, so nothing for a stopped stream can reach the app - even the synchronous terminal of a load-fail-stop batch, and even when a later load reuses the same event tag before the drain (a bridge-side tag latch could not tell those generations apart).
- A cancelled answer never journals, so replay regenerates and cancels the same entries and the timelines stay identical with no extra machinery; the VideoMsgFn seam returns to plain Msg and the Zig-native stopVideo keeps its one-terminal-per-load delivery.
- The bridge pin now covers the same-tag reopen-before-drain shape.

* Mirror synchronous load refusals under replay; token-scope ownership and cancel

- The .video_load record's video_kind now carries the load's outcome: a refusal reset the live channel before loadVideo returned, so the replayed fake load resets at the same instant (parking its identity for the journaled terminal) and a snapshot an update reads inside the very dispatch matches the recording's; semantic epoch bumps to 3 (same bytes, the field gained meaning). Pinned by a record/replay probe test.
- Declarative video ownership now rides the load token the reconciler captured at its own load, never the derived key alone - the key is a pure function of the source string, and a manual load carrying it must survive declaration removal and flag deltas untouched; pinned.
- stopVideoCancel is token-scoped: the stopped stream is the latest accepted load, so a replaced predecessor sharing the arm's public key keeps its owed terminal - only stop cancels; pinned at the channel level.

* Refuse post-completion play on the fake video channel too

- A non-looping natural end retires the platform player, so play meets an absent player live: one .failed terminal and the channel resets before playVideo returns; the fake executor now models the same refusal (failVideoChannel on plain fake; under replay just the identity park and reset, because the journaled terminal delivers itself), so a snapshot an update reads right after its own play answers the same on every executor.
- Pinned by a record/replay probe test: the mid-dispatch snapshot reads inactive on both sides, the terminal delivers at its recorded wake, and models and fingerprints match.

* Break the video timer retain cycles and gate video-load outcomes at replay

- Both video timers (the 1/60 pixel clock and the 500ms readout) are now weak-host block timers that self-invalidate when the host is gone: a target-selector repeating timer retains its target through the run loop, so a host destroyed mid-playback without an orderly stop could never dealloc - leaving AVPlayer, the timers, and the conversion buffer permanently retained.
- Replay refuses a .video_load record whose video_kind is neither .loaded nor .failed as a damaged journal: the recorder writes exactly those two outcomes, and anything else would steer the replayed fake load into a state the recording never had; pinned by a hand-patched-journal test.

* Commit the video declaration at install; provenance-gate video rejections

- The main-canvas <video src> capture now stages during the build and commits only when the rebuild installs: a build that fails downstream never mounted, and the retained tree on the glass still shows the old declaration - a later reconcile acting on the unmounted capture would stop or replace a playback the presented tree still declares.
- Replay refuses a .video record whose kind and token are recorder-impossible: rejections stamp token 0 (a refused load never minted one) and every delivery carries its minted token, so a .rejected re-stamped onto a delivered record can no longer slip through the regeneration skip and silently omit the recorded Msg; pinned by a hand-patched-journal test.

* Fail the replay finish check on undelivered fed video results

- A fed record is one recorded delivery, and the event that consumed it live follows it in every honest journal - so a fed entry still staged when the journal ends means truncation or hand-editing, and finishReplay now refuses instead of reporting success; regenerated loop-side answers may honestly outlive the last drain on both timelines and stay exempt.
- Pinned: a fed-but-undelivered result fails the finish check and the same result delivered makes it silent again.

* Restart a finished playback from Play instead of failing it

- A non-looping natural end retires the player, so Play answered with one failed event and the seek was guaranteed to spring back - a broken response to a valid finished state. Effects.restartVideo is the resume path: a fresh load of the channel's own remembered source with autoplay, keeping key, surface, handler, and the loop and mute flags, journaling like any load.
- The house transport's toggle restarts when the completion latch is set and its scrub disables (Play is the live affordance); the snapshot and chrome state expose completed so custom players can do the same - the video-player example's Play now restarts its finished clip.
- Pinned: clicking the house toggle after the natural end issues a fresh platform load and the playback runs again.

* Validate replayed video payloads and source shapes; retire the example's dead seeks

- The replay pairing now requires the fed record's payload to equal what the platform event resolves to: the recorder journals every delivery verbatim from that event, so an altered kind or scalar around an intact identity refuses as divergence instead of handing the app a Msg the mirrors contradict; pinned by a hand-patched-journal test.
- A journaled cascade resolution must be one the load's request shape could select (.local needs a path, .stream needs a url); an impossible pairing latches divergence for the finish check, pinned at the channel level.
- The video-player example's seek-family controls (slider, back, forward) disable after a natural completion - a retired player refuses seeks and the thumb would spring back; Play stays live and restarts.

* Document the video output's vended-size contract at the frame tap

- The pixel-buffer width/height attributes are client requirements AVPlayerItemVideoOutput satisfies by scaling every vended frame, not hints: a 3840x2160 H.264 source tapped with fitted 1866x1050 attributes vends 1866x1050 buffers, so a 4K playback never hits the oversized-frame drop - that guard is defense in depth against a hypothetical non-conforming host, and the comment at the attributes site now says so.

* Keep declared ownership across restart; saturate past-window seeks

- The house transport's restart mints a fresh load identity, so the declarative reconciler re-captures its ownership token when the finished playback was its own - removal and same-source flag deltas keep working after a replay-from-end; pinned in the restart test.
- The TS bridge no longer rewinds a finite seek past the exact-integer window to zero: it saturates just below the window, where the engine's duration clamp lands it at the end - an oversized forward seek means the end, and only NaN and negatives (not millisecond offsets at all) seek to 0; pinned at the bridge level.

* Class non-finite seeks as invalid offsets, never oversized forward seeks

- Infinity reached the past-window saturation branch and sought an active video to its end, though the literal validation rejects non-finite offsets: the saturation now requires a FINITE value, so Infinity seeks to 0 like NaN and negatives - not millisecond offsets at all.
- Pinned at the bridge level beside the finite past-window seek.

* Keep the reserved video surface id inside the f64 exact window

- Surface ids ride the TS wire as f64, and a source-less <video> with custom controls is fed by the app's own Cmd.videoLoad naming exactly the reserved id - the old value sat near 2^63, so the bridge's exact-integer validation rejected it and the element could only ever show its placeholder from TypeScript.
- The id moves below 2^53 (mnemonic preserved, bit 63 clear, still a valid producer id reserved by convention); pinned: in the window, outside the derived-texture namespace, and round-trips the f64 wire unchanged.

* Deterministic seek mirror, journaled handler presence, and baked rotation

- The seek mirror now gates on the deterministic completion latch on every executor: a completed playback keeps its terminal position live and replayed alike (the platform is not even asked - the retired player could only refuse), and residual platform verdicts are fire-and-forget so an exotic host's answer can never diverge replay's mirrors from the recording's.
- Every .video record journals whether its delivery dispatched a Msg, and feedVideoRecord requires the replayed handler presence to agree - a record whose Msg silently vanished, or a Msg live never dispatched, refuses as divergence instead of a silent consume.
- Rotated media renders upright: a track with a non-identity preferredTransform gets a properties-of-asset video composition baked into the vended frames (verified against a portrait-flagged H.264 asset: the raw tap vends encoded orientation, the composition vends display orientation); identity-transform assets skip the render pass and track-less streams have no transform to bake.

* Repaint the chrome when the reconcile moves playback; seek on slider key steps

- A src change loading an autoplaying replacement reconciles after the build installs, so the just-installed chrome advertised the OLD transport state while its control acted on the new one - Play on the label, pause in effect. The rebuild now runs one guarded repass when the reconcile moved the mirrors the build rendered; the repass reconciles an unchanged declaration, so the mirrors are a fixed point. Pinned: the replacement's pause glyph shows in the same build.
- Keyboard and assistive steps on the house seek slider arrive as set_value intents with no widget change event behind them; they now map their fraction onto the duration and drive the channel like the pointer scrub instead of being consumed silently for the next tick to snap back. Pinned with a focused arrowright step.

* Repaint secondary-window chrome when its own reconcile moves playback

- The slot rebuild reconciles its captured declaration after the slot tree installs, so a first mount of an autoplaying <video controls> in a secondary window rendered its chrome from the still-inactive snapshot - disabled Play over a playing video - until some later platform event, seconds away on a slow stream. The slot path now runs the main rebuild's guarded repass when the reconcile moved the mirrors the build rendered; the repass reconciles an unchanged declaration, so the mirrors are a fixed point.
- Pinned: the slot's first installed build shows an enabled pause toggle.

* Latch missing video records; scope bridge verbs to their own stream

- A recorded video event arriving under replay with a handler bound and no fed record before it now latches divergence for the finish check: the recorder journals every handled delivery immediately before its event, so absence is truncation or hand-editing - never a Msg to drop silently; pinned at the channel level.
- Bridge video verbs now prove ownership with the load identity their own accepted load minted (Effects.videoMintedToken): a wire key whose stream was since replaced by a load the bridge never issued - a declarative element's - no-ops its transport verbs, and its stop cancels only its own stream's staged answers while the playback on the channel survives untouched; pinned at the bridge level.

* State the replace-is-not-stop rule at the videoLoad contract

- A replaced load still delivers the terminal it owes, routed to its own event arm - the never-silent promise applies to the replaced stream's failure exactly as to any other, and only Cmd.videoStop cancels undelivered answers. The wire contract now says so explicitly where the open-or-replace semantics are defined; the bridge's routing and the arm-separation pin already enforce it.

* Remember volume across a failed load; gate impossible terminal payloads

- Volume is a remembered preference the next load re-applies, so the bridge's ownership gate now lets videoSetVolume through when the channel is IDLE - a failed load's handler routinely sets it before retrying, and with nobody's playback on the channel there is nothing to protect; a foreign live playback still gates it. Pinned both ways.
- Replay refuses a .video record whose payload shape the recorder never writes for its kind: terminals deliver with playing and buffering false and no dimensions, and a completion pins position to the duration - a synchronously failed load's record has no platform event behind it to cross-check, so the gate is where a hand-set width refuses; pinned by a hand-patched-journal test.
2026-07-22 23:19:29 -05:00
Chris Tate 4f22071c82 Keep the WebView stub compile silent and support GLib 2.72 (#152)
* Remove the informational pragma from both WebView stub paths

- zig renders every clang diagnostic of a failing C compile as error:, so the note masqueraded as the build-killer whenever a real error joined it
- the stub branches keep their explanatory comments; the misconfigured web-build #error stays
- build graphs (build/app.zig and the ejected template) document why the expected state is silent: runtime WebViewNotFound is the teaching channel

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>

* Compile the GTK host against GLib 2.72

- G_APPLICATION_DEFAULT_FLAGS is GLib 2.74+; the host's GTK floor is 4.10, whose own GLib floor is 2.72
- distros backporting GTK 4.10 onto a 2.72 base (Ubuntu 22.04 derivatives) now compile canvas apps out of the box

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>

* Pin diagnostic-free stub compiles in both canvas-smoke CI lanes

- zig cc passthrough is the one channel where C warnings reach stderr, so each lane compiles its stub host and asserts zero diagnostics
- fails on the old pragma, passes after its removal; the webkit-less build + ELF audit receipts already live in these lanes
- changelog fragment for the user-visible fix

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>

* Run the stub diagnostic receipts against a cold zig cache

- on a cache hit zig cc replays nothing, stderr included, so a restored cache would hide the exact diagnostics the steps pin against
- throwaway ZIG_GLOBAL/LOCAL_CACHE_DIR per step keeps every run cold

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>

* Print the captured compiler output when a stub receipt compile fails

- Actions runs steps under bash -e, so a nonzero command substitution killed the step at the assignment and swallowed the forensics the step captured; the || arm keeps errexit out of the capture.

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>

* Pin the GLib 2.72 fallback with an error-set receipt on stock 22.04

- No stock image pairs old glib with GTK 4.10, so the receipt asserts the error set: GTK-age failures only, never a glib symbol - a future 2.74+ symbol without a fallback trips it.

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>

* Whitelist GTK-age diagnostic shapes in the GLib 2.72 receipt

- The prefix blacklist missed non-undeclared shapes (unknown glib type names); the whitelist rejects everything that is not a GTK-age root or its cascades, with the cascade rationale in the script.

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>

* Demand positive evidence from the GLib 2.72 receipt

- Unlocated error shapes (driver/invocation failures) reject instead of sailing past the located-diagnostic parser, and fewer than five GTK-age roots means the compile proved nothing.
- Renamed to the allowlist vocabulary.

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>

* Narrow the conversion cascade to its signature and document the lattice

- Int-to-pointer lines only pass when converting from 'int' (the undeclared-function-returns-int shape); the docstring now records why cascade allowances are sound: full-GTK lanes compile the same file, so only the old-glib delta reaches this filter, and glib regressions always reject at their root.

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>

---------

Co-authored-by: nextpointer <110530249+nextpointer@users.noreply.github.com>
2026-07-18 13:23:03 -05:00
Chris Tate e59091060f Fix the Linux Debug startup crash at the GTK create-view seam (#153)
* Fix the Linux Debug startup crash at the GTK create-view seam

- Force the LLVM backend for the app executable on x86_64 (and in the ejected template's build): zig 0.16.0's self-hosted backend shifts native_sdk_gtk_create_view's stack-passed arguments, so Debug `native dev` runs crashed reading a garbage role pointer.
- Cap the GTK host's view string lengths at the platform limits with a teaching refusal, so a corrupted C-ABI boundary fails loudly instead of faulting in strndup.

Co-authored-by: codehz <13158903+codehz@users.noreply.github.com>

* Add a Linux Debug-scaffold runtime smoke to CI

- linux-dev-smoke scaffolds the default ts-core template with the CLI, builds it -Doptimize=Debug with automation, and drives it under Xvfb to ready + first presented pixels.
- Pins the Debug half of the Linux runtime story: release-shaped lanes always use the LLVM backend and can never see a Debug-only x86_64 codegen fault.

Co-authored-by: codehz <13158903+codehz@users.noreply.github.com>

* Apply the x86_64 LLVM-backend workaround to the checked-in example builds

- The web-frontend examples own expanded build.zigs that created their exe without use_llvm, leaving Debug x86_64 runs exposed to the same self-hosted-backend C-ABI miscompile the app graph now guards against.

Co-authored-by: codehz <13158903+codehz@users.noreply.github.com>

* Restore the windows-canvas-smoke job key the dev-smoke insertion ate

- Duplicate name/runs-on/steps keys collapsed both jobs into one: the Wine smoke ran mislabeled and the new Debug scaffold smoke never executed.

Co-authored-by: codehz <13158903+codehz@users.noreply.github.com>

---------

Co-authored-by: codehz <13158903+codehz@users.noreply.github.com>
2026-07-18 12:14:13 -05:00
Chris Tate 4b9d40b871 Carry the TypeScript toolchain as a CLI dependency (#123)
* Carry the transpiler's TypeScript toolchain as exactly-pinned CLI dependencies

- @native-sdk/cli gains @typescript/typescript6 6.0.2 and @typescript/old npm:typescript@6.0.3 as regular dependencies, so npm installs the whole toolchain (the wrapper AND the real compiler it re-exports) in the same transaction as the CLI
- packages/core pins the same two exact versions as devDependencies (replacing the ^6.0.2 range) and its lockfile follows
- check-version-sync asserts both pins string-equal across the two manifests and shape-exact: X.Y.Z for the wrapper, npm:typescript@X.Y.Z for the alias

* Gate TS verbs and direct zig build on toolchain resolution, teach per layout

- transpilerResolves walks node's ancestor node_modules from packages/core and requires the wrapper's manifest + entrypoint AND the aliased real compiler resolving from the wrapper's own directory (nested, hoisted, and global layouts alike); partial extractions read as unresolved
- the gate runs before any zig spawn (check, dev --core, build-graph verbs) and never runs npm: checkouts (the packages/core/test signal) are taught the one npm ci --include=dev against a resolved absolute path, npm layouts are taught the reinstall (BrokenToolchainInstall)
- build/app.zig carries the twin predicate for direct zig build and fails configure with a clean teaching instead of a panic; test-ts-toolchain-twins pins the twins' alias probes and teachings in lockstep

* Add the toolchain-as-dependency changelog fragment

- first TS-verb use needs no network, no install step, and never runs npm
- repo checkouts are taught the one npm ci command; direct zig build teaches instead of panicking

* Exit quietly on the broken-install teaching

- BrokenToolchainInstall already prints its reinstall guidance; list it in failVerb's expected-error switch so the CLI exits without a Zig error-return trace.

* Scope the pre-spawn TS toolchain gate to CLI-generated graphs

- Ejected apps pin their own SDK in build.zig.zon, so gating the CLI's
  resolved SDK false-failed healthy apps whose direct zig build works;
  they now flow to the spawn, where build/app.zig's tsCoreStage teaching
  names the app's actual dependency SDK.
- Name the one generated-vs-ejected decision (isEjectedAt) so the
  preflight and the argv assembly share a single predicate, and keep
  check/dev --core gated: they always transpile against the CLI's SDK.
- Cover both paths in a verbs test: an ejected-shaped TS layout skips
  the gate against a toolchain-less SDK; the generated layout still
  teaches, and passes once the toolchain resolves.

* Raise the TypeScript-tier node floor to 22.15 with a fail-fast teaching

- ts_run.mjs now teaches "upgrade to Node.js 22.15+" and exits before importing a node_modules-resident target when module.registerHooks is missing, instead of dying inside node with the raw ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING; repo-checkout targets keep running natively on any Node 22.
- Every "Node.js 22+" surface (nodeMissing teaching, build/app.zig panic, scaffold README, quick-start docs, changelog fragment) now names 22.15.
- packages/core/test/ts_run.test.ts pins both tiers by spawning the runner with a --import preload that deletes module.registerHooks, plus the real hook-stripping path against the package's own toolchain install.

* Strip every .ts through the runner hook and pin-check the resolved compiler

- build/ts_run.mjs now strips ALL .ts modules (node's default stripping is 22.18+, so the 22.15-22.17 checkout fall-through died raw); hooks-absent teaches for any .ts target, and the hook requires @typescript/old directly with a sane direct-run teaching when the dev install is missing
- typed_ast.ts imports @typescript/old instead of the wrapper, so a consumer tree's conflicting hoisted copy can never shadow the SDK's exact pin (the wrapper stays the declared dependency)
- both resolution twins (ts_core.zig transpilerResolution, build/app.zig tsToolchainResolution) read the resolved @typescript/old version and hold it against the npm:typescript@X.Y.Z pin parsed from packages/core/package.json, teaching resolved-vs-pinned on mismatch; fixtures gain version/pin manifests and mismatch tests cover both layouts

* Validate the compiler from the packages/core origin runtime resolves from

- Both resolution twins now walk @typescript/old from packages/core — the origin typed_ast.ts and ts_run.mjs actually load from — instead of holding the wrapper's origin against the pin, which false-rejected npm's own conflict shape (consumer's hoisted compiler + our exact pin nested under the CLI); the unused @typescript/typescript6 wrapper is no longer probed at all, though it stays a declared dependency.
- Make the node requirement branch-aware everywhere it is taught (22.15+ admits 23.0-23.4 numerically, but registerHooks only landed in 22.15 and 23.5): one phrasing across ts_run.mjs, both gate teachings, the templates README, the quick-start docs, and the changelog fragment.
- Correct the changelog fragment's claim that repo checkouts run on any Node 22: every .ts module rides the same registerHooks stripping, so the floor is uniform across layouts.
2026-07-13 14:00:54 -05:00
Chris Tate 8e37536acb Windows fixes: caption clearance, GUI subsystem, elision budget, dark titlebars (#122)
* Hand the full snap quantum back to the text wrap/elision budget

- Edge snapping rounds each frame edge independently, so an exact-fit text frame at a fractional position can lose up to a FULL device pixel of width — the old 0.5/scale hand-back plus the 0.125 elision slack under-covered it, and the TS scaffold's centered counter digit painted as "…" on Windows at scale 1
- textWrapMaxWidth now returns width + 1/scale with the epsilon policy documented at the seam; regression tests sweep fractional origins for every digit at scales 1/1.25/1.5/2 plus the exact centered-row scaffold shape

* Reserve the Windows caption cluster in drag-header layout

- A window-drag header that never consumed the chrome channel's trailing inset laid right-aligned content under the DWM min/max/close cluster; the caption punch-out then visibly truncated it (system-monitor-ts's header status)
- The runtime now runs a one-retry clearance pass: when the built layout leaves drag-header CONTENT under the platform-reported control cluster, it stamps the cluster into DesignTokens.window_controls and rebuilds once, and widget layout trims the drag row's content box on the cluster's side - apps that already pad (soundboard) never trigger and keep byte-identical layout
- Regression: engine tests for both cluster sides and the content-only trigger, plus runtime tests faking Windows-shaped chrome through the null platform for the naive and the contract header shapes

* Give Windows release exes the GUI subsystem and pin it at packaging

- Every app exe was console-subsystem (zig's default), so dev AND packaged apps flashed a terminal behind the window; the posture is now: Debug keeps the console (dev logs live there), release-shaped builds get /SUBSYSTEM:WINDOWS - redirected logging still works because only console AUTO-allocation is subsystem-gated
- native package reads the wrapped exe's PE subsystem (peSubsystem), warns with the rebuild teaching when it wraps a console binary, and reports it in PackageStats.windows_console_subsystem; tests pin the reader and both packaging outcomes over synthetic PE headers
- Verified on the Windows box: ReleaseFast ts-smoke.exe reads subsystem=2, launches headed with no console, automation and file-redirected logs intact

* Dark-mode titlebars on Windows and a kinder scaffold empty state

- Standard-chrome windows set DWMWA_USE_IMMERSIVE_DARK_MODE from the OS app scheme at creation (pre-show, no light-caption flash) and on appearance broadcasts; hidden-titlebar windows keep their pixel-sampled caption fidelity
- The TS scaffold's status bar branches on {stampedMs < 0} to say "press Stamp for a timestamp" instead of "stamped: -1ms"; fresh scaffold verified through native check and the ts-core e2e scaffold pins

* Emit the GUI-subsystem posture into the scaffold's standalone build.zig

- The web-frontend scaffold (Next/Vite/React/Svelte/Vue, native init --full) generates its own build.zig and never set exe.subsystem, so those apps still shipped console-subsystem Windows release exes - and the packaging warn had no remedy, since rebuilding can't fix a scaffold that never sets it
- The generated build.zig now carries the same release-only assignment as the SDK build graph (Debug keeps the console for dev logs), with the condensed rationale
- The Vite template test pins the emitted condition and assignment lines

* Resolve a drag header's anchored children against the cleared rect

- The collision scan counts anchored descendants of the drag header, but the remedy only trimmed the flow content box - an anchored floater triggered the one retry and then stayed under the caption cluster (retry paid, nothing moved)
- layoutWidgetDepth now passes the anchor base through windowControlsClearedContent for window_drag widgets only, covering both the trailing (Windows) and leading (macOS) clusters; non-drag widgets' anchored children are untouched by construction
- Engine test pins the anchored-only collision converging in one pass (scan fires, remedy moves it, re-scan stays quiet), the macOS mirror, and byte-identical non-drag anchoring under stamped tokens

* Run the window-control clearance retry in secondary windows too

- rebuildWindowSlot laid out exactly once, so a model-declared hidden-inset window's drag header still rendered content under the OS caption cluster; it now runs the same collision scan + one-retry pass as the main rebuild, stamping the cluster into a local copy of the slot's tokens.
- windowControlsReservation takes the canvas label so both rebuild paths share it, and the slot's build+layout pass is factored into buildWindowSlotPass (same arena reuse as the main path's buildLayoutPass retry).
- ui_app_window_tests: a colliding secondary drag header re-lays clear of the cluster while the main canvas stays unstamped, and a padded secondary header keeps its own layout.

* Read only the PE headers for the packaging subsystem check

- peIsConsoleSubsystem slurped the whole exe through readPath (capped at 128 MiB) and swallowed every error as false, so a console exe over the cap packaged without the promised warning and packaging allocated the entire binary to read 2 bytes.
- peSubsystemAtPath reads the 0x40-byte DOS header, bounds the rest to e_lfanew + 94 bytes through the existing pure peSubsystem parser, and rejects e_lfanew past a 1 MiB ceiling as not-a-real-PE; only genuinely-unreadable files still degrade to no-claim.
- Tests: a sparse console exe past the old cap still warns, and a bogus 8 MiB e_lfanew answers nothing under the failing allocator (no full-file or offset-sized allocation).

* Persist the subsystem verdict in the report and stats summary

- package-manifest.zon now carries .subsystem = "gui"/"console" whenever the posture check ran (a Windows package with a binary), threaded through writeReport like asset_count; other targets make no claim
- the stats summary prints "subsystem: gui" or "subsystem: console (a terminal window opens behind the app - rebuild with `native build`)" alongside the web-layer and signing verdicts
- PackageStats.windows_console_subsystem is now a ternary (?bool) so an unprobed package cannot pass for a GUI verdict; the synthetic-PE tests pin both report fields and the no-binary null

* Stop claiming a gui subsystem the PE parse never established

- Replace the bool verdict with WindowsSubsystem { gui, console, unknown }: gui and console only when the optional header said so, unknown for non-PE/truncated bytes, benign read errors, and unmodeled subsystem values; null still means the check never ran
- Diagnostic prints "subsystem: unknown (unrecognized executable format)" and the report writes .subsystem = "unknown" instead of affirming gui; console warning behavior and OutOfMemory propagation unchanged
- Pin unknown for a non-PE file, a truncated PE, and a native-subsystem exe; retarget the sparse-oversized and offset-ceiling probes onto the verdict helper

* Package a release-shaped exe from the web-frontend scaffold

- The emitted build.zig defaulted -Doptimize to Debug for everything, so the documented `zig build package` wrapped a Debug, console-subsystem exe; the package step now builds its own exe that defaults to ReleaseFast (mirroring `native build`) while an explicit -Doptimize or --release still pins both roles.
- Registers -Doptimize by hand instead of standardOptimizeOption so the graph can tell unset from explicit, the same optimizeMode split build/app.zig uses; the --optimize arg, the artifact name, and the Windows GUI-subsystem posture all key on the package exe's actual mode.
- Extends the Vite template pins to the package-exe shape and forbids the stale dev-exe wiring.

* Judge drag-header text by its painted bounds in the caption scan

- The collision scan intersected each candidate's full frame with the caption cluster, so a grow/stretch centered title spanning the header row false-positived while its glyphs sat clear — and the paid retry visibly shifted the title.
- Single-line text leaves now intersect their aligned painted bounds: measured width through the same tokens seam the layout ran with, capped at the frame, placed per text_alignment; controls keep the frame test, and span paragraphs or explicit newlines fall back to it conservatively.
- Threads tokens from both windowControlsReservation call sites, pins the centered-title no-retry case at engine and runtime level, and keeps the trailing true-positive, the macOS leading mirror, and post-remedy re-scan convergence green.
2026-07-13 09:00:17 -05:00
Chris Tate 584dbbbaa9 TypeScript authoring: write app cores in TypeScript (#119)
* TypeScript authoring: write app cores in TypeScript

- App cores can be authored in TypeScript and compiled ahead of time to arena-backed native code: the complete language minus the ecosystem and purity violations, checked by tsc plus a teaching checker (NS1001-NS1060), emitting readable Zig with 83ns dispatch, no JS engine, and no GC
- The full platform surface reaches TS cores: the Cmd and Sub effects vocabulary bridged to the real engine, markup views binding the committed model, record and replay byte-identical to node semantics, stock-IDE support, multi-file cores with @native-sdk/core library modules, and native init scaffolding TypeScript by default with Zig first-class by choice
- Two showcase ports prove the bar with zero hand-written Zig: soundboard-ts at pixel parity with its Zig original and system-monitor-ts sampling the real OS, each with end-to-end batteries including replayed sessions with zero host calls
- Docs lead TypeScript-first with a segmented language toggle and a markup-first components reference; the eval suite gains dual-track realistic cases measuring both authoring tiers' health and efficiency

* Ship packages/core in the npm package and run its .ts modules from any layout

- copy-framework.js stages the @native-sdk/core closure (src/, sdk/, rt/, package.json + package-lock.json; test/ and scripts/ stay out), the sync check pins each staged entry plus the dep.path coverage, and package.json "files" covers the mirrored paths
- build/ts_run.mjs runs the transpiler tier's .ts modules on every layout: node refuses builtin type stripping under node_modules, so the runner strips those modules with the transpiler's own installed TypeScript and passes repo checkouts through untouched; build/app.zig, native check, and native dev --core all invoke through it
- the missing-dependency teaching now names the real dependency root (works verbatim on the npm-installed layout, where npm ci runs in the shipped packages/core against its shipped lockfile)

* TS scaffolds ship a CI workflow

- the --full ts-core template now writes the Zig full template's workflow (logic tests + Linux automation smoke, no WebKitGTK) with the node tier added to both jobs: setup-node and one npm ci in the fetched SDK's packages/core, the same install native build's teaching names
- slim scaffolds keep shipping no workflow (zero-config parity with the slim Zig template), now pinned by the ts slim template test

* Wire @native-sdk/core into the release automation

- sync-version.js stamps packages/core (manifest + lockfile own-package fields) and the committed TS examples' pins with the CLI release version, check-version-sync.js refuses a half-bumped tree, and the npm version script stages the stamped files; packages/core rides 0.4.4 from here on and scaffold pins follow the bundled manifest automatically
- the release publish step gains the packages/core publish gated on its "private" flag: private (until the 0.5.0 cut, by design) skips with a loud flip-requirement comment; dropping the flag is the publish switch, no workflow edit needed
- the TS scaffold README notes npm install is optional (the CLI materializes and refreshes the editor package itself), closing the pre-publish gap window honestly

* Provide node to the CI jobs that build TypeScript cores

- The Native Examples job panicked on the missing transpiler dependency, and the Zig Core and tooling jobs were silently skipping every node-gated ts-core suite; all three now set up node and npm ci packages/core
2026-07-12 21:37:07 -05:00
Chris Tate 49aa5e7481 Native-only Linux host: compile out WebKitGTK when no web intent is declared (#110)
* Compile the GTK host without WebKitGTK for native-only apps

- NATIVE_SDK_ALLOW_WEBKITGTK_STUB mirrors the Windows WebView2 seam: the define wins over header visibility, compiles out every WebKit-touching path (opaque never-non-NULL web-view pointers keep the GTK-only bookkeeping shape), and stubs the exported webview entry points
- Both Linux build graphs (build/app.zig and the generated template) compile gtk_host.c with the stub and drop the webkitgtk-6.0 link when the web layer is excluded; the native scaffold's generated CI stops installing libwebkitgtk-6.0-dev

Co-authored-by: WhiteHades <44260523+WhiteHades@users.noreply.github.com>

* Audit the web layer in Linux ELF executables

- tools/audit_web_layer.zig auto-detects PE vs ELF and hand-rolls an ELF64 reader over the section headers: DT_NEEDED entries naming libwebkitgtk/libjavascriptcoregtk and webkit_/jsc_ dynamic symbols are the evidence, refusal (never a pass) for anything it cannot parse
- native package grows the ELF twin of the PE guard: a WebKitGTK-linking binary packaged under a native-only decision is refused with the same teaching message, pinned by synthetic-ELF tests covering both evidence channels

Co-authored-by: WhiteHades <44260523+WhiteHades@users.noreply.github.com>

* Prove the Linux seam in CI from both directions

- linux-webkitgtk gains test-linux-web-layer-audit (seam under webkit-PRESENT conditions: native-only ELF scans clean, web ELF keeps its references) and linux-canvas-smoke drops libwebkitgtk-6.0-dev so the build itself is the native-only link test, with the ELF audit run on the real binary
- vendor pins keep the stub define wired through gtk_host.c and both build graphs; the macOS gpu-dashboard smoke asserts a native-only session spawns zero new WebKit helper processes

Co-authored-by: WhiteHades <44260523+WhiteHades@users.noreply.github.com>

* Pin native-only behavior: automation sessions, record/replay, docs

- An automation-driven session on a native-only canvas app proves normal command dispatch plus the WebViewLayerNotBuilt teaching error on a webview attempt, with zero webviews reaching the platform host
- The session record/replay reference journal round-trips identically under web_layer=false; the capabilities and app.zon pages note the user-visible Linux consequence (no WebKitGTK to build or run)

Co-authored-by: WhiteHades <44260523+WhiteHades@users.noreply.github.com>

* Forward the web engine to native package from the SDK build graph

- A Chromium exe packaged through zig build package shipped no CEF runtime because the CLI defaults to the system engine; forward --web-engine, --cef-dir, and --cef-auto-install exactly as the generated build graph already does

---------

Co-authored-by: WhiteHades <44260523+WhiteHades@users.noreply.github.com>
2026-07-11 12:53:47 -05:00
Chris Tate 9b4f62d040 Infer the web layer and enforce native-only builds end to end (#107)
* Infer the web layer and enforce native-only builds end to end

- The build graph parses app.zon and strips the Windows webview layer, loader staging, and dev PATH wiring when nothing declares web use; a webview_layer manifest field and -Dweb-layer flag override inference in both directions
- Conflicting declarations are rejected with one teaching message at validate, configure, runner compile, and package time, and a native-only build that reaches webview creation fails fast with WebViewLayerNotBuilt instead of a blank window
- A PE cross-audit build step pins that native-only Windows exes never reference the loader while webview apps must, and native check prints the web-layer verdict

Co-authored-by: WhiteHades <44260523+WhiteHades@users.noreply.github.com>

* Unify web-layer inference behind one shared contract

- app_manifest.web_layer owns the declaration scan, engine folding, and include/exclude decision, usable at comptime by the runner and at runtime by the build graph, validator, CLI, and generated scaffolds, with boundary ownership documented where each adapter lives
- Packaging decides from the resolved engine so --web-engine overrides cannot skew the layer, the runner guard covers shell views and manifest chromium, and the full scaffold emits the same inference, conflict panic, and conditional Windows wiring as the SDK graph
- A contract matrix test runs every manifest shape through every boundary form so the definitions can never diverge again

* Document the webview_layer override and Chromium web intent

- The capabilities page counts a Chromium-resolved engine as web intent and points at the override; the app.zon reference gains the webview_layer field, its inference and include/exclude semantics, and the exclude-conflict rule with the shipped error's remedy

* Carry the web-layer resolution into packaging

- Both build graphs forward their computed web-layer decision to native package via a new --web-layer flag, so the exe and the package can never disagree; a confirming flag keeps the manifest's reason while an overriding one names itself
- Packaging PE-scans Windows binaries and refuses to package a loader-referencing exe under a loaderless decision, closing the mismatch for hand-built binaries too
- Fixes an adjacent buildgraph bug where a sentinel-terminated path allocation was returned as a plain slice

* Honor the webview stub define before header visibility

- NATIVE_SDK_ALLOW_WEBVIEW2_STUB now excludes the embedded layer even when WebView2 headers are globally visible, so a native-only build can never reintroduce the loader reference; the vendor pins lock the guard order
- The stub message says the layer is excluded by configuration instead of claiming the header is missing

---------

Co-authored-by: WhiteHades <44260523+WhiteHades@users.noreply.github.com>
2026-07-11 11:12:12 -05:00
Chris Tate 26df3687f4 Make the Windows embedded WebView layer real (#86)
* Make the Windows embedded WebView layer real

- Vendor the WebView2 SDK header and loader under third_party/webview2 (BSD-3-Clause, license preserved) so repo state alone compiles the embedded layer; a missing header is now a hard error instead of a silent stub
- Fix the conformance blockers behind the guard: a local WRL callback factory for mingw, the uncaptured bridge-handler variable, an EventToken shim, and STA COM initialization on the host thread
- Stage the arch-matched loader beside built, run, packaged, and scaffolded apps, mirror it into the npm payload, and pin the wiring with vendor and loader-layout tests

* Carry the SDK root through package shortcuts and generated builds

- packageShortcut and package-ios now pass the environ map into createPackage like the package verb, so NATIVE_SDK_PATH resolves the framework root for loader staging from standalone binaries
- Generated frontend builds export NATIVE_SDK_PATH on the package command and stage the loader dir on the dev command's PATH, mirroring the SDK-dependency graph
2026-07-09 21:48:41 -05:00
Chris Tate 512298b474 Anti-alias rounded primitives and adopt Per-Monitor V2 DPI on Windows (#81)
* Anti-alias rounded primitives and adopt Per-Monitor V2 DPI on Windows

- Rounded-rect fills and strokes render through one continuous signed-distance coverage field, so curved edges ramp smoothly with no silhouette drift; a supersampled ground-truth test pins shape fidelity and radius-0 rects stay bit-identical
- Hairline borders snap to whole device pixel columns at emit time and geometry snapping is on by default in the house and Geist packs, keeping 1px borders crisp while arcs stay smooth; pure-SDF geometry remains available by disabling pixel_snap.geometry
- Windows apps declare Per-Monitor V2 DPI awareness in the embedded manifest and the Win32 host sizes windows, child views, and surfaces in physical pixels with WM_DPICHANGED re-rasterization, so canvases render at device scale instead of being bitmap-stretched

* Re-pin example reference signatures for the snap default

- gpu-dashboard and gpu-components pin their reference-surface signatures inside the example suites, which only test-examples-native runs; the geometry-snap default changed those pixels

* Re-apply explicit webview frames on DPI change and stamp static tokens with surface scale

- WM_DPICHANGED now re-applies explicit child webview frames rooted at the message window, matching the native-view pass
- effectiveTokens stamps pixel_snap.scale onto a copy of static tokens and scale changes rebuild static-token apps, so hairline snapping stays on the device grid across monitor density changes

* Lay out the components scene with the tokens it renders with

- The catalog laid out under default tokens (geometry snapping off) and rendered under pack tokens (snapping on), so the ceil rule for label-exact widths no-oped and per-edge frame rounding elided the third theme tab
- Layout builders now take the token set, the live app lays out with its surface tokens, and a regression test asserts the theme strip never elides under snapping in either pack

* Snap hairline borders to the lighter whole-pixel width

- Within the existing snap-eligibility window, fractional hairline widths now floor to the lighter device-pixel count instead of rounding, so a 1px border at 1.5x covers one crisp device column instead of two
- Sub-half-pixel strokes still never snap and 1x, 1.25x, and 2x rendering is pinned byte-identical by the updated tests

* Adopt resize-carried density and give each window its own snap scale

- handleResize adopts the event's scale factor before rebuilding, so a DPI-only monitor move re-stamps tokens and re-emits even when the logical size is unchanged
- Window slots own a per-window pixel_snap_scale stamped into their token emission, so secondary windows on different-density monitors snap on their own device grid

* Round native view frames once from accumulated logical coordinates

- Native child view origins accumulate logical x/y through the parent chain and every physical edge rounds exactly once, so nested controls no longer drift a pixel at fractional scales and abutting frames share pixel columns
- The app manifest declares an ordered DPI awareness chain (PerMonitorV2, PerMonitor, legacy dpiAware) so pre-1607 systems degrade gracefully instead of losing awareness entirely

* Round hidden-titlebar content sizes like the standard path

- hiddenOuterSizeForContent rounds scaled content extents through a shared helper instead of truncating, so hidden-titlebar windows and min-size floors match the requested logical size at fractional scales
- check-framework-sync now explains that the package mirror is generated and points at copy-framework.js instead of implying the mirror should be committed

* Chain window DPI resolution through monitor and system fallbacks

- dpiForWindow now mirrors the manifest's awareness chain: GetDpiForWindow, then GetDpiForMonitor via shcore, then the system DPI, so pre-1607 systems that the manifest makes DPI-aware no longer render tiny 1x content
- gpuSurfaceScale delegates to the shared helper and the build pin asserts the chain alongside the manifest elements
2026-07-09 21:32:50 -05:00
Chris Tate b47111069c Native SDK: the complete toolkit for building native desktop apps (#67)
zero-native becomes the Native SDK. Apps are authored as native markup plus Zig on a deterministic runtime and rendered by the toolkit's own engine into real OS windows — no browser, no WebView, no interpreter in the binary.

- Desktop is complete on macOS, Windows, and Linux: native rendering with per-platform titlebar fidelity, audio playback with streaming, a verified track cache, and real spectrum analysis, native context menus, packaging with sealed code signing, and a deterministic automation and record-replay story.
- Experimental iOS and Android host tiers ship behind the same app manifest: simulator and emulator dev loops, archive-ready packaging, real platform tab bars and push navigation, with embedding over the C ABI underneath.
- The docs site, component catalog, theme packs, showcase apps, and CHANGELOG carry the full account.
2026-07-08 18:51:43 -05:00