bun-app-window 0.7.0 → 0.9.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,272 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-10-25
10
+
11
+ ### Added
12
+
13
+ - **`getTitle()` / `getMinSize()` / `getBrowserUi()` read back what the setters wrote.** Until now
14
+ every window capability was one-way: `setTitle()`, `setMinSize()` and `setBrowserUi()` returned
15
+ `rc === 0`, which only means "the message reached the UI thread". A failed `SetWindowTextW`, a
16
+ size the system clamped, a browser behaviour that could not be disabled — all silent.
17
+
18
+ The three are **not** the same shape, and copying one template across all three would have been
19
+ wrong:
20
+
21
+ | Method | Where the truth lives |
22
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `getMinSize()` | `g_min_w` / `g_min_h` — the same values `WM_GETMINMAXINFO` consumes, so the cache _is_ the truth |
24
+ | `getTitle()` | The window itself (`GetWindowTextW`). `g_title` is assigned once in `shell_start_ex` and `shell_title()` never updates it, so reading it would return the startup title forever |
25
+ | `getBrowserUi()` | A new `g_browser_ui`, accumulated with `\|=` |
26
+
27
+ `getTitle()` is the one that matters: reading a cached _input_ would make it an echo rather than
28
+ a getter, and it would report success for a `SetWindowTextW` that never took effect — exactly the
29
+ failure the whole read-back surface exists to expose.
30
+
31
+ - **`getBrowserUi()` accumulates, because that is what the window actually does.** `apply_browser_ui()`
32
+ only ever calls `put_XXX(FALSE)` and returns early on `mask == 0` — there is no path that turns a
33
+ behaviour back on. So `setBrowserUi(MENU)` then `setBrowserUi(KEYS)` leaves _both_ disabled.
34
+ Recording the last mask would make the getter report `KEYS` alone, which reads as "the menu came
35
+ back". It is a bitwise OR at the setter instead.
36
+
37
+ - **`Shell.getRect()` reads the window rectangle as a structure.** `getBounds()` has always returned
38
+ the native line `"x,y,w,h,maximized,dpi"` as a string, so every consumer had to split and validate
39
+ it themselves — and the minimised sentinel rectangle (`-32000,-32000` plus an icon-sized box)
40
+ passes a naive shape check while being a completely fake position. Reading geometry from the host
41
+ also meant importing `bun-app-window/state` (which pulls in `node:fs`) for a single value.
42
+
43
+ `getRect()` returns `{ x, y, width, height, maximized, dpi } | null`, applying the same three
44
+ rejections as `parseBounds()`: window not started, zero size or DPI, or the sentinel rectangle.
45
+ All six fields come from one native read, so a caller asking "is this the whole screen?" does not
46
+ need a second FFI call that could observe a different state.
47
+
48
+ - **`parseBounds` and `WindowRect` moved to `bun-app-window/types`.** `bun-app-window/state` still
49
+ re-exports both — `WindowPlacement` is now an alias of `WindowRect`, not a second structurally
50
+ identical type — so existing imports keep working. The move lets the main entry use the parser
51
+ without depending on `node:fs`, and both paths expose the same function object (`toBe` pins it).
52
+
53
+ ### Fixed
54
+
55
+ - **`getTitle()` no longer returns a silently truncated title.** The first version read into a
56
+ fixed 512-character buffer and _then_ checked against the caller's capacity — but
57
+ `GetWindowTextW(hwnd, wbuf, 512)` only writes 511 characters and returns 511 for anything longer,
58
+ and 511 ASCII bytes fit comfortably under the 1024-byte buffer. Measured: a 600-character title
59
+ came back as 511 characters, **89 short, with no signal at all**. That is worse than failing: the
60
+ caller has no way to know the string is incomplete.
61
+
62
+ It now asks `GetWindowTextLengthW` for the real length first and returns `-1` (→ `null`) when it
63
+ exceeds the internal buffer. The buffer size and the bound check share one constant, so raising
64
+ one without the other cannot reintroduce the truncation.
65
+
66
+ The guard for this has its own cautionary note: the first version counted occurrences in the
67
+ un-stripped function body, and the mutation test left `// if (len >= kTitleBufW) return -1;` in a
68
+ comment — the count stayed at 3 and **the mutation did not turn red**. Counting after stripping
69
+ comments is what made it fail correctly.
70
+
71
+ - **`setMinSize()` no longer leaks across windows.** `g_min_w` / `g_min_h` are process-level globals
72
+ and nothing reset them, so a window that set 800×600 handed those limits to the _next_ window in
73
+ the same process — one that never asked for them. `g_browser_ui` would have inherited the same
74
+ defect. All three now reset in `shell_start_ex` alongside the other per-life globals.
75
+
76
+ Behaviour change: calling `setMinSize()` _before_ `start()` no longer has any effect. The public
77
+ surface never promised it (the documented path is `ShellOptions.minWidth` / `minHeight`, applied
78
+ after `start()`), and the TS side already works that way.
79
+
80
+ ## [0.8.0] - 2026-10-25
81
+
82
+ ### Added
83
+
84
+ - **`bundlePage` passes `plugins`, `define` and `loader` through to `Bun.build`.** These are the
85
+ generic fields a compiling frontend framework needs; before this release `BundlePageOptions` had
86
+ only `entry` and `minify`, so Svelte and Vue could only be built by calling `Bun.build` directly
87
+ and giving up the failure diagnostics and byte accounting this module exists to provide.
88
+
89
+ Measured on a real Svelte 5 component (`bun-plugin-svelte` 0.0.6): 109,888 B entry script plus a
90
+ 133 B stylesheet, with the component's render code present in the output.
91
+
92
+ - **`PageBundle` now reports every output, and `writePageBundle` writes them all.** `outputs` lists
93
+ each artifact with its `path` / `kind` / `code` / `bytes`; `code` and `bytes` keep their previous
94
+ meaning and now come from the `entry-point` artifact rather than `outputs[0]` — output order is
95
+ not a contract, and stylesheets can sort first.
96
+
97
+ The second artifact used to be dropped silently. The symptom was a page that rendered correctly
98
+ with no styling, and nothing anywhere reported a missing file. `writePageBundle` now returns
99
+ `written: Array<{ path, bytes }>` so a caller's cleanup can remove every file it produced, not
100
+ just the entry one.
101
+
102
+ Placement rule: the entry artifact goes to `out`; every other artifact goes to the same
103
+ directory with its extension substituted. `page.bundle.txt` therefore yields
104
+ `page.bundle.css`, not `main.css` — the entry's filename is an implementation detail and must
105
+ not leak into a path that HTML and `assets` reference.
106
+
107
+ - **`createApp({ assets })` serves extra static files.** A path-to-text map with `content-type`
108
+ inferred from the extension. Multiple artifacts meant every consumer had to hand-write a `routes`
109
+ callback to serve the stylesheet, and the usual mistake — a wrong MIME type — makes the browser
110
+ drop the stylesheet silently. Unrecognised extensions fall back to `application/octet-stream`
111
+ rather than guessing: a wrong MIME type means the file is not executed, while a fallback means it
112
+ is downloaded, and the latter is immediately visible.
113
+
114
+ A caller's `routes` still wins, and paths that are not registered are 404 — `assets` is a
115
+ convenience, not a catch-all.
116
+
117
+ - **Page-side errors are now reported to the host.** An injected listener captures `error` and
118
+ `unhandledrejection` and posts them to the host over HTTP — deliberately not over the WebSocket,
119
+ which does not exist yet when a page script fails during evaluation, and that is the case worth
120
+ observing most. Pass `onPageError` to receive a `PageError`; without a callback the host prints
121
+ them to stderr rather than discarding them.
122
+
123
+ Measured on a real window: a page script that throws at module evaluation used to produce a
124
+ blank window and a single "window ready" line in the host log. It now produces
125
+ `kind=error message=Uncaught Error: … source=…/page.js:1:7` in the host log within the same
126
+ startup.
127
+
128
+ - **`emit()` accepts `{ sticky: true }`.** A page loads after the window is created, so anything
129
+ broadcast between `createApp` returning and the page's WebSocket opening is delivered to zero
130
+ connections and never replayed — a page that reads its initial state from events alone stays
131
+ empty, with no error at either end. Sticky events keep the last payload per event name and replay
132
+ it in `attach()`. It is opt-in: a notification-shaped event that turns sticky gives late
133
+ connections one stale message that reads like a fresh one.
134
+
135
+ ### Changed
136
+
137
+ - **The gate gains a third stage: `bun fmt --check --no-script`.** Four stages now — `check` →
138
+ `test` → `fmt` → `pack`. The formatting check costs about 50 ms on 92 files, and it sits before
139
+ the consumer acceptance stage because a formatting problem is local code quality, not a
140
+ distribution question. `--skip-fmt` exists for the same reason `--skip-pack` does; `--skip-test`
141
+ does **not** skip it, since formatting and tests have no dependency between them.
142
+
143
+ Two criteria this stage depends on, each measured rather than assumed:
144
+
145
+ **`--no-script` is mandatory.** When `package.json` has a `fmt` script, `bun fmt --check` hands
146
+ the whole command to that script and returns its exit code verbatim. Measured with a script that
147
+ only prints one line: `bun fmt --check` exited 0 and printed that line, having inspected no files
148
+ at all. The criterion would silently degrade to "did that script succeed". This repository has no
149
+ `fmt` script today, so the flag changes nothing right now — which is exactly why it needs a
150
+ static assertion: the failure point is the day someone adds the script, and nothing would turn
151
+ red then.
152
+
153
+ **The file count has to be checked.** If `.prettierignore` is ever written as `*`, the command
154
+ exits 0 and reports everything compliant while having looked at nothing. `fmtVerdict()` therefore
155
+ treats "exit 0 but no file count parsed" as a failure, the same way `bun check` treats "checked 0
156
+ files".
157
+
158
+ **The judgement is a pure function because it had no guard.** Reverse verification ran before
159
+ this was written: removing "non-zero exit means failure" and removing the "count below 1" guard
160
+ each left the entire suite green — `runFmt` can only be reached by spawning a real subprocess,
161
+ and the short-circuit tests use a fake runner. The logic was lifted into `fmtVerdict()` so both
162
+ defences have cases; removing either now turns the corresponding assertion red, verified in both
163
+ directions.
164
+
165
+ - **The declared toolchain baseline is Bun 1.4.4.** `engines.bun` is `>=1.4.4` and `@types/bun` is
166
+ `^1.4.4`; the three READMEs, the requirement table and the badge follow. The declaration is backed
167
+ by the gate run recorded at the end of this entry, not inherited from an earlier release.
168
+
169
+ Two behaviours were re-measured on this baseline rather than assumed unchanged:
170
+
171
+ | Probe | Result |
172
+ | -------------------------------------- | ----------------------------------- |
173
+ | `FFIType.buffer` as `args` / `returns` | throws `invalid ABI type` |
174
+ | `"buffer"` string literal as `args` | accepted, and rejected as `returns` |
175
+
176
+ So the `bun:ffi` constraint in `AGENTS.md` §5.1 holds on this baseline unchanged.
177
+
178
+ `bun-types@1.4.4` gates its import-attribute declarations behind
179
+ `typesVersions: { ">=7.1": ... }`, and its root `index.d.ts` is 1,306 B with no
180
+ `import-attributes` reference. The TypeScript 7.0.2 limitation documented in the requirements
181
+ table therefore still holds; nothing about it changed with this baseline.
182
+
183
+ Full regression: `bun check` 80 files, `bun test` 683 pass / 0 fail across 36 files,
184
+ `VERIFY-PACK PASSED` (7 checks), consumer probe 70 checks with `source=package`.
185
+
186
+ - **`alias` is implemented by this package, not passed through.** The plan was to forward it like
187
+ the other three fields; measurement said otherwise. Bun 1.4.4's `BuildConfig` has no `alias`
188
+ property, and passing one is not an error — it is silently ignored. Four forms were tried
189
+ (absolute path to a file, relative path to a file, relative path to a directory, trailing-slash
190
+ key) and all four failed to resolve, each reporting only `Bundle failed`.
191
+
192
+ It is now backed by a small `onResolve` plugin with a specified boundary: a specifier equal to a
193
+ key or prefixed by `key/` is rewritten; relative paths, absolute paths and `scheme:` specifiers
194
+ are left alone. The plugin is only registered when `alias` is supplied, so its hook does not sit
195
+ in every resolution path. When the alias target cannot be resolved, the candidate path is
196
+ handed back unchanged so Bun reports its own error rather than a rewritten one.
197
+
198
+ Aliases merge with, rather than replace, the caller's plugins — the two are orthogonal, and
199
+ replacing would make "using alias means you cannot use a framework plugin" a hidden rule.
200
+
201
+ - **BREAKING: `createIpcServer` throws when the service table contains a reserved client name.**
202
+ `on` / `off` / `close` / `connected` / `pending` / `then` resolve to the IPC client's own members,
203
+ so a service method under one of those names is unreachable and neither end reports anything.
204
+ Measured before this change: `client.close()` returned `undefined` — not a Promise — and the
205
+ server-side method was invoked zero times, with no exception raised, because `await undefined` is
206
+ a legal expression. The check runs at creation and names the offending keys. Pass
207
+ `{ rejectReservedNames: false }` only when the table is generated from external input and
208
+ validated elsewhere.
209
+
210
+ - **`createApp` validates `html` against `page`.** Supplying one without the other produced a window
211
+ that rendered normally, loaded no script, and responded to nothing, with an empty host log.
212
+ Both directions now throw before the server starts; a reference that lacks `type="module"`
213
+ produces a warning instead, because the legal ways to write a `<script>` tag are too varied for a
214
+ string match to justify blocking startup.
215
+
216
+ - **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.
217
+
218
+ - `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.
219
+ - `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.
220
+
221
+ - **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.
222
+
223
+ ### Note
224
+
225
+ - **`alias` defaults to empty, deliberately.** `$lib` is a SvelteKit convention and `@/` is some
226
+ project's `tsconfig` alias; neither belongs to this package. Presetting either would mean choosing
227
+ an ecosystem on the consumer's behalf.
228
+
229
+ - **Neutrality is now enforced by tests, not by convention.** `tests/pack-neutrality.test.ts` asserts
230
+ that `dependencies` / `peerDependencies` / `optionalDependencies` are all empty, that no frontend
231
+ framework name appears in a non-comment line of `src/shell/`, and that no alias is preset.
232
+ Rationale and rejected alternatives are in `ADR 0021` (repository-local).
233
+
234
+ - The three READMEs gain a subsection on multi-artifact output; package size re-measured as
235
+ 22 files / 0.50 MB packed / 1.47 MB unpacked.
236
+
237
+ - **`bun fmt` adopted with an explicit style, and one contract moved off source layout.**
238
+ `bunfig.toml` pins `useTabs = false`, `tabWidth = 4`, `printWidth = 120`; `.prettierignore` excludes
239
+ `vendor/` first, because that tree is a verbatim upstream copy and editing it manufactures
240
+ conflicts with the next upstream sync. 73 files were reformatted; `vendor/` verified unchanged.
241
+
242
+ The style had to be declared rather than defaulted: `bun fmt --help` documents `tabWidth` as
243
+ `default: 2`, while 1.4.4 actually emits 4. A default that disagrees with its own documentation
244
+ is not a fact to depend on.
245
+
246
+ Reformatting exposed a real defect rather than a cosmetic one. `tests/version.test.ts` read
247
+ `--windows-version` **line by line**, so it only recognised the version literal when
248
+ `"--windows-version", "0.8.0.0",` sat on one line. At 120 columns the formatter splits those two
249
+ elements, and four assertions went red against code that had not changed. The contract was
250
+ anchored on layout instead of on the value. It now matches over the whole text after blanking
251
+ comment lines, so both layouts parse; `bump-version.ts` already spanned lines via `\s*` and
252
+ preserves the surrounding whitespace, which is now pinned by a test that asserts the rewritten
253
+ version stays on its own line rather than being collapsed back.
254
+
255
+ `bun fmt` does **not** honour `// prettier-ignore` on array elements — measured: the comment is
256
+ kept, the element is still split. That is why the fix moved the parser instead of silencing the
257
+ formatter.
258
+
259
+ The reformat adds 30 KB to the unpacked tarball (1.44 → 1.47 MB): splitting array elements adds
260
+ a level of indentation. Declared size updated in all three READMEs.
261
+
262
+ - The four changes in the 0.8.0 batch — page-error relay, sticky events, reserved-name
263
+ rejection and `html`/`page` validation — share one design criterion: **a legal action that puts the
264
+ system into an unusable state must be observable by someone.** Two of them now stop at creation; two
265
+ are exposed through the host log. The rationale, the measurements behind each, and the rejected
266
+ alternatives are in `ADR 0020` (repository-local).
267
+
9
268
  ## [0.7.0] - 2026-10-25
10
269
 
11
270
  ### Added
12
271
 
13
272
  - **`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
273
 
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.
274
+ **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
275
 
17
276
  **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
277
 
@@ -36,16 +295,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
36
295
 
37
296
  - **Six README defects fixed, then the checks that would have caught them were written.** The two worst:
38
297
 
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 |
298
+ | Defect | Before | Now |
299
+ | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
300
+ | `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 |
301
+ | 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 |
302
+ | `ackOf()`, `titleBarCss()`, `detectIconSet()`, `captions: "native"` | never mentioned | documented |
303
+ | 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 |
304
+ | `types` subpath constants | `STATE_*` only | three named constants plus the `CAPTION_*` trio, and why the caption order is load-bearing |
305
+ | Tarball size | 1.37 MB unpacked | 1.39 MB — measured, not estimated |
47
306
 
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 (`各替我省一次调用` → `各能省掉一次调用`).
307
+ 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
308
 
50
309
  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
310
 
@@ -88,7 +347,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88
347
 
89
348
  **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
349
 
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.
350
+ - **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
351
 
93
352
  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
353
 
@@ -114,7 +373,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
114
373
  HTMINBUTTON → minimized state=2, win=314x50 (sentinel — really minimized)
115
374
  ```
116
375
 
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.
376
+ **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
377
 
119
378
  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
379
 
@@ -122,9 +381,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
122
381
 
123
382
  - **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
383
 
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.
384
+ **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
385
 
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.
386
+ **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
387
 
129
388
  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
389
 
@@ -190,7 +449,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
190
449
 
191
450
  - **`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
451
 
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`.
452
+ - **`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
453
 
195
454
  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
455
 
@@ -200,23 +459,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
200
459
 
201
460
  - **BREAKING: `Shell.beginResize(edge)` takes `RESIZE_EDGE` values (10–17).** Migrating from 0.5.x:
202
461
 
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` |
462
+ | You were passing | Before | Now |
463
+ | ----------------------------------- | ---------------------- | ------- |
464
+ | `RESIZE_EDGE.LEFT` (10) and friends | `rc=-1`, nothing moved | works |
465
+ | raw indices 0–7 | worked | `rc=-1` |
207
466
 
208
467
  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
468
 
210
469
  - `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
470
  - **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.
471
+ - `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
472
  - 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
473
 
215
474
  ### Fixed
216
475
 
217
476
  - **`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
477
 
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.
478
+ 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
479
 
221
480
  ### Removed
222
481
 
@@ -241,7 +500,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
241
500
  - **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
501
 
243
502
  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.
503
+ 2. `AllowExternalDrop` means _allow external drop_. `FALSE` refuses drops; it does not forward them.
245
504
 
246
505
  The user-visible symptom was a red "not allowed" cursor over the window on every drag, with no drop and no `WM_DROPFILES`.
247
506
 
@@ -251,7 +510,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
251
510
 
252
511
  ### Added
253
512
 
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.
513
+ - **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
514
 
256
515
  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
516
 
@@ -263,7 +522,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
263
522
 
264
523
  - **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
524
 
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.
525
+ - **`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
526
 
268
527
  `--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
528
 
@@ -271,13 +530,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
271
530
 
272
531
  - **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
532
 
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 |
533
+ | Round | Cause | What made it visible |
534
+ | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
535
+ | 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` |
536
+ | 2 | `preventDefault()` alone does not accept a drop — `dataTransfer.dropEffect` stayed `none` | `dragover=72` with `drop=0` |
537
+ | 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` |
538
+ | 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 |
539
+ | 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
540
 
282
541
  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
542
 
@@ -290,17 +549,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
290
549
 
291
550
  ### Verification
292
551
 
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 |
552
+ | Command | Result |
553
+ | ----------------------------------- | --------------------------------------------------------------------------------- |
554
+ | `bun check` | 69 files |
555
+ | `bun test` | 558 pass / 0 fail |
556
+ | `bun run scripts/gate.ts` | PASSED |
557
+ | `bun run scripts/verify-dll.ts` | PASSED, same source as `native/shell.cc` (1,016,832 B) |
558
+ | `scripts/probe-window-events.ts` | PASSED (21 checks) |
559
+ | `scripts/probe-accelerator-keys.ts` | PASSED (6 checks) |
560
+ | `scripts/probe-drop.ts` | PASSED (6 checks) |
561
+ | `scripts/probe-drop-live.ts --file` | **PASSED (12 checks)** — one real file drag, absolute path |
562
+ | `scripts/probe-drop-live.ts --dir` | **PASSED (12 checks)** — one real folder drag, path **and** folder classification |
304
563
 
305
564
  ## [0.5.0] - 2026-10-21
306
565
 
@@ -308,13 +567,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
308
567
 
309
568
  ### Fixed
310
569
 
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`.
570
+ - **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
571
 
313
572
  The bug is easy to misdiagnose, because `PostMessage(hwnd, WM_KEYDOWN, VK_F11, 0)` toggles fullscreen correctly — that experiment simply bypasses what broke.
314
573
 
315
574
  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
575
 
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*.
576
+ - **`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
577
 
319
578
  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
579
 
@@ -337,7 +596,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
337
596
 
338
597
  - **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
598
 
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.
599
+ `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
600
 
342
601
  - **`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
602
 
@@ -351,17 +610,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
351
610
 
352
611
  ### Verification
353
612
 
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` |
613
+ | Command | Result |
614
+ | ----------------------------------- | ----------------------------------------------------- |
615
+ | `bun check` | 68 files |
616
+ | `bun test` | 556 pass / 0 fail |
617
+ | `bun run scripts/gate.ts` | 4 stages, PASSED |
618
+ | `bun run scripts/gate.ts --clean` | PASSED (clean) |
619
+ | `bun run scripts/verify-dll.ts` | PASSED, `dist/libshell.dll` matches `native/shell.cc` |
620
+ | `scripts/probe-window-events.ts` | 21 checks |
621
+ | `scripts/probe-accelerator-keys.ts` | 6 checks |
622
+ | `scripts/probe-drop.ts` | 6 checks |
623
+ | shape B in an empty directory | exit 0, `source=embedded`, `reused=true` |
365
624
 
366
625
  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
626
 
@@ -427,7 +686,7 @@ Dragging a real file into a window to confirm the page sees the **full path**. A
427
686
 
428
687
  Four decisions, each with a failure mode behind it:
429
688
 
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.
689
+ **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
690
 
432
691
  **`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
692
 
@@ -475,7 +734,7 @@ Dragging a real file into a window to confirm the page sees the **full path**. A
475
734
 
476
735
  - 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
736
 
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.
737
+ - 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
738
 
480
739
  ## [0.3.0] - 2026-10-21
481
740
 
@@ -491,7 +750,7 @@ Dragging a real file into a window to confirm the page sees the **full path**. A
491
750
 
492
751
  - **`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
752
  - 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.
753
+ - **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
754
 
496
755
  ### Added
497
756
 
@@ -509,37 +768,39 @@ Dragging a real file into a window to confirm the page sees the **full path**. A
509
768
 
510
769
  - **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
770
  - **`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*.
771
+ - 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
772
 
514
773
  ### Added
515
774
 
516
775
  - `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
776
 
518
- | route | ms | bytes |
519
- | --- | --- | --- |
777
+ | route | ms | bytes |
778
+ | ------------------------------------- | -------- | ----------- |
520
779
  | **our `downscaleBgra` + our encoder** | **45.9** | **102,036** |
521
- | `Bun.Image` resize + png | 95.9 | 222,621 |
780
+ | `Bun.Image` resize + png | 95.9 | 222,621 |
522
781
 
523
782
  **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.
783
+
524
784
  - `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
785
  - `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
786
  - `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
787
  - 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.
788
+ - 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
789
 
530
790
  ### Changed
531
791
 
532
792
  - `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
793
 
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 |
794
+ | command | bytes | end-to-end ms |
795
+ | ------------------------ | ---------- | ------------- |
796
+ | `--level 1` | 704,353 | 148 |
797
+ | default (level 6) | 439,347 | 170 |
798
+ | `--level 9` | 411,366 | 330 |
799
+ | `--crop 700,300,800,600` | **50,836** | 170 |
540
800
 
541
801
  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.**
802
+
803
+ - 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
804
 
544
805
  ### Note
545
806
 
@@ -685,14 +946,16 @@ First public release.
685
946
  ### Known limitations
686
947
 
687
948
  - 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.
949
+ - TypeScript sources are published, not compiled. Consumers need Bun 1.4.4 or newer. Installing under Node succeeds but nothing runs.
689
950
  - `bun:ffi` is marked experimental in Bun, and the entire native boundary depends on it.
690
951
  - `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
952
  - 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
953
  - Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
693
954
  - No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
694
955
 
695
- [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.7.0...HEAD
956
+ [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.9.0...HEAD
957
+ [0.9.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.8.0...v0.9.0
958
+ [0.8.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.7.0...v0.8.0
696
959
  [0.7.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.2...v0.7.0
697
960
  [0.6.2]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.1...v0.6.2
698
961
  [0.6.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.0...v0.6.1