Skip to main content

max / goingson

Take quasi 0.80, and serve quasi-focus.js The shell links every script the renderer ships unless the host says otherwise, and this host says otherwise about none of them.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 18:22 UTC
Signed with PGP, not checked
Commit: f1f7ad57b561d60d713705e558fb6be49c1d58b2
Parent: 0e2cd56
4 files changed, +163 insertions, -31 deletions
M Cargo.lock +25 -25
@@ -1387,7 +1387,7 @@
1387 1387 "libc",
1388 1388 "option-ext",
1389 1389 "redox_users",
1390 - "windows-sys 0.61.2",
1390 + "windows-sys 0.60.2",
1391 1391 ]
1392 1392
1393 1393 [[package]]
@@ -1628,7 +1628,7 @@
1628 1628 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1629 1629 dependencies = [
1630 1630 "libc",
1631 - "windows-sys 0.61.2",
1631 + "windows-sys 0.60.2",
1632 1632 ]
1633 1633
1634 1634 [[package]]
@@ -3650,7 +3650,7 @@
3650 3650 "png 0.18.1",
3651 3651 "serde",
3652 3652 "thiserror 2.0.20",
3653 - "windows-sys 0.61.2",
3653 + "windows-sys 0.60.2",
3654 3654 ]
3655 3655
3656 3656 [[package]]
@@ -3783,7 +3783,7 @@
3783 3783 source = "registry+https://github.com/rust-lang/crates.io-index"
3784 3784 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3785 3785 dependencies = [
3786 - "windows-sys 0.61.2",
3786 + "windows-sys 0.60.2",
3787 3787 ]
3788 3788
3789 3789 [[package]]
@@ -4717,7 +4717,7 @@
4717 4717
4718 4718 [[package]]
4719 4719 name = "quasi-http"
4720 - version = "0.79.0"
4720 + version = "0.80.0"
4721 4721 dependencies = [
4722 4722 "form_urlencoded",
4723 4723 "http",
@@ -4726,7 +4726,7 @@
4726 4726
4727 4727 [[package]]
4728 4728 name = "quasi-notifs"
4729 - version = "0.79.0"
4729 + version = "0.80.0"
4730 4730 dependencies = [
4731 4731 "quasi-router",
4732 4732 "synckit-config",
@@ -4734,14 +4734,14 @@
4734 4734
4735 4735 [[package]]
4736 4736 name = "quasi-router"
4737 - version = "0.79.0"
4737 + version = "0.80.0"
4738 4738 dependencies = [
4739 4739 "makeover-layout",
4740 4740 ]
4741 4741
4742 4742 [[package]]
4743 4743 name = "quasi-tauri"
4744 - version = "0.79.0"
4744 + version = "0.80.0"
4745 4745 dependencies = [
4746 4746 "http",
4747 4747 "quasi-http",
@@ -4767,7 +4767,7 @@
4767 4767
4768 4768 [[package]]
4769 4769 name = "quasi-webview"
4770 - version = "0.79.0"
4770 + version = "0.80.0"
4771 4771 dependencies = [
4772 4772 "docengine",
4773 4773 "makeover-layout",
@@ -5206,7 +5206,7 @@
5206 5206 "errno",
5207 5207 "libc",
5208 5208 "linux-raw-sys",
5209 - "windows-sys 0.61.2",
5209 + "windows-sys 0.60.2",
5210 5210 ]
5211 5211
5212 5212 [[package]]
@@ -5263,7 +5263,7 @@
5263 5263 "security-framework",
5264 5264 "security-framework-sys",
5265 5265 "webpki-root-certs",
5266 - "windows-sys 0.61.2",
5266 + "windows-sys 0.60.2",
5267 5267 ]
5268 5268
5269 5269 [[package]]
@@ -5803,7 +5803,7 @@
5803 5803 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
5804 5804 dependencies = [
5805 5805 "libc",
5806 - "windows-sys 0.61.2",
5806 + "windows-sys 0.60.2",
5807 5807 ]
5808 5808
5809 5809 [[package]]
@@ -6524,7 +6524,7 @@
6524 6524 "serde_with",
6525 6525 "swift-rs",
6526 6526 "thiserror 2.0.20",
6527 - "toml 0.9.12+spec-1.1.0",
6527 + "toml 1.1.4+spec-1.1.0",
6528 6528 "url",
6529 6529 "urlpattern",
6530 6530 "uuid",
@@ -6563,7 +6563,7 @@
6563 6563 "getrandom 0.4.3",
6564 6564 "once_cell",
6565 6565 "rustix",
6566 - "windows-sys 0.61.2",
6566 + "windows-sys 0.60.2",
6567 6567 ]
6568 6568
6569 6569 [[package]]
@@ -6585,7 +6585,7 @@
6585 6585 "parking_lot",
6586 6586 "rustix",
6587 6587 "signal-hook",
6588 - "windows-sys 0.61.2",
6588 + "windows-sys 0.60.2",
6589 6589 ]
6590 6590
6591 6591 [[package]]
@@ -7067,7 +7067,7 @@
7067 7067 "png 0.18.1",
7068 7068 "serde",
7069 7069 "thiserror 2.0.20",
7070 - "windows-sys 0.61.2",
7070 + "windows-sys 0.60.2",
7071 7071 ]
7072 7072
7073 7073 [[package]]
@@ -7102,7 +7102,7 @@
7102 7102 dependencies = [
7103 7103 "memoffset",
7104 7104 "tempfile",
7105 - "windows-sys 0.61.2",
7105 + "windows-sys 0.60.2",
7106 7106 ]
7107 7107
7108 7108 [[package]]
@@ -7632,7 +7632,7 @@
7632 7632 source = "registry+https://github.com/rust-lang/crates.io-index"
7633 7633 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
7634 7634 dependencies = [
7635 - "windows-sys 0.61.2",
7635 + "windows-sys 0.60.2",
7636 7636 ]
7637 7637
7638 7638 [[package]]
@@ -8489,22 +8489,22 @@
8489 8489 "winnow 1.0.4",
8490 8490 ]
8491 8491
8492 - [[patch.unused]]
8493 - name = "ops-status"
8494 - version = "0.1.0"
8495 -
8496 8492 [[patch.unused]]
8497 8493 name = "quasi-axum"
8498 - version = "0.79.0"
8494 + version = "0.80.0"
8499 8495
8500 8496 [[patch.unused]]
8501 8497 name = "quasi-basics"
8502 - version = "0.79.0"
8498 + version = "0.80.0"
8503 8499
8504 8500 [[patch.unused]]
8505 8501 name = "quasi-immediate"
8506 - version = "0.79.0"
8502 + version = "0.80.0"
8507 8503
8508 8504 [[patch.unused]]
8509 8505 name = "quasi-store"
8510 8506 version = "0.1.0"
8507 +
8508 + [[patch.unused]]
8509 + name = "ops-status"
8510 + version = "0.1.0"
@@ -36,7 +36,7 @@
36 36 makeover-geometry = "0.7"
37 37 # Only to write its selection and clock scripts into the frontend. Those two are
38 38 # the whole of `frontend/js/` since the swap.
39 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
39 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
40 40
41 41 [dependencies]
42 42 goingson-core = { workspace = true }
@@ -54,10 +54,10 @@
54 54 # dependency's source even with the feature off -- so a public clone could not
55 55 # build at all against a URL it could not reach. quasi went public that day and
56 56 # the URL moved here with it.
57 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
58 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
59 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
60 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
57 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
58 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
59 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
60 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
61 61
62 62 # The notification declaration, and it is NOT behind the `quasi` feature: the
63 63 # watcher is desktop app behaviour rather than a described screen, and the
@@ -74,7 +74,7 @@
74 74 # before it has ported its screens cannot take it. `notifications::Notifier` is
75 75 # the eight lines meanwhile, and it goes when the flip makes quasi-router
76 76 # unconditional here.
77 - quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.79", features = ["synckit", "describe"] }
77 + quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.80", features = ["synckit", "describe"] }
78 78
79 79 # Tauri
80 80 tauri = { workspace = true, features = ["image-png", "devtools"] }
@@ -617,6 +617,7 @@
617 617 ("quasi-repeat.js", quasi_webview::REPEAT_JS),
618 618 ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
619 619 ("quasi-instant.js", quasi_webview::INSTANT_JS),
620 + ("quasi-focus.js", quasi_webview::FOCUS_JS),
620 621 ("quasi-menu.js", quasi_webview::MENU_JS),
621 622 ("quasi-outline.js", quasi_webview::OUTLINE_JS),
622 623 ] {
@@ -1,0 +1,131 @@
1 + // Keeping the reader's place in the tab order across a swap htmx does not
2 + // restore focus for.
3 + //
4 + // htmx saves and restores focus around the *main* swap target, and only that
5 + // one. A focused control inside an out-of-band region is destroyed outside that
6 + // window, so focus falls to `body`: the caret vanishes, and the next Tab starts
7 + // from the top of the document rather than from where the reader was. This
8 + // renderer emits out-of-band swaps -- `node::oob_html`, which is how a response
9 + // updates a second region it did not target -- so it owns the gap.
10 + //
11 + // The failure is silent to anyone not navigating by keyboard, which is the
12 + // reason to fix it rather than leave it: nobody will report it.
13 + //
14 + // # Why it triggers on focus actually being lost
15 + //
16 + // The obvious shape is "record what was focused inside a region about to be
17 + // replaced, put it back afterwards". This does that, with one narrowing: it
18 + // only restores when focus has genuinely fallen to `body` or out of the
19 + // document. htmx already restores focus for the main target, correctly and
20 + // with its own record of the caret, and a second party putting focus somewhere
21 + // after it would be two scripts fighting over the same frame. So the record is
22 + // taken from anything focused, and spent only where htmx left nothing.
23 + (() => {
24 + "use strict";
25 +
26 + // What was focused when the request went out, and where its caret was.
27 + // One slot: a document makes one focused element, and a second request
28 + // starting before the first settles is the reader having moved on.
29 + let held = null;
30 +
31 + /**
32 + * How to find this element again in markup that has replaced it.
33 + *
34 + * An `id` is the description's own address for the thing and survives a
35 + * re-render, so it is preferred. A `name` is the next best: a form control
36 + * that carries one is being identified by the description too, since that
37 + * is the key the route reads it under. Anything with neither is not
38 + * addressable and is left alone -- guessing at a neighbour by position is
39 + * how focus lands on the wrong control, which is worse than losing it.
40 + */
41 + const address = (element) => {
42 + if (element.id) return `#${CSS.escape(element.id)}`;
43 + const name = element.getAttribute?.("name");
44 + if (name) {
45 + const tag = element.tagName.toLowerCase();
46 + return `${tag}[name="${CSS.escape(name)}"]`;
47 + }
48 + return null;
49 + };
50 +
51 + /**
52 + * The caret, for a control that has one.
53 + *
54 + * `selectionStart` throws on input types that have no text selection
55 + * (`email`, `number`, and others depending on the browser), which is a
56 + * `DOMException` rather than a `null`, so this is guarded rather than
57 + * checked: the list of which types support it differs between engines and
58 + * hard-coding it here would be a second thing to keep in step.
59 + */
60 + const caret = (element) => {
61 + try {
62 + const { selectionStart, selectionEnd, selectionDirection } = element;
63 + if (selectionStart === null || selectionStart === undefined) return null;
64 + return { selectionStart, selectionEnd, selectionDirection };
65 + } catch {
66 + return null;
67 + }
68 + };
69 +
70 + /** Put a caret back, ignoring a control that has stopped accepting one. */
71 + const restoreCaret = (element, at) => {
72 + if (!at) return;
73 + try {
74 + element.setSelectionRange(
75 + at.selectionStart,
76 + at.selectionEnd,
77 + at.selectionDirection ?? "none",
78 + );
79 + } catch {
80 + // The replacement is a different kind of control than the one that
81 + // was there. Focus is still right; the caret is not applicable.
82 + }
83 + };
84 +
85 + // Recorded on the way out rather than on the swap: by the time a swap
86 + // fires, an out-of-band region may already have been replaced and the
87 + // element read from `document.activeElement` would be detached, with its
88 + // caret gone with it.
89 + //
90 + // `htmx:before:request` is htmx 4's name; 2.x spelled it
91 + // `htmx:beforeRequest`.
92 + const remember = () => {
93 + const active = document.activeElement;
94 + if (!active || active === document.body) {
95 + held = null;
96 + return;
97 + }
98 + const selector = address(active);
99 + held = selector ? { selector, at: caret(active) } : null;
100 + };
101 +
102 + // Spent after everything has settled, including out-of-band regions, and
103 + // only where focus was actually lost. `htmx:after:settle` is htmx 4's name;
104 + // 2.x spelled it `htmx:afterSettle`.
105 + const restore = () => {
106 + const record = held;
107 + held = null;
108 + if (!record) return;
109 +
110 + // htmx put focus somewhere itself, which is the main target's case and
111 + // is already right. Leave it alone.
112 + const active = document.activeElement;
113 + if (active && active !== document.body) return;
114 +
115 + const found = document.querySelector(record.selector);
116 + // No match means the control the reader was in is genuinely gone --
117 + // a row they deleted, a field a reveal took away. Focus stays where
118 + // htmx left it rather than being moved somewhere arbitrary.
119 + if (!found || typeof found.focus !== "function") return;
120 +
121 + // `preventScroll`, because the reader has not asked to go anywhere: the
122 + // point is that nothing visible changed for them. Restoring focus and
123 + // jumping the viewport to it would be a second surprise on top of the
124 + // one being fixed.
125 + found.focus({ preventScroll: true });
126 + restoreCaret(found, record.at);
127 + };
128 +
129 + document.addEventListener("htmx:before:request", remember);
130 + document.addEventListener("htmx:after:settle", restore);
131 + })();