bun-app-window 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,13 +6,201 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] - 2026-10-25
10
+
11
+ ### Added
12
+
13
+ - **`bundlePage` passes `plugins`, `define` and `loader` through to `Bun.build`.** These are the
14
+ generic fields a compiling frontend framework needs; before this release `BundlePageOptions` had
15
+ only `entry` and `minify`, so Svelte and Vue could only be built by calling `Bun.build` directly
16
+ and giving up the failure diagnostics and byte accounting this module exists to provide.
17
+
18
+ Measured on a real Svelte 5 component (`bun-plugin-svelte` 0.0.6): 109,888 B entry script plus a
19
+ 133 B stylesheet, with the component's render code present in the output.
20
+
21
+ - **`PageBundle` now reports every output, and `writePageBundle` writes them all.** `outputs` lists
22
+ each artifact with its `path` / `kind` / `code` / `bytes`; `code` and `bytes` keep their previous
23
+ meaning and now come from the `entry-point` artifact rather than `outputs[0]` — output order is
24
+ not a contract, and stylesheets can sort first.
25
+
26
+ The second artifact used to be dropped silently. The symptom was a page that rendered correctly
27
+ with no styling, and nothing anywhere reported a missing file. `writePageBundle` now returns
28
+ `written: Array<{ path, bytes }>` so a caller's cleanup can remove every file it produced, not
29
+ just the entry one.
30
+
31
+ Placement rule: the entry artifact goes to `out`; every other artifact goes to the same
32
+ directory with its extension substituted. `page.bundle.txt` therefore yields
33
+ `page.bundle.css`, not `main.css` — the entry's filename is an implementation detail and must
34
+ not leak into a path that HTML and `assets` reference.
35
+
36
+ - **`createApp({ assets })` serves extra static files.** A path-to-text map with `content-type`
37
+ inferred from the extension. Multiple artifacts meant every consumer had to hand-write a `routes`
38
+ callback to serve the stylesheet, and the usual mistake — a wrong MIME type — makes the browser
39
+ drop the stylesheet silently. Unrecognised extensions fall back to `application/octet-stream`
40
+ rather than guessing: a wrong MIME type means the file is not executed, while a fallback means it
41
+ is downloaded, and the latter is immediately visible.
42
+
43
+ A caller's `routes` still wins, and paths that are not registered are 404 — `assets` is a
44
+ convenience, not a catch-all.
45
+
46
+ - **Page-side errors are now reported to the host.** An injected listener captures `error` and
47
+ `unhandledrejection` and posts them to the host over HTTP — deliberately not over the WebSocket,
48
+ which does not exist yet when a page script fails during evaluation, and that is the case worth
49
+ observing most. Pass `onPageError` to receive a `PageError`; without a callback the host prints
50
+ them to stderr rather than discarding them.
51
+
52
+ Measured on a real window: a page script that throws at module evaluation used to produce a
53
+ blank window and a single "window ready" line in the host log. It now produces
54
+ `kind=error message=Uncaught Error: … source=…/page.js:1:7` in the host log within the same
55
+ startup.
56
+
57
+ - **`emit()` accepts `{ sticky: true }`.** A page loads after the window is created, so anything
58
+ broadcast between `createApp` returning and the page's WebSocket opening is delivered to zero
59
+ connections and never replayed — a page that reads its initial state from events alone stays
60
+ empty, with no error at either end. Sticky events keep the last payload per event name and replay
61
+ it in `attach()`. It is opt-in: a notification-shaped event that turns sticky gives late
62
+ connections one stale message that reads like a fresh one.
63
+
64
+ ### Changed
65
+
66
+ - **The gate gains a third stage: `bun fmt --check --no-script`.** Four stages now — `check` →
67
+ `test` → `fmt` → `pack`. The formatting check costs about 50 ms on 92 files, and it sits before
68
+ the consumer acceptance stage because a formatting problem is local code quality, not a
69
+ distribution question. `--skip-fmt` exists for the same reason `--skip-pack` does; `--skip-test`
70
+ does **not** skip it, since formatting and tests have no dependency between them.
71
+
72
+ Two criteria this stage depends on, each measured rather than assumed:
73
+
74
+ **`--no-script` is mandatory.** When `package.json` has a `fmt` script, `bun fmt --check` hands
75
+ the whole command to that script and returns its exit code verbatim. Measured with a script that
76
+ only prints one line: `bun fmt --check` exited 0 and printed that line, having inspected no files
77
+ at all. The criterion would silently degrade to "did that script succeed". This repository has no
78
+ `fmt` script today, so the flag changes nothing right now — which is exactly why it needs a
79
+ static assertion: the failure point is the day someone adds the script, and nothing would turn
80
+ red then.
81
+
82
+ **The file count has to be checked.** If `.prettierignore` is ever written as `*`, the command
83
+ exits 0 and reports everything compliant while having looked at nothing. `fmtVerdict()` therefore
84
+ treats "exit 0 but no file count parsed" as a failure, the same way `bun check` treats "checked 0
85
+ files".
86
+
87
+ **The judgement is a pure function because it had no guard.** Reverse verification ran before
88
+ this was written: removing "non-zero exit means failure" and removing the "count below 1" guard
89
+ each left the entire suite green — `runFmt` can only be reached by spawning a real subprocess,
90
+ and the short-circuit tests use a fake runner. The logic was lifted into `fmtVerdict()` so both
91
+ defences have cases; removing either now turns the corresponding assertion red, verified in both
92
+ directions.
93
+
94
+ - **The declared toolchain baseline is Bun 1.4.4.** `engines.bun` is `>=1.4.4` and `@types/bun` is
95
+ `^1.4.4`; the three READMEs, the requirement table and the badge follow. The declaration is backed
96
+ by the gate run recorded at the end of this entry, not inherited from an earlier release.
97
+
98
+ Two behaviours were re-measured on this baseline rather than assumed unchanged:
99
+
100
+ | Probe | Result |
101
+ | -------------------------------------- | ----------------------------------- |
102
+ | `FFIType.buffer` as `args` / `returns` | throws `invalid ABI type` |
103
+ | `"buffer"` string literal as `args` | accepted, and rejected as `returns` |
104
+
105
+ So the `bun:ffi` constraint in `AGENTS.md` §5.1 holds on this baseline unchanged.
106
+
107
+ `bun-types@1.4.4` gates its import-attribute declarations behind
108
+ `typesVersions: { ">=7.1": ... }`, and its root `index.d.ts` is 1,306 B with no
109
+ `import-attributes` reference. The TypeScript 7.0.2 limitation documented in the requirements
110
+ table therefore still holds; nothing about it changed with this baseline.
111
+
112
+ Full regression: `bun check` 80 files, `bun test` 683 pass / 0 fail across 36 files,
113
+ `VERIFY-PACK PASSED` (7 checks), consumer probe 70 checks with `source=package`.
114
+
115
+ - **`alias` is implemented by this package, not passed through.** The plan was to forward it like
116
+ the other three fields; measurement said otherwise. Bun 1.4.4's `BuildConfig` has no `alias`
117
+ property, and passing one is not an error — it is silently ignored. Four forms were tried
118
+ (absolute path to a file, relative path to a file, relative path to a directory, trailing-slash
119
+ key) and all four failed to resolve, each reporting only `Bundle failed`.
120
+
121
+ It is now backed by a small `onResolve` plugin with a specified boundary: a specifier equal to a
122
+ key or prefixed by `key/` is rewritten; relative paths, absolute paths and `scheme:` specifiers
123
+ are left alone. The plugin is only registered when `alias` is supplied, so its hook does not sit
124
+ in every resolution path. When the alias target cannot be resolved, the candidate path is
125
+ handed back unchanged so Bun reports its own error rather than a rewritten one.
126
+
127
+ Aliases merge with, rather than replace, the caller's plugins — the two are orthogonal, and
128
+ replacing would make "using alias means you cannot use a framework plugin" a hidden rule.
129
+
130
+ - **BREAKING: `createIpcServer` throws when the service table contains a reserved client name.**
131
+ `on` / `off` / `close` / `connected` / `pending` / `then` resolve to the IPC client's own members,
132
+ so a service method under one of those names is unreachable and neither end reports anything.
133
+ Measured before this change: `client.close()` returned `undefined` — not a Promise — and the
134
+ server-side method was invoked zero times, with no exception raised, because `await undefined` is
135
+ a legal expression. The check runs at creation and names the offending keys. Pass
136
+ `{ rejectReservedNames: false }` only when the table is generated from external input and
137
+ validated elsewhere.
138
+
139
+ - **`createApp` validates `html` against `page`.** Supplying one without the other produced a window
140
+ that rendered normally, loaded no script, and responded to nothing, with an empty host log.
141
+ Both directions now throw before the server starts; a reference that lacks `type="module"`
142
+ produces a warning instead, because the legal ways to write a `<script>` tag are too varied for a
143
+ string match to justify blocking startup.
144
+
145
+ - **Release verification split into two points around the irreversible step.** A release writes three carriers: the git commit, the GitHub tag + release, and the npm tarball. The first three actions can all be undone; `bun publish` cannot — the version number is spent permanently. Verification therefore now happens on both sides of it.
146
+
147
+ - `release-verify.ts --pre-publish`, run between `gh release create` and `bun publish`: HEAD equals `origin/main`, the tag is annotated and points at HEAD, the release exists and is not a draft, and npm has not yet taken that version number. This is what authorises the irreversible step.
148
+ - `release-verify.ts`, run after publish: the core check is a single comparison — `tag → git archive → bun pm pack → sha1` against the tarball downloaded from npm. When that matches, every file inside is identical, so no per-file or per-paragraph assertion is needed. `bun pm pack` was measured to be deterministic: repacking the `v0.7.0` tree reproduced the sha1 npm had already published, byte for byte.
149
+
150
+ - **The pre-release sentinel went from sixteen checks to six.** Eight of them duplicated `tests/version.test.ts` and `tests/readme-contract.test.ts` verbatim, and the three package-size checks stated one fact three times. What remains is exactly what `bun test` structurally cannot do: CHANGELOG section, CHANGELOG links, empty `Unreleased`, clean working tree, unoccupied tag, and the declared package size against the real tarball. Duplication costs more than a few milliseconds — two implementations drift, and then a person bypasses whichever one is stricter, which is usually the one nobody maintains.
151
+
152
+ ### Note
153
+
154
+ - **`alias` defaults to empty, deliberately.** `$lib` is a SvelteKit convention and `@/` is some
155
+ project's `tsconfig` alias; neither belongs to this package. Presetting either would mean choosing
156
+ an ecosystem on the consumer's behalf.
157
+
158
+ - **Neutrality is now enforced by tests, not by convention.** `tests/pack-neutrality.test.ts` asserts
159
+ that `dependencies` / `peerDependencies` / `optionalDependencies` are all empty, that no frontend
160
+ framework name appears in a non-comment line of `src/shell/`, and that no alias is preset.
161
+ Rationale and rejected alternatives are in `ADR 0021` (repository-local).
162
+
163
+ - The three READMEs gain a subsection on multi-artifact output; package size re-measured as
164
+ 22 files / 0.50 MB packed / 1.47 MB unpacked.
165
+
166
+ - **`bun fmt` adopted with an explicit style, and one contract moved off source layout.**
167
+ `bunfig.toml` pins `useTabs = false`, `tabWidth = 4`, `printWidth = 120`; `.prettierignore` excludes
168
+ `vendor/` first, because that tree is a verbatim upstream copy and editing it manufactures
169
+ conflicts with the next upstream sync. 73 files were reformatted; `vendor/` verified unchanged.
170
+
171
+ The style had to be declared rather than defaulted: `bun fmt --help` documents `tabWidth` as
172
+ `default: 2`, while 1.4.4 actually emits 4. A default that disagrees with its own documentation
173
+ is not a fact to depend on.
174
+
175
+ Reformatting exposed a real defect rather than a cosmetic one. `tests/version.test.ts` read
176
+ `--windows-version` **line by line**, so it only recognised the version literal when
177
+ `"--windows-version", "0.8.0.0",` sat on one line. At 120 columns the formatter splits those two
178
+ elements, and four assertions went red against code that had not changed. The contract was
179
+ anchored on layout instead of on the value. It now matches over the whole text after blanking
180
+ comment lines, so both layouts parse; `bump-version.ts` already spanned lines via `\s*` and
181
+ preserves the surrounding whitespace, which is now pinned by a test that asserts the rewritten
182
+ version stays on its own line rather than being collapsed back.
183
+
184
+ `bun fmt` does **not** honour `// prettier-ignore` on array elements — measured: the comment is
185
+ kept, the element is still split. That is why the fix moved the parser instead of silencing the
186
+ formatter.
187
+
188
+ The reformat adds 30 KB to the unpacked tarball (1.44 → 1.47 MB): splitting array elements adds
189
+ a level of indentation. Declared size updated in all three READMEs.
190
+
191
+ - The four changes in the 0.8.0 batch — page-error relay, sticky events, reserved-name
192
+ rejection and `html`/`page` validation — share one design criterion: **a legal action that puts the
193
+ system into an unusable state must be observable by someone.** Two of them now stop at creation; two
194
+ are exposed through the host log. The rationale, the measurements behind each, and the rejected
195
+ alternatives are in `ADR 0020` (repository-local).
196
+
9
197
  ## [0.7.0] - 2026-10-25
10
198
 
11
199
  ### Added
12
200
 
13
201
  - **`setFrameless()` switches the window form at runtime; `isFrameless()` reports the current one.** `FLAG_FRAMELESS` had been a creation-time flag since 0.3, so comparing a frameless window against a framed one meant restarting the process. That is a real cost for acceptance: the form check has three branches (`frameless` + restored, `frameless` + maximized, `framed`), one run only ever proves one of them — and a restart throws away the event ring, the zoom factor and the restored geometry, which are exactly the state that makes "is the geometry consistent across a switch" testable. Consumers can now also expose a title-bar toggle instead of shipping two builds.
14
202
 
15
- **The creation path and the behaviour predicates are now two variables.** `g_frameless` decides *which window creation path runs* — our own `webview_shell` window (embedded `webview_create(1, hwnd)`, full `shell_wnd_proc`) versus the window the webview library creates (`toplevel_proc` subclassing only). That is fixed the moment `CreateWindowExW` / `webview_create` returns and cannot change. `g_frameless_live` decides *behaviour*: whether `WM_NCCALCSIZE` intercepts, whether resize borders and caption buttons take part in hit-testing, whether `fit_widget` reserves the title-bar strip, and what `push_state` reports as `frameless`. Five predicates moved to the live variable; the creation path stayed where it was.
203
+ **The creation path and the behaviour predicates are now two variables.** `g_frameless` decides _which window creation path runs_ — our own `webview_shell` window (embedded `webview_create(1, hwnd)`, full `shell_wnd_proc`) versus the window the webview library creates (`toplevel_proc` subclassing only). That is fixed the moment `CreateWindowExW` / `webview_create` returns and cannot change. `g_frameless_live` decides _behaviour_: whether `WM_NCCALCSIZE` intercepts, whether resize borders and caption buttons take part in hit-testing, whether `fit_widget` reserves the title-bar strip, and what `push_state` reports as `frameless`. Five predicates moved to the live variable; the creation path stayed where it was.
16
204
 
17
205
  **Switching does not touch the style bits.** `create_own_window` already passes `WS_OVERLAPPEDWINDOW` — the caption, border and sizing frame are all there. Frameless is achieved by `WM_NCCALCSIZE` returning 0, which erases the non-client area; it is not achieved by removing style bits. So going back to framed takes two steps: let `WM_NCCALCSIZE` fall through, then `SetWindowPos(SWP_FRAMECHANGED)` to force the system to recompute. Assembling `WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX` by hand would give five chances to produce a window that cannot be dragged or has no system menu; `WM_NCCALCSIZE` just hands the system back the answer it would have computed anyway.
18
206
 
@@ -36,16 +224,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
36
224
 
37
225
  - **Six README defects fixed, then the checks that would have caught them were written.** The two worst:
38
226
 
39
- | Defect | Before | Now |
40
- | --- | --- | --- |
41
- | `FLAG_TITLEBAR_RESERVE` (256) / `FLAG_NATIVE_CAPTIONS` (512) | **absent from the flag table entirely** — the latter is the only way Snap Layouts can trigger | documented, including why the first is superseded by the second |
42
- | All three READMEs | pointed readers at `AGENTS.md` for "shape A / shape B" — gitignored, not in `files`, so **unreachable for anyone who installed the package** from the day those sentences were written | rewritten self-contained |
43
- | `ackOf()`, `titleBarCss()`, `detectIconSet()`, `captions: "native"` | never mentioned | documented |
44
- | Default glyph set | "buttons default to geometric glyphs; setting `icons: \"fluent\"` switches to Segoe Fluent Icons" — **backwards since 0.6.1** | `detectIconSet()` picks `fluent` by default, `unicode` only as fallback |
45
- | `types` subpath constants | `STATE_*` only | three named constants plus the `CAPTION_*` trio, and why the caption order is load-bearing |
46
- | Tarball size | 1.37 MB unpacked | 1.39 MB — measured, not estimated |
227
+ | Defect | Before | Now |
228
+ | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
229
+ | `FLAG_TITLEBAR_RESERVE` (256) / `FLAG_NATIVE_CAPTIONS` (512) | **absent from the flag table entirely** — the latter is the only way Snap Layouts can trigger | documented, including why the first is superseded by the second |
230
+ | All three READMEs | pointed readers at `AGENTS.md` for "shape A / shape B" — gitignored, not in `files`, so **unreachable for anyone who installed the package** from the day those sentences were written | rewritten self-contained |
231
+ | `ackOf()`, `titleBarCss()`, `detectIconSet()`, `captions: "native"` | never mentioned | documented |
232
+ | Default glyph set | "buttons default to geometric glyphs; setting `icons: \"fluent\"` switches to Segoe Fluent Icons" — **backwards since 0.6.1** | `detectIconSet()` picks `fluent` by default, `unicode` only as fallback |
233
+ | `types` subpath constants | `STATE_*` only | three named constants plus the `CAPTION_*` trio, and why the caption order is load-bearing |
234
+ | Tarball size | 1.37 MB unpacked | 1.39 MB — measured, not estimated |
47
235
 
48
- The glyph row was the worst of the six: a reader following it would set `icons: "fluent"` to *get* the Win 11 glyphs, when that had been the default since 0.6.1 — the documentation described the opposite of what the code did. Two `about 0.4 s` / `about two seconds` figures were removed rather than updated; they drift with the machine and the reader cannot verify them. Two first-person phrasings were also removed from the Chinese READMEs (`各替我省一次调用` → `各能省掉一次调用`).
236
+ The glyph row was the worst of the six: a reader following it would set `icons: "fluent"` to _get_ the Win 11 glyphs, when that had been the default since 0.6.1 — the documentation described the opposite of what the code did. Two `about 0.4 s` / `about two seconds` figures were removed rather than updated; they drift with the machine and the reader cannot verify them. Two first-person phrasings were also removed from the Chinese READMEs (`各替我省一次调用` → `各能省掉一次调用`).
49
237
 
50
238
  Then the checks above were written, and verified in both directions: four mutations were injected into the real documents — deleting a flag mention, adding a heading to one file, corrupting one file's size, adding an `AGENTS.md` reference — and each turned exactly the expected assertion red. All four were reverted and the suite returned green.
51
239
 
@@ -88,7 +276,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88
276
 
89
277
  **Still manual:** whether the focus-dimmed glyph actually renders dimmer on screen. The check needs the window to lose foreground, and in this session `SetForegroundWindow` never took it away from the host chat window — both readings came back at full brightness, i.e. the window never left the active state, so the probe measured nothing. The logic is covered by unit tests and the CSS is a plain attribute selector, but the end-to-end path (native `focused` bit → `windowState` push → `setActive` → repaint) has not been observed.
90
278
 
91
- - **Caption buttons on a frameless window now answer `HTMAXBUTTON`, so Windows 11 Snap Layouts works.** The only documented trigger is `WM_NCHITTEST` returning `HTMAXBUTTON` for the maximize/restore button (*Support snap layouts for desktop apps on Windows 11*). Previously `hit_test_client` returned only the eight resize codes and `HTCLIENT`, so hovering maximize never opened the layout picker — the last piece of "looks like a Win 11 app" that CSS cannot buy.
279
+ - **Caption buttons on a frameless window now answer `HTMAXBUTTON`, so Windows 11 Snap Layouts works.** The only documented trigger is `WM_NCHITTEST` returning `HTMAXBUTTON` for the maximize/restore button (_Support snap layouts for desktop apps on Windows 11_). Previously `hit_test_client` returned only the eight resize codes and `HTCLIENT`, so hovering maximize never opened the layout picker — the last piece of "looks like a Win 11 app" that CSS cannot buy.
92
280
 
93
281
  Three constants became a single source of truth in `types.ts` — `CAPTION_BAR_HEIGHT` (32), `CAPTION_BUTTON_WIDTH` (46), `CAPTION_BUTTONS` (`close` / `maximize` / `minimize`, right to left). Two copies follow it: `kCaptionBarHeight` / `kCaptionButtonWidth` plus the `caption_button_at()` order in `native/shell.cc` (C++ cannot import TS), and the `32px` / `46px` in `titlebar.css`.
94
282
 
@@ -114,7 +302,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
114
302
  HTMINBUTTON → minimized state=2, win=314x50 (sentinel — really minimized)
115
303
  ```
116
304
 
117
- **Still manual**: whether the Snap flyout actually *appears* on hover. It is a DWM-owned popup, so `PrintWindow` cannot capture it and no readout exposes it. Automating the hover was attempted and blocked — the Windows MCP refused the cursor move (`USER_CONTROL`, `state: user`), and `SetCursorPos` landed 216 px away, i.e. the pointer was in use. What is verified is the precondition the documentation names; what is not verified is the flyout itself.
305
+ **Still manual**: whether the Snap flyout actually _appears_ on hover. It is a DWM-owned popup, so `PrintWindow` cannot capture it and no readout exposes it. Automating the hover was attempted and blocked — the Windows MCP refused the cursor move (`USER_CONTROL`, `state: user`), and `SetCursorPos` landed 216 px away, i.e. the pointer was in use. What is verified is the precondition the documentation names; what is not verified is the flyout itself.
118
306
 
119
307
  One measurement worth keeping: the outer 13 physical pixels of the window are the resize border, so the title bar's top ~6.5 logical pixels answer `HTTOP` rather than the button code. Caption buttons win where they overlap (they are checked first), but a probe that samples `y=1` will read `HTTOP` and should not be read as "the button is missing".
120
308
 
@@ -122,9 +310,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
122
310
 
123
311
  - **The caption buttons sat 18 CSS px left of where they belong, and the title bar was squeezed to a third of its height — because a consumer's `header { … }` rule leaked into the component.** The component mounts on `<header class="ws-titlebar">`, and `baw-mvp`'s page has an element-selector rule for its own page header: `header { padding: 12px 18px 10px; gap: 12px }`. `.ws-titlebar` never declared `padding`, so the 18px right padding pushed all three buttons left by 18 CSS px (measured: 36 physical px @192dpi), and under `box-sizing: border-box` the 12/10 vertical padding squeezed the 32px bar down to 9px of content, shrinking the buttons to 18 physical px tall.
124
312
 
125
- **It reads as "the glyph is not centred", and that diagnosis is wrong.** Measuring the hover backplate — `background` covers the whole button, so its edges *are* the button's edges — put the backplate at 92 physical px wide with the glyph's centre and the backplate's centre both at `d=137.5`: the glyph was centred all along. The whole group was displaced, then squashed.
313
+ **It reads as "the glyph is not centred", and that diagnosis is wrong.** Measuring the hover backplate — `background` covers the whole button, so its edges _are_ the button's edges — put the backplate at 92 physical px wide with the glyph's centre and the backplate's centre both at `d=137.5`: the glyph was centred all along. The whole group was displaced, then squashed.
126
314
 
127
- **Specificity is not the mechanism.** An element selector (`0,0,1`) does lose to a class selector (`0,1,0`) — but only where the component *declares* the property. An undeclared property does not compete at all, so it is handed to the consumer. The mounting tag is part of the public contract, and `<header>` / `<button>` / `<div>` are exactly the tags consumers write global rules for.
315
+ **Specificity is not the mechanism.** An element selector (`0,0,1`) does lose to a class selector (`0,1,0`) — but only where the component _declares_ the property. An undeclared property does not compete at all, so it is handed to the consumer. The mounting tag is part of the public contract, and `<header>` / `<button>` / `<div>` are exactly the tags consumers write global rules for.
128
316
 
129
317
  The fix declares `margin` / `padding` / `gap` on `.ws-titlebar` and `margin` on `.ws-titlebar__btn`. The consumer sides changes too: `header { … }` becomes `.app-header { … }`, and `button { … }` becomes `button:not(.ws-titlebar__btn)` — the latter also stops `button:active { transform: translateY(1px) }` from reaching the caption buttons, which had been sinking the glyph 1px on press where Win 11 only swaps the backplate.
130
318
 
@@ -190,7 +378,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
190
378
 
191
379
  - **`App.attachPlacement(file, opts?)`** — wires placement persistence to the skeleton's single event pump. Calling `attachPlacementStore(shell, file)` without `{ events }` makes it start a **second** pump, and `shell.pollEvents()` is consumptive: the two pumps each get part of the stream, window position is dropped at random, and neither side reports anything. This was the library's easiest way to fail silently, so the choice was removed rather than documented harder.
192
380
 
193
- - **`bun-app-window/pack`** — the page-script bundler, so consumers stop writing it. `bundlePage({ entry })` returns `{ code, bytes }`; `writePageBundle({ entry, out })` also writes and re-checks the on-disk size. Three things are fixed rather than configurable: `target: "browser"` and `format: "esm"` (that is what a page script *is*), and reporting **bytes** instead of `string.length` — the latter is UTF-16 code units, which on a source containing CJK drifts from the byte count by a wide margin while the unit is printed as `B`. Measured on a consumer: reported `35,085 B`, on-disk `37,319 B`.
381
+ - **`bun-app-window/pack`** — the page-script bundler, so consumers stop writing it. `bundlePage({ entry })` returns `{ code, bytes }`; `writePageBundle({ entry, out })` also writes and re-checks the on-disk size. Three things are fixed rather than configurable: `target: "browser"` and `format: "esm"` (that is what a page script _is_), and reporting **bytes** instead of `string.length` — the latter is UTF-16 code units, which on a source containing CJK drifts from the byte count by a wide margin while the unit is printed as `B`. Measured on a consumer: reported `35,085 B`, on-disk `37,319 B`.
194
382
 
195
383
  Failure handling covers both shapes `Bun.build` produces, which was not obvious: compile-time diagnostics come back as `{ success: false, logs }`, but an **unreachable entry point throws an `AggregateError`** whose `message` is only `Bundle failed` and whose members live on `.errors` — `.logs` does not exist on that path. Without the second branch, the most common mistake (a wrong entry path) produced a message with no information in it.
196
384
 
@@ -200,23 +388,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
200
388
 
201
389
  - **BREAKING: `Shell.beginResize(edge)` takes `RESIZE_EDGE` values (10–17).** Migrating from 0.5.x:
202
390
 
203
- | You were passing | Before | Now |
204
- | --- | --- | --- |
205
- | `RESIZE_EDGE.LEFT` (10) and friends | `rc=-1`, nothing moved | works |
206
- | raw indices 0–7 | worked | `rc=-1` |
391
+ | You were passing | Before | Now |
392
+ | ----------------------------------- | ---------------------- | ------- |
393
+ | `RESIZE_EDGE.LEFT` (10) and friends | `rc=-1`, nothing moved | works |
394
+ | raw indices 0–7 | worked | `rc=-1` |
207
395
 
208
396
  If you were passing raw indices, replace them with the matching `RESIZE_EDGE` member — `RESIZE_INDEX` is exported if you need to see the mapping.
209
397
 
210
398
  - `tests/gate.test.ts` shrank with the stage: `parsePs1Summary` and the `.ps1`-failure short-circuit assertions are gone, and `planStages` now asserts three stages.
211
399
  - **A new assertion keeps the deletion honest.** `tests/gate.test.ts` now fails if a `.ps1` appears anywhere in the repository. Deleting a stage and leaving nothing behind would mean the next `.ps1` arrives ungoverned and looks exactly like "the migration is complete". Verified in both directions: creating `scripts/__probe.ps1` turns it red naming that path, removing it returns green.
212
- - `docs/pitfalls.md` 3.18 keeps all five traps and is re-headed as *retired* rather than *paused* — the values and consequences were measured, so the record outlives the script. 5.2 and 5.6 no longer describe a `.ps1` that might come back.
400
+ - `docs/pitfalls.md` 3.18 keeps all five traps and is re-headed as _retired_ rather than _paused_ — the values and consequences were measured, so the record outlives the script. 5.2 and 5.6 no longer describe a `.ps1` that might come back.
213
401
  - Two ghost instructions surfaced while deleting: `scripts/trust-cert.ts` told the user to run `scripts/test-sign.ts`, a path that never existed, and `native/build-pack.ts` told them to run `scripts/make-icon.ps1`, which was migrated to TypeScript long ago. Both now point at something that exists.
214
402
 
215
403
  ### Fixed
216
404
 
217
405
  - **`RESIZE_EDGE` and `shell_begin_resize()` were never the same numbering** (`pitfalls` 5.13). `RESIZE_EDGE` is the Win32 `HT*` constants (10–17); the native side reads `codes[edge]` as an **array index 0–7** over `{HTLEFT, HTRIGHT, HTTOP, HTBOTTOM, HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT}`. The mapping is therefore not an offset — `HTTOPLEFT` (13) lands at index 4 while `HTBOTTOM` (15) lands at 3. `beginResize` used to pass its argument straight through, so following the documentation produced `rc=-1` and a window that did not resize, while the gate stayed green.
218
406
 
219
- Fixed in TypeScript, so **the DLL does not need rebuilding**. Three assertions now hold the mapping down, the important one being that it is *not* `value - 10`: subtracting ten happens to be correct for the first three entries (LEFT/RIGHT/TOP → 0/1/2) and wrong from the fourth on, which is exactly how a partial assertion would have given false confidence.
407
+ Fixed in TypeScript, so **the DLL does not need rebuilding**. Three assertions now hold the mapping down, the important one being that it is _not_ `value - 10`: subtracting ten happens to be correct for the first three entries (LEFT/RIGHT/TOP → 0/1/2) and wrong from the fourth on, which is exactly how a partial assertion would have given false confidence.
220
408
 
221
409
  ### Removed
222
410
 
@@ -241,7 +429,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
241
429
  - **Dropped files and folders are accepted again, and the page receives absolute paths.** 0.5.0 set `ICoreWebView2Controller4::put_AllowExternalDrop(FALSE)` on the belief that it stepped aside from WebView2's internal `IDropTarget` and let the OLE chain fall through to the parent window's `DragAcceptFiles` handler. **Both halves of that reasoning were wrong**:
242
430
 
243
431
  1. `DragAcceptFiles` only sets `WS_EX_ACCEPTFILES`; it does **not** register an `IDropTarget`. Reading the `OleDropTargetInterface` window property across the whole chain (`webview` → `webview_widget` → `Chrome_WidgetWin_0` → `Chrome_WidgetWin_1` → `Chrome_RenderWidgetHostHWND` → `Intermediate D3D Window`) returns false for every window. Modern Windows does not fall back to that legacy path during OLE hit-testing.
244
- 2. `AllowExternalDrop` means *allow external drop*. `FALSE` refuses drops; it does not forward them.
432
+ 2. `AllowExternalDrop` means _allow external drop_. `FALSE` refuses drops; it does not forward them.
245
433
 
246
434
  The user-visible symptom was a red "not allowed" cursor over the window on every drag, with no drop and no `WM_DROPFILES`.
247
435
 
@@ -251,7 +439,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
251
439
 
252
440
  ### Added
253
441
 
254
- - **Folders now work, and `dirs` tells them apart.** A dropped folder arrives as a `FileSystemDirectoryHandle`, but on the host side that handle does **not** survive — measured: the collection holds one entry, `QueryInterface` to both `ICoreWebView2File` and `ICoreWebView2FileSystemHandle` fails, and the item is simply unusable. What *does* work is that a folder also appears in `dataTransfer.files` as an ordinary `File`, whose `get_Path` returns the correct path.
442
+ - **Folders now work, and `dirs` tells them apart.** A dropped folder arrives as a `FileSystemDirectoryHandle`, but on the host side that handle does **not** survive — measured: the collection holds one entry, `QueryInterface` to both `ICoreWebView2File` and `ICoreWebView2FileSystemHandle` fails, and the item is simply unusable. What _does_ work is that a folder also appears in `dataTransfer.files` as an ordinary `File`, whose `get_Path` returns the correct path.
255
443
 
256
444
  So the injected script posts **handles and files together**, the host tries `ICoreWebView2File` first and the handle second, and duplicate paths are collapsed. Because the surviving object is a `File`, `get_Kind` is unavailable — "is this a folder" is decided by `GetFileAttributesW` + `FILE_ATTRIBUTE_DIRECTORY`, the same predicate the `WM_DROPFILES` fallback uses, which is why both paths produce identical output:
257
445
 
@@ -263,7 +451,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
263
451
 
264
452
  - **The host now always answers.** Previously, when `AdditionalObjects` yielded no usable path, the handler returned silently — the page waited, the host said nothing, and neither side had a clue. Every drop now produces exactly one `shell-msg`, carrying `ok`, `err`, and the counts `nFile` / `nHandle` / `nNoPath` that made this diagnosis possible in the first place.
265
453
 
266
- - **`scripts/probe-drop-live.ts`** — an interactive probe for the half that cannot be synthesised. `AdditionalObjects` only ever contains objects produced by a real drag; page JavaScript cannot fabricate one with a path, and `keybd_event` cannot hold a mouse button across processes. The probe opens a window, waits for one manual drop, and does the judging automatically: the page reports the received payload back over `fetch`, and the probe asserts paths are non-empty, absolute, existing, and classified exactly as the filesystem says. Twelve checks; a failure names *which* of the five gates closed.
454
+ - **`scripts/probe-drop-live.ts`** — an interactive probe for the half that cannot be synthesised. `AdditionalObjects` only ever contains objects produced by a real drag; page JavaScript cannot fabricate one with a path, and `keybd_event` cannot hold a mouse button across processes. The probe opens a window, waits for one manual drop, and does the judging automatically: the page reports the received payload back over `fetch`, and the probe asserts paths are non-empty, absolute, existing, and classified exactly as the filesystem says. Twelve checks; a failure names _which_ of the five gates closed.
267
455
 
268
456
  `--file` and `--dir` are mutually exclusive — one target per run, because a run that asks for both will always fail the one you did not drag. It must run **non-elevated**; the file comment says why and how.
269
457
 
@@ -271,13 +459,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
271
459
 
272
460
  - **This release went through five rounds of "nothing happens".** Each was a genuinely different cause with an identical symptom, and only the last two were in code this release changed. They are recorded as `docs/pitfalls.md` 2.16 and 2.17, and the injected script now carries a marker for each so the next failure names its own stage:
273
461
 
274
- | Round | Cause | What made it visible |
275
- | --- | --- | --- |
276
- | 1 | The host ran elevated (High integrity) while Explorer runs at Medium; UIPI refuses the drag before any message is sent | Not a single `dragenter` |
277
- | 2 | `preventDefault()` alone does not accept a drop — `dataTransfer.dropEffect` stayed `none` | `dragover=72` with `drop=0` |
278
- | 3 | `chrome.webview.postMessage` is *not* the pair of `get_AdditionalObjects`; `File` serialises to `{}`, so the collection was empty while the call succeeded | `drop=1 files=1`, `count=0` |
279
- | 4 | The probe reported its own post as failed — it fetched the marker 250 ms later, after the host had already closed the window | the only red check in an otherwise green run |
280
- | 5 | A folder's `FileSystemHandle` does not survive the bridge; only the `File` in `dataTransfer.files` carries a path | `nHandle=0` while the collection was non-empty |
462
+ | Round | Cause | What made it visible |
463
+ | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
464
+ | 1 | The host ran elevated (High integrity) while Explorer runs at Medium; UIPI refuses the drag before any message is sent | Not a single `dragenter` |
465
+ | 2 | `preventDefault()` alone does not accept a drop — `dataTransfer.dropEffect` stayed `none` | `dragover=72` with `drop=0` |
466
+ | 3 | `chrome.webview.postMessage` is _not_ the pair of `get_AdditionalObjects`; `File` serialises to `{}`, so the collection was empty while the call succeeded | `drop=1 files=1`, `count=0` |
467
+ | 4 | The probe reported its own post as failed — it fetched the marker 250 ms later, after the host had already closed the window | the only red check in an otherwise green run |
468
+ | 5 | A folder's `FileSystemHandle` does not survive the bridge; only the `File` in `dataTransfer.files` carries a path | `nHandle=0` while the collection was non-empty |
281
469
 
282
470
  Rounds 3 and 5 are the same lesson twice: **a call that does not throw is not a call that worked.** `__shellDropReady` / `__shellDropPosted` / `__shellDropVia` / `__shellDropError` now exist so "did the injection run", "which route was posted", "was the drop delivered" and "did the host receive a usable object" are four separate observations instead of one silent guess.
283
471
 
@@ -290,17 +478,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
290
478
 
291
479
  ### Verification
292
480
 
293
- | Command | Result |
294
- | --- | --- |
295
- | `bun check` | 69 files |
296
- | `bun test` | 558 pass / 0 fail |
297
- | `bun run scripts/gate.ts` | PASSED |
298
- | `bun run scripts/verify-dll.ts` | PASSED, same source as `native/shell.cc` (1,016,832 B) |
299
- | `scripts/probe-window-events.ts` | PASSED (21 checks) |
300
- | `scripts/probe-accelerator-keys.ts` | PASSED (6 checks) |
301
- | `scripts/probe-drop.ts` | PASSED (6 checks) |
302
- | `scripts/probe-drop-live.ts --file` | **PASSED (12 checks)** — one real file drag, absolute path |
303
- | `scripts/probe-drop-live.ts --dir` | **PASSED (12 checks)** — one real folder drag, path **and** folder classification |
481
+ | Command | Result |
482
+ | ----------------------------------- | --------------------------------------------------------------------------------- |
483
+ | `bun check` | 69 files |
484
+ | `bun test` | 558 pass / 0 fail |
485
+ | `bun run scripts/gate.ts` | PASSED |
486
+ | `bun run scripts/verify-dll.ts` | PASSED, same source as `native/shell.cc` (1,016,832 B) |
487
+ | `scripts/probe-window-events.ts` | PASSED (21 checks) |
488
+ | `scripts/probe-accelerator-keys.ts` | PASSED (6 checks) |
489
+ | `scripts/probe-drop.ts` | PASSED (6 checks) |
490
+ | `scripts/probe-drop-live.ts --file` | **PASSED (12 checks)** — one real file drag, absolute path |
491
+ | `scripts/probe-drop-live.ts --dir` | **PASSED (12 checks)** — one real folder drag, path **and** folder classification |
304
492
 
305
493
  ## [0.5.0] - 2026-10-21
306
494
 
@@ -308,13 +496,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
308
496
 
309
497
  ### Fixed
310
498
 
311
- - **Keyboard shortcuts work again.** `F11`, `F12`, `F5`, `Ctrl+R`, `Ctrl+0` and `Ctrl±` were all dead, while `Ctrl+wheel` zoom kept working. The cause was message routing, not the handling logic: `WM_KEYDOWN` is delivered to the **focused** window, and focus sits on WebView2's render child window (`Chrome_RenderWidgetHostHWND`), which does not forward unhandled keys to its parent. Every `handle_input_message` call site on the two window procedures was unreachable by real keystrokes. `Ctrl+wheel` survived only because `WM_MOUSEWHEEL` *is* forwarded to the parent by the focused window's `DefWindowProc`.
499
+ - **Keyboard shortcuts work again.** `F11`, `F12`, `F5`, `Ctrl+R`, `Ctrl+0` and `Ctrl±` were all dead, while `Ctrl+wheel` zoom kept working. The cause was message routing, not the handling logic: `WM_KEYDOWN` is delivered to the **focused** window, and focus sits on WebView2's render child window (`Chrome_RenderWidgetHostHWND`), which does not forward unhandled keys to its parent. Every `handle_input_message` call site on the two window procedures was unreachable by real keystrokes. `Ctrl+wheel` survived only because `WM_MOUSEWHEEL` _is_ forwarded to the parent by the focused window's `DefWindowProc`.
312
500
 
313
501
  The bug is easy to misdiagnose, because `PostMessage(hwnd, WM_KEYDOWN, VK_F11, 0)` toggles fullscreen correctly — that experiment simply bypasses what broke.
314
502
 
315
503
  The shell now subscribes to `ICoreWebView2Controller::add_AcceleratorKeyPressed`, which fires before the key reaches the page and does not depend on the focus chain. Key semantics live in one place (`handle_accelerator_key`), shared by the callback and the surviving `WM_KEYDOWN` path; only `KEY_DOWN` / `SYSTEM_KEY_DOWN` are handled (handling `KEY_UP` would toggle twice and look like "nothing installed"), and handled keys call `put_Handled(TRUE)`.
316
504
 
317
- - **`FLAG_ACCEPT_DROP` now delivers full paths.** With the flag on, pages received a DOM `drop` carrying only `name` and `size`; the promised `shell-msg { t: "drop", paths: [...] }` never arrived. `DragAcceptFiles` only sets `WS_EX_ACCEPTFILES` on the **top-level** window, but OLE hit-testing walks to the deepest window under the cursor (`webview_widget` → `Chrome_WidgetWin_1` → `Chrome_RenderWidgetHostHWND` → `Intermediate D3D Window`, all measured `ACCEPTFILES=False`). WebView2 registers its own `IDropTarget` on the render child, takes the drop, and turns it into a DOM event — the top-level default drop target never sees it. The three `ChangeWindowMessageFilterEx` calls only allow messages *in*; they do not decide where a message *goes*.
505
+ - **`FLAG_ACCEPT_DROP` now delivers full paths.** With the flag on, pages received a DOM `drop` carrying only `name` and `size`; the promised `shell-msg { t: "drop", paths: [...] }` never arrived. `DragAcceptFiles` only sets `WS_EX_ACCEPTFILES` on the **top-level** window, but OLE hit-testing walks to the deepest window under the cursor (`webview_widget` → `Chrome_WidgetWin_1` → `Chrome_RenderWidgetHostHWND` → `Intermediate D3D Window`, all measured `ACCEPTFILES=False`). WebView2 registers its own `IDropTarget` on the render child, takes the drop, and turns it into a DOM event — the top-level default drop target never sees it. The three `ChangeWindowMessageFilterEx` calls only allow messages _in_; they do not decide where a message _goes_.
318
506
 
319
507
  The fix is upstream: `ICoreWebView2Controller4::put_AllowExternalDrop(FALSE)` steps aside from that internal target, so the OLE chain continues to the parent and `WM_DROPFILES` reaches `handle_drop`. No hand-written `IDropTarget` — that would fight WebView2's internals, and the layering can change between releases (which is exactly how this was hit). On an older runtime the `QueryInterface` fails and the shell keeps WebView2's default behaviour rather than blocking startup.
320
508
 
@@ -337,7 +525,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
337
525
 
338
526
  - **WebView2 C/C++ SDK upgraded from `1.0.1150.38` to `1.0.4258.31`.** `AllowExternalDrop` needs SDK `1.0.1185.39` or newer and did not exist in the previous vendored copy, which is why the drag-and-drop fix above was impossible before. `AdditionalObjects` and `ICoreWebView2File` arrived with it; they are not used, because they are reachable only through `postMessage`, and this shell keeps page communication on WebSocket. The old nupkg is removed and the new one is committed, so offline rebuilds behave exactly as before.
339
527
 
340
- `CORE_WEBVIEW_TARGET_PRODUCT_VERSION` moves from `99.0.1150.38` to `154.0.4258.31`. This project already requires Bun ≥ 1.4.3, TypeScript ≥ 7.0.2, Windows x64 and a working WebView2 Runtime, and **does not aim for compatibility with older runtimes** — that is a decision, not an oversight.
528
+ `CORE_WEBVIEW_TARGET_PRODUCT_VERSION` moves from `99.0.1150.38` to `154.0.4258.31`. This project requires Bun ≥ 1.4.4, TypeScript ≥ 7.0.2, Windows x64 and a working WebView2 Runtime, and **does not aim for compatibility with older runtimes** — that is a decision, not an oversight.
341
529
 
342
530
  - **`timeoutMs: 0` is now documented and guarded.** `pickFolder()` blocks the host thread for however long a person browses, so `connect({ timeoutMs: 0 })` is required when exposing it over IPC — otherwise the client rejects after 30 seconds while the host is still working, and a missing `.catch` makes that entirely silent. Both halves are now in the docs, and two assertions keep the contract: one behavioural (`timeoutMs: 0` still pending after 50 ms) and one static (no `||` fallback, which would turn `0` back into `30000`).
343
531
 
@@ -351,17 +539,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
351
539
 
352
540
  ### Verification
353
541
 
354
- | Command | Result |
355
- | --- | --- |
356
- | `bun check` | 68 files |
357
- | `bun test` | 556 pass / 0 fail |
358
- | `bun run scripts/gate.ts` | 4 stages, PASSED |
359
- | `bun run scripts/gate.ts --clean` | PASSED (clean) |
360
- | `bun run scripts/verify-dll.ts` | PASSED, `dist/libshell.dll` matches `native/shell.cc` |
361
- | `scripts/probe-window-events.ts` | 21 checks |
362
- | `scripts/probe-accelerator-keys.ts` | 6 checks |
363
- | `scripts/probe-drop.ts` | 6 checks |
364
- | shape B in an empty directory | exit 0, `source=embedded`, `reused=true` |
542
+ | Command | Result |
543
+ | ----------------------------------- | ----------------------------------------------------- |
544
+ | `bun check` | 68 files |
545
+ | `bun test` | 556 pass / 0 fail |
546
+ | `bun run scripts/gate.ts` | 4 stages, PASSED |
547
+ | `bun run scripts/gate.ts --clean` | PASSED (clean) |
548
+ | `bun run scripts/verify-dll.ts` | PASSED, `dist/libshell.dll` matches `native/shell.cc` |
549
+ | `scripts/probe-window-events.ts` | 21 checks |
550
+ | `scripts/probe-accelerator-keys.ts` | 6 checks |
551
+ | `scripts/probe-drop.ts` | 6 checks |
552
+ | shape B in an empty directory | exit 0, `source=embedded`, `reused=true` |
365
553
 
366
554
  Each of the two new probes was verified in both directions: removing `install_accelerator_handler()` makes a real `F11` do nothing (1826 px → 1826 px), and removing `handle_drop(...)` leaves the `HDROP` handle alive. With the fix in place both pass.
367
555
 
@@ -427,7 +615,7 @@ Dragging a real file into a window to confirm the page sees the **full path**. A
427
615
 
428
616
  Four decisions, each with a failure mode behind it:
429
617
 
430
- **Capacity 128, drop the oldest.** A one-second drag at a 16 ms cadence is about 60 events, so 128 is a two-fold margin. Dropping the *newest* instead would freeze the ring at a stale state and the host would see "the window never changed" — far worse than losing history. Losses are reported as gaps in `seq` rather than synthesised into an overflow event: the gap carries the exact count, and no new `seq` special case is needed.
618
+ **Capacity 128, drop the oldest.** A one-second drag at a 16 ms cadence is about 60 events, so 128 is a two-fold margin. Dropping the _newest_ instead would freeze the ring at a stale state and the host would see "the window never changed" — far worse than losing history. Losses are reported as gaps in `seq` rather than synthesised into an overflow event: the gap carries the exact count, and no new `seq` special case is needed.
431
619
 
432
620
  **`CLOSED` is emitted after teardown completes.** That is the whole point: "saw `CLOSED`" must be equivalent to "the process-wide singleton is released, restart is safe". It follows that `shell_poll_events` must **not** return -1 when `g_webview` is null, unlike every other export — `CLOSED` is produced precisely after the handle is gone.
433
621
 
@@ -475,7 +663,7 @@ Dragging a real file into a window to confirm the page sees the **full path**. A
475
663
 
476
664
  - The acceptance for this release was bidirectional rather than one-sided. Commenting out `emit_event(SHELL_EVENT_STATE, ...)`, rebuilding, and running `scripts/probe-window-events.ts` turned it from 21/21 green to 11/21 red with `exit=1` — while the ten `CLOSED` assertions stayed green, confirming the injection was isolated to the state path. Restoring and rebuilding returned 21/21, and `verify-dll.ts` reported the same sha256 as before the injection, which also proves the build is reproducible. `docs/pitfalls.md` 2.4k records the four failure modes found along the way.
477
665
 
478
- - This is **not** a competitor to `Bun.WebView`, and the READMEs now say so explicitly. `Bun.WebView` is a *headless* browser for automation, testing and scraping: it has no window, and it can only screenshot its own page. This package draws a real desktop window — system title bar, DPI awareness, snap layouts, taskbar identity — and its capture tool can photograph **any** window, which is the whole point for computer-use. Comparing API surface between the two is comparing different problems.
666
+ - This is **not** a competitor to `Bun.WebView`, and the READMEs now say so explicitly. `Bun.WebView` is a _headless_ browser for automation, testing and scraping: it has no window, and it can only screenshot its own page. This package draws a real desktop window — system title bar, DPI awareness, snap layouts, taskbar identity — and its capture tool can photograph **any** window, which is the whole point for computer-use. Comparing API surface between the two is comparing different problems.
479
667
 
480
668
  ## [0.3.0] - 2026-10-21
481
669
 
@@ -491,7 +679,7 @@ Dragging a real file into a window to confirm the page sees the **full path**. A
491
679
 
492
680
  - **`FLAG_ALLOW_EXTERNAL_NAV` (new, `128`) opts out of navigation interception** for applications that genuinely are browsers. This is the release's one breaking-in-effect change: without the flag, a cross-origin top-level navigation now opens the system browser instead of replacing the app. Nothing else about the flag's surrounding policy moved — `window.open` / `target=_blank` is still cancelled and opened externally **even with this flag**, because that is a capability boundary rather than a safety policy: the shell owns exactly one top-level window (the native side is a process-level singleton), so a second window would have no host API able to close, read or message it.
493
681
  - Only `http:` / `https:` ever reach `ShellExecuteW`. `file:`, `javascript:`, `ms-*:` and custom schemes are cancelled silently and never touch a protocol handler, and `mailto:` / `tel:` are deliberately **not** on the list either — a dead `mailto:` link is preferable to handing a page an arbitrary protocol dispatcher.
494
- - **An origin-less startup URL is no longer a free pass.** The old predicate returned "same origin" whenever `g_origin` was empty, which meant `file://`, `about:blank` and custom-scheme apps — the most common shape for a desktop shell — were the least protected. That branch now degrades to a *same-document* test: a navigation stays inside the window only if it matches the startup URL after stripping the `#fragment`. In-app hash routing keeps working; anything else is external.
682
+ - **An origin-less startup URL is no longer a free pass.** The old predicate returned "same origin" whenever `g_origin` was empty, which meant `file://`, `about:blank` and custom-scheme apps — the most common shape for a desktop shell — were the least protected. That branch now degrades to a _same-document_ test: a navigation stays inside the window only if it matches the startup URL after stripping the `#fragment`. In-app hash routing keeps working; anything else is external.
495
683
 
496
684
  ### Added
497
685
 
@@ -509,37 +697,39 @@ Dragging a real file into a window to confirm the page sees the **full path**. A
509
697
 
510
698
  - **The PNG encoder produced IDAT chunks that were not zlib.** `Bun.deflateSync` is byte-identical to `zlib.deflateRawSync`, so the output was **raw deflate**, while the specification requires zlib framing (`0x78` header plus an adler32 trailer). What made this expensive to find is that **three decoders gave two different answers**: `System.Drawing` opened the files, and our own decoder plus `Bun.inflateSync` decompressed them — because they share the same raw-deflate assumption as the encoder. Only the strict decoder rejected them: `Bun.Image` (libspng) returned `ERR_IMAGE_DECODE_FAILED`, and so did the upstream validator behind `read_file`. Worse, `Bun.Image.metadata()` still succeeded, because it reads only the header and never touches the IDAT payload — so the failure looked like "correct size, correct format, just won't open". `native/png.ts` now imports `deflateSync` from `node:zlib` (Bun's built-in node compat module, not an external node runtime), and `tests/png.test.ts` asserts both that the first IDAT byte is `0x78` and that the payload decompresses under **strict** zlib inflate — never `Bun.inflateSync`, which would make the assertion vacuous. Recorded as `docs/pitfalls.md` 3.11.
511
699
  - **`Bun.Image.resize(w)` limits the width, not the longest edge.** The documented semantics are `resize(800)` → "width 800, keep aspect ratio". `native/model-image.ts` was calling `resize(maxEdge, undefined, { withoutEnlargement: true })`, which is correct for landscape images and silently wrong for portrait ones: `300×600` with `maxEdge: 100` produced `100×200`, four times the intended pixel count, with the token estimate and byte size inflated to match. The fix passes both dimensions with `fit: "inside"`. Caught by asserting on a **portrait** image; a landscape-only test would have stayed green. Recorded as `docs/pitfalls.md` 3.15.
512
- - Six references in `AGENTS.md` and `docs/pitfalls.md` pointed at files that no longer exist: `tools/capture-window.ps1` (three occurrences), `probe-memory.ps1`, `src/safe-port.ts` (the module is actually `src/shell/port.ts`), and `src/gui/page-script.test.ts` (the whole `src/gui/` directory was removed when the UI became a static `src/ui/index.html`). A migration leaves legitimate historical references behind, so the fix is not to delete them but to make the decision explicit — see the guard under *Added*.
700
+ - Six references in `AGENTS.md` and `docs/pitfalls.md` pointed at files that no longer exist: `tools/capture-window.ps1` (three occurrences), `probe-memory.ps1`, `src/safe-port.ts` (the module is actually `src/shell/port.ts`), and `src/gui/page-script.test.ts` (the whole `src/gui/` directory was removed when the UI became a static `src/ui/index.html`). A migration leaves legitimate historical references behind, so the fix is not to delete them but to make the decision explicit — see the guard under _Added_.
513
701
 
514
702
  ### Added
515
703
 
516
704
  - `native/resample.ts` — area-average (box) downscaling over BGRA, and the reason `--max-edge` no longer goes through `Bun.Image` at all. Measured on a real 3098×1850 window scaled to 1300×776, median of five runs:
517
705
 
518
- | route | ms | bytes |
519
- | --- | --- | --- |
706
+ | route | ms | bytes |
707
+ | ------------------------------------- | -------- | ----------- |
520
708
  | **our `downscaleBgra` + our encoder** | **45.9** | **102,036** |
521
- | `Bun.Image` resize + png | 95.9 | 222,621 |
709
+ | `Bun.Image` resize + png | 95.9 | 222,621 |
522
710
 
523
711
  **2.1× faster and 2.2× smaller — both directions, not a trade-off.** It is faster because `Bun.Image` takes a PNG as input, so it must encode and then decode again before it can resample; we hold the raw `CreateDIBSection` pointer and never pay that. It is smaller because its default filter is `lanczos3` — a **sharpening** filter that rings around text edges, and the resulting high-frequency detail is exactly what DEFLATE compresses worst. Area averaging is inherently smooth, so the correct algorithm saves both costs at once. A prefix-sum variant was tried and came out **slower** (51.3 vs 39.1 ms for the resample alone): the extra full-width pass and the larger intermediate buffer cost more in cache locality than the inner-loop subtraction saves. Recorded as `docs/pitfalls.md` 3.16.
712
+
524
713
  - `native/resample.ts` weights the average by alpha (`Σ(b·a)/Σa`) whenever any pixel is transparent, because a plain average mixes the RGB of fully transparent pixels — usually `0` — into the result and grows a black fringe around rounded corners and shadows. `scripts/make-icon.ts` produces exactly such rounded corners. Fully opaque input takes a fast path where the arithmetic is exact. `tests/resample.test.ts` pins this with a 2×2 → 1×1 construction (one opaque black pixel, three opaque whites): the right answer is white at alpha 191, the wrong one is grey 191.
525
714
  - `cropBgra` in `native/gdi.ts` — cropping is a per-row `memcpy` over the raw pointer, so it costs no FFI and no resample. It is deliberately applied **before** encoding: once the pixels are handed to `Bun.Image` they are an opaque object and can no longer be cut. `--crop x,y,w,h` exposes it, and the rectangle is clamped to the image rather than producing a malformed one.
526
715
  - `native/model-image.ts` — narrowed to the three things the in-repo code cannot do: **enlarging** (box averaging cannot interpolate), **palette** quantisation, and **jpeg / webp** encoding. Everything else on the PNG path now stays inside `native/`.
527
716
  - A **two-way** guard on the `AGENTS.md` directory tree. The existing assertion ("everything declared must exist on disk") cannot catch the opposite failure, and `native/model-image.ts` sat outside the tree for a whole round while the gate was green — an unlisted file is invisible to an agent reading the tree as its map. Writing the reverse assertion also exposed a blind spot in the forward one: the tree is aligned by longest name, so `probe-child-windows.ts` is followed by a **single** space, and the parser's `\s{2,}` separator meant that line had never been parsed — and therefore never checked — since the guard was written.
528
- - A guard for script paths referenced in the docs, backed by an explicit *deleted* allow-list (same orientation as `verify-pack.ts`'s `ALLOW`: adding an entry must be a deliberate decision). A second assertion fails if a listed-deleted file reappears, so the list cannot rot unnoticed.
717
+ - A guard for script paths referenced in the docs, backed by an explicit _deleted_ allow-list (same orientation as `verify-pack.ts`'s `ALLOW`: adding an entry must be a deliberate decision). A second assertion fails if a listed-deleted file reappears, so the list cannot rot unnoticed.
529
718
 
530
719
  ### Changed
531
720
 
532
721
  - `tools/capture-window.ts` defaults are now set by measurement rather than intuition: `--format png`, `--level 6`, `--max-edge 0`, plus a new `--level <1-9>` and `--crop <x,y,w,h>`. Level 6 is the knee — 31% smaller than level 1 for about 3 ms, and 4× faster than level 9 for 13% more bytes. End-to-end on a real window (including Bun startup and `PrintWindow`):
533
722
 
534
- | command | bytes | end-to-end ms |
535
- | --- | --- | --- |
536
- | `--level 1` | 704,353 | 148 |
537
- | default (level 6) | 439,347 | 170 |
538
- | `--level 9` | 411,366 | 330 |
539
- | `--crop 700,300,800,600` | **50,836** | 170 |
723
+ | command | bytes | end-to-end ms |
724
+ | ------------------------ | ---------- | ------------- |
725
+ | `--level 1` | 704,353 | 148 |
726
+ | default (level 6) | 439,347 | 170 |
727
+ | `--level 9` | 411,366 | 330 |
728
+ | `--crop 700,300,800,600` | **50,836** | 170 |
540
729
 
541
730
  Level 9 buys 6% for double the time; cropping buys **88%** and also drops the token estimate from 1024 to 291. Recorded as `docs/pitfalls.md` 3.14.
542
- - The choice between the in-repo encoder and `Bun.Image` is now two conditions rather than one: **downscale *and* png** together keep the whole path inside `native/`; anything else (enlarging, palette, jpeg, webp) still goes to `Bun.Image`. Previously a crop was enough to trigger the `Bun.Image` detour, which re-encoded through it for no benefit — 50.0 ms / 84 KiB against 6.0 ms / 62 KiB for the same crop. **Cropping is a pixel operation and must not change the encoding route.**
731
+
732
+ - The choice between the in-repo encoder and `Bun.Image` is now two conditions rather than one: **downscale _and_ png** together keep the whole path inside `native/`; anything else (enlarging, palette, jpeg, webp) still goes to `Bun.Image`. Previously a crop was enough to trigger the `Bun.Image` detour, which re-encoded through it for no benefit — 50.0 ms / 84 KiB against 6.0 ms / 62 KiB for the same crop. **Cropping is a pixel operation and must not change the encoding route.**
543
733
 
544
734
  ### Note
545
735
 
@@ -685,14 +875,15 @@ First public release.
685
875
  ### Known limitations
686
876
 
687
877
  - Windows x64 only. The `os` and `cpu` fields in `package.json` reject every other platform at install time. Cross-platform support is a reservation in the design, not a capability: `shell.cc` is Win32 and WebView2 throughout.
688
- - TypeScript sources are published, not compiled. Consumers need Bun 1.4.3 or newer. Installing under Node succeeds but nothing runs.
878
+ - TypeScript sources are published, not compiled. Consumers need Bun 1.4.4 or newer. Installing under Node succeeds but nothing runs.
689
879
  - `bun:ffi` is marked experimental in Bun, and the entire native boundary depends on it.
690
880
  - `pickFolder()` blocks the calling thread. The modal loop inside `SHBrowseForFolder` keeps timers on that thread from firing, and the call cannot be cancelled or timed out. Run it in a worker if you need concurrency.
691
881
  - One window per process. The native layer keeps a process-wide singleton, so a second `start()` fails until the previous window is observed dead through `isAlive()`.
692
882
  - Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
693
883
  - No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
694
884
 
695
- [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.7.0...HEAD
885
+ [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.8.0...HEAD
886
+ [0.8.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.7.0...v0.8.0
696
887
  [0.7.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.2...v0.7.0
697
888
  [0.6.2]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.1...v0.6.2
698
889
  [0.6.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.0...v0.6.1