max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
4 files changed,
+163 insertions,
-31 deletions
| @@ -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 | + | })(); |