nandi/frqpublic Fork 0
c9d7b92
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

A root that keeps its shape, so a hover stops moving the room

`render-root` returned a bare tree with no dialog and a Stack with one, so
putting a hover card up changed the root's SHAPE — one child, then two — and
Flutter rebuilds a subtree rather than updating it when the shape above it
changes. Two complaints came out of that, and both read as faults of the card.

The `:scroll` widget's `:managed` ScrollController is state, so it was
disposed and remade with a fresh offset: resting on a reaction threw the
reader back up the backlog. Which is the hazard that widget's own comment
warns about, arrived at from the other side.

And the MouseRegion under the pointer was remade with it, so its `onExit`
never fired — the pointer left a widget that had ceased to exist, the unhover
never ran, and the card stayed up. That one only became reliable once avatars
started loading, because every one that lands calls `bump!`.

The second layer is built either way now; with nothing to show it is an empty
box that hears nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-17T02:16:13-07:00 Browse files
c9d7b92 parent: 1a1f153
modified flutter/src/frq/hiccup.cljd +58 -9
@@ -47,6 +47,32 @@
4747 (let [w (:width-request p)]
4848 (when (and (number? w) (pos? w)) (double w))))
4949
50+;; How a network image URL is reached from here. Identity, unless told
51+;; otherwise.
52+;;
53+;; The web needs telling. `cdn.bsky.app` serves avatars with no
54+;; `Access-Control-Allow-Origin`, and Flutter loads images over XHR in a
55+;; browser so the bytes arrive and are thrown away for want of a header the
56+;; far side does not send. The deployed build answers by fetching them through
57+;; its own origin; `frq.main-web` installs that, and the desktop and the APK
58+;; never call it because neither is subject to CORS at all.
59+;;
60+;; An atom in the renderer rather than a seam in `frq.io`: nothing under
61+;; `common/` renders an image, and the two call sites are both here.
62+;;
63+;; A comment and not a docstring, for the reason `frq.net.dart/wire-log?`
64+;; gives: `defonce` is `(defonce name expr)` and nothing else, and the extra
65+;; form is an arity error reported against the namespace rather than the line.
66+(defonce image-url (atom identity))
67+
68+(defn- src-url
69+ "`src` as this platform can actually load it."
70+ [src]
71+ (let [s (str src)]
72+ (if (or (.startsWith s "http://") (.startsWith s "https://"))
73+ (str (@image-url s))
74+ s)))
75+
5076 (defn pointer?
5177 "Whether there is a mouse here rather than a finger.
5278
@@ -1126,7 +1152,7 @@
11261152 bg (when (and src (not= "" src))
11271153 (if (or (.startsWith (str src) "http://")
11281154 (.startsWith (str src) "https://"))
1129- (m/NetworkImage (str src))
1155+ (m/NetworkImage (src-url src))
11301156 (m/FileImage (io/File. (str src)))))
11311157 face (m/CircleAvatar
11321158 .radius (/ s 2.0)
@@ -1173,7 +1199,7 @@
11731199 .errorBuilder oops)
11741200 (or (.startsWith (str src) "http://")
11751201 (.startsWith (str src) "https://"))
1176- (m/Image.network (str src) .fit m/BoxFit.contain
1202+ (m/Image.network (src-url src) .fit m/BoxFit.contain
11771203 .errorBuilder oops)
11781204 :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain
11791205 .errorBuilder oops))
@@ -1330,9 +1356,27 @@
13301356 (if-not (pointer?)
13311357 (render node)
13321358 (let [[tree dialog] (lift-dialog node)]
1333- (if (nil? dialog)
1334- (render tree)
1335- (let [modal (boolean (:modal (props dialog)))]
1359+ ;; The Stack is built whether or not there is a dialog, and that is the
1360+ ;; whole of this. Returning a bare `(render tree)` when there is none
1361+ ;; meant the root changed SHAPE the moment a hover put a card up one
1362+ ;; child, then two and Flutter rebuilds a subtree rather than updating
1363+ ;; it when the shape under a slot changes. Two things went with it, and
1364+ ;; both were blamed on the card:
1365+ ;;
1366+ ;; The scroll view's `:managed` ScrollController is state, so it was
1367+ ;; disposed and remade with a fresh offset: resting on a reaction threw
1368+ ;; the reader back up the backlog. The same hazard the `:managed`
1369+ ;; comment on `:scroll` warns about, arrived at from the other side.
1370+ ;;
1371+ ;; And the MouseRegion under the pointer was remade too, so its `onExit`
1372+ ;; never fired the pointer left a widget that no longer existed, the
1373+ ;; unhover never ran, and the card stayed up for ever. It got worse the
1374+ ;; moment avatars started loading, because every one that lands calls
1375+ ;; `bump!` and rebuilds the tree.
1376+ ;;
1377+ ;; So the second layer is always there; with nothing to show it is an
1378+ ;; empty box that hears nothing.
1379+ (let [modal (boolean (and dialog (:modal (props dialog))))]
13361380 (m/Stack
13371381 ;; Expand, and not the default loose fit: a Stack hands its
13381382 ;; unpositioned children the parent's constraints *loosened*, so
@@ -1350,7 +1394,7 @@
13501394 .color (if modal
13511395 (m/Color.fromRGBO 0 0 0 0.45)
13521396 (m/Color.fromRGBO 0 0 0 0.0)))]
1353- (if modal
1397+ (if (and modal (some? dialog))
13541398 ;; Absorbing and not merely painting: a press on the dimmed
13551399 ;; half of the window is a press meant for the card.
13561400 (m/GestureDetector .onTap (fn [] nil) .child scrim)
@@ -1361,6 +1405,11 @@
13611405 ;; whole window, faces included. The card does its own scrolling.
13621406 (m/Positioned.fill
13631407 .child
1364- (m/Padding
1365- .padding (m/EdgeInsets.all t/space-s)
1366- .child (m/Center .child (render dialog))))]))))))
1408+ (if (nil? dialog)
1409+ ;; Nothing to show, and nothing to hear: an empty layer that
1410+ ;; keeps the shape constant so the screens below are updated
1411+ ;; rather than rebuilt.
1412+ (m/IgnorePointer .child (m/SizedBox.shrink))
1413+ (m/Padding
1414+ .padding (m/EdgeInsets.all t/space-s)
1415+ .child (m/Center .child (render dialog)))))])))))
@@ -47,6 +47,32 @@
47 (let [w (:width-request p)]47 (let [w (:width-request p)]
48 (when (and (number? w) (pos? w)) (double w))))48 (when (and (number? w) (pos? w)) (double w))))
49 49
50+;; How a network image URL is reached from here. Identity, unless told
51+;; otherwise.
52+;;
53+;; The web needs telling. `cdn.bsky.app` serves avatars with no
54+;; `Access-Control-Allow-Origin`, and Flutter loads images over XHR in a
55+;; browser so the bytes arrive and are thrown away for want of a header the
56+;; far side does not send. The deployed build answers by fetching them through
57+;; its own origin; `frq.main-web` installs that, and the desktop and the APK
58+;; never call it because neither is subject to CORS at all.
59+;;
60+;; An atom in the renderer rather than a seam in `frq.io`: nothing under
61+;; `common/` renders an image, and the two call sites are both here.
62+;;
63+;; A comment and not a docstring, for the reason `frq.net.dart/wire-log?`
64+;; gives: `defonce` is `(defonce name expr)` and nothing else, and the extra
65+;; form is an arity error reported against the namespace rather than the line.
66+(defonce image-url (atom identity))
67+
68+(defn- src-url
69+ "`src` as this platform can actually load it."
70+ [src]
71+ (let [s (str src)]
72+ (if (or (.startsWith s "http://") (.startsWith s "https://"))
73+ (str (@image-url s))
74+ s)))
75+
50 (defn pointer?76 (defn pointer?
51 "Whether there is a mouse here rather than a finger.77 "Whether there is a mouse here rather than a finger.
52 78
@@ -1126,7 +1152,7 @@
1126 bg (when (and src (not= "" src))1152 bg (when (and src (not= "" src))
1127 (if (or (.startsWith (str src) "http://")1153 (if (or (.startsWith (str src) "http://")
1128 (.startsWith (str src) "https://"))1154 (.startsWith (str src) "https://"))
1129- (m/NetworkImage (str src))1155+ (m/NetworkImage (src-url src))
1130 (m/FileImage (io/File. (str src)))))1156 (m/FileImage (io/File. (str src)))))
1131 face (m/CircleAvatar1157 face (m/CircleAvatar
1132 .radius (/ s 2.0)1158 .radius (/ s 2.0)
@@ -1173,7 +1199,7 @@
1173 .errorBuilder oops)1199 .errorBuilder oops)
1174 (or (.startsWith (str src) "http://")1200 (or (.startsWith (str src) "http://")
1175 (.startsWith (str src) "https://"))1201 (.startsWith (str src) "https://"))
1176- (m/Image.network (str src) .fit m/BoxFit.contain1202+ (m/Image.network (src-url src) .fit m/BoxFit.contain
1177 .errorBuilder oops)1203 .errorBuilder oops)
1178 :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain1204 :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain
1179 .errorBuilder oops))1205 .errorBuilder oops))
@@ -1330,9 +1356,27 @@
1330 (if-not (pointer?)1356 (if-not (pointer?)
1331 (render node)1357 (render node)
1332 (let [[tree dialog] (lift-dialog node)]1358 (let [[tree dialog] (lift-dialog node)]
1333- (if (nil? dialog)1359+ ;; The Stack is built whether or not there is a dialog, and that is the
1334- (render tree)1360+ ;; whole of this. Returning a bare `(render tree)` when there is none
1335- (let [modal (boolean (:modal (props dialog)))]1361+ ;; meant the root changed SHAPE the moment a hover put a card up one
1362+ ;; child, then two and Flutter rebuilds a subtree rather than updating
1363+ ;; it when the shape under a slot changes. Two things went with it, and
1364+ ;; both were blamed on the card:
1365+ ;;
1366+ ;; The scroll view's `:managed` ScrollController is state, so it was
1367+ ;; disposed and remade with a fresh offset: resting on a reaction threw
1368+ ;; the reader back up the backlog. The same hazard the `:managed`
1369+ ;; comment on `:scroll` warns about, arrived at from the other side.
1370+ ;;
1371+ ;; And the MouseRegion under the pointer was remade too, so its `onExit`
1372+ ;; never fired the pointer left a widget that no longer existed, the
1373+ ;; unhover never ran, and the card stayed up for ever. It got worse the
1374+ ;; moment avatars started loading, because every one that lands calls
1375+ ;; `bump!` and rebuilds the tree.
1376+ ;;
1377+ ;; So the second layer is always there; with nothing to show it is an
1378+ ;; empty box that hears nothing.
1379+ (let [modal (boolean (and dialog (:modal (props dialog))))]
1336 (m/Stack1380 (m/Stack
1337 ;; Expand, and not the default loose fit: a Stack hands its1381 ;; Expand, and not the default loose fit: a Stack hands its
1338 ;; unpositioned children the parent's constraints *loosened*, so1382 ;; unpositioned children the parent's constraints *loosened*, so
@@ -1350,7 +1394,7 @@
1350 .color (if modal1394 .color (if modal
1351 (m/Color.fromRGBO 0 0 0 0.45)1395 (m/Color.fromRGBO 0 0 0 0.45)
1352 (m/Color.fromRGBO 0 0 0 0.0)))]1396 (m/Color.fromRGBO 0 0 0 0.0)))]
1353- (if modal1397+ (if (and modal (some? dialog))
1354 ;; Absorbing and not merely painting: a press on the dimmed1398 ;; Absorbing and not merely painting: a press on the dimmed
1355 ;; half of the window is a press meant for the card.1399 ;; half of the window is a press meant for the card.
1356 (m/GestureDetector .onTap (fn [] nil) .child scrim)1400 (m/GestureDetector .onTap (fn [] nil) .child scrim)
@@ -1361,6 +1405,11 @@
1361 ;; whole window, faces included. The card does its own scrolling.1405 ;; whole window, faces included. The card does its own scrolling.
1362 (m/Positioned.fill1406 (m/Positioned.fill
1363 .child1407 .child
1364- (m/Padding1408+ (if (nil? dialog)
1365- .padding (m/EdgeInsets.all t/space-s)1409+ ;; Nothing to show, and nothing to hear: an empty layer that
1366- .child (m/Center .child (render dialog))))]))))))1410+ ;; keeps the shape constant so the screens below are updated
1411+ ;; rather than rebuilt.
1412+ (m/IgnorePointer .child (m/SizedBox.shrink))
1413+ (m/Padding
1414+ .padding (m/EdgeInsets.all t/space-s)
1415+ .child (m/Center .child (render dialog)))))])))))
modified flutter/src/frq/main_web.cljd +19 -1
@@ -27,7 +27,9 @@
2727 [frq.io.web :as host]
2828 [frq.net.web :as net-web]
2929 [frq.oauth.web :as oauth-web]
30- [frq.atproto.web :as atproto-web]))
30+ [frq.atproto.web :as atproto-web]
31+ ["dart:html" :as html]
32+ [frq.hiccup :as hiccup]))
3133
3234 (defn ^:async main []
3335 (app/bind!)
@@ -41,6 +43,22 @@
4143 ;; the OAuth one above it, and unlike freeq's broker, nothing in it cares
4244 ;; which origin this build is served from.
4345 (atproto-web/install!)
46+ ;; Images through our own origin. A browser refuses bytes that arrive with
47+ ;; no `Access-Control-Allow-Origin`, and neither `cdn.bsky.app` (avatars)
48+ ;; nor freeq's /api/v1/media (pasted pictures) sends one — so the fetch
49+ ;; succeeds, the header is missing, and Flutter is handed nothing. The
50+ ;; server this build is served from proxies both and answers with the
51+ ;; header; see PROXY_HOSTS in `.modal/flutter-web/serve.py`.
52+ ;;
53+ ;; Left alone: anything already on this origin, which needs no help and
54+ ;; would otherwise be fetched through a round trip to itself.
55+ (let [origin (str (.-origin (.-location html/window)))]
56+ (reset! hiccup/image-url
57+ (fn [url]
58+ (let [u (str url)]
59+ (if (.startsWith u origin)
60+ u
61+ (str origin "/proxy?url=" (Uri.encodeComponent u)))))))
4462 ;; Two questions with one answer: did we just come back from the
4563 ;; authorization server, and failing that was there a sign-in before today.
4664 ;; Either way what lands is a session in `frq.cells`, which is what
@@ -27,7 +27,9 @@
27 [frq.io.web :as host]27 [frq.io.web :as host]
28 [frq.net.web :as net-web]28 [frq.net.web :as net-web]
29 [frq.oauth.web :as oauth-web]29 [frq.oauth.web :as oauth-web]
30- [frq.atproto.web :as atproto-web]))30+ [frq.atproto.web :as atproto-web]
31+ ["dart:html" :as html]
32+ [frq.hiccup :as hiccup]))
31 33
32 (defn ^:async main []34 (defn ^:async main []
33 (app/bind!)35 (app/bind!)
@@ -41,6 +43,22 @@
41 ;; the OAuth one above it, and unlike freeq's broker, nothing in it cares43 ;; the OAuth one above it, and unlike freeq's broker, nothing in it cares
42 ;; which origin this build is served from.44 ;; which origin this build is served from.
43 (atproto-web/install!)45 (atproto-web/install!)
46+ ;; Images through our own origin. A browser refuses bytes that arrive with
47+ ;; no `Access-Control-Allow-Origin`, and neither `cdn.bsky.app` (avatars)
48+ ;; nor freeq's /api/v1/media (pasted pictures) sends one — so the fetch
49+ ;; succeeds, the header is missing, and Flutter is handed nothing. The
50+ ;; server this build is served from proxies both and answers with the
51+ ;; header; see PROXY_HOSTS in `.modal/flutter-web/serve.py`.
52+ ;;
53+ ;; Left alone: anything already on this origin, which needs no help and
54+ ;; would otherwise be fetched through a round trip to itself.
55+ (let [origin (str (.-origin (.-location html/window)))]
56+ (reset! hiccup/image-url
57+ (fn [url]
58+ (let [u (str url)]
59+ (if (.startsWith u origin)
60+ u
61+ (str origin "/proxy?url=" (Uri.encodeComponent u)))))))
44 ;; Two questions with one answer: did we just come back from the62 ;; Two questions with one answer: did we just come back from the
45 ;; authorization server, and failing that was there a sign-in before today.63 ;; authorization server, and failing that was there a sign-in before today.
46 ;; Either way what lands is a session in `frq.cells`, which is what64 ;; Either way what lands is a session in `frq.cells`, which is what