@gotcos/glasses-server 7.0.30 → 7.0.31

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
@@ -1,3 +1,47 @@
1
+ ## 7.0.31 - Control Web: a message's text no longer runs under and below Reply on a wide screen
2
+
3
+ 2026-11-12. A hotfix to 7.0.30, nothing else in it.
4
+
5
+ - **7.0.30 shipped this fault.** On a wide screen (the open Duo, an iPad, a desktop browser), a message's text could run behind and below the pinned Reply box. The top bar was gone, and the left rail started partway down its list and ended partway down the screen. Miles saw it on the open Duo at 17:25 and could not make it happen again. It came from 7.0.30's Reply staying at the bottom of the screen.
6
+ - **The cause.** The message reader was the whole page. Its Reply box was pinned to Safari's idea of the screen, and the rail was as tall as that idea. On his Duo, typing a reply moved Safari's view about 409 px down the page, as a keyboard does, so the field stays in sight. After the keyboard closed, he folded and unfolded the Duo five times. Safari then reported its visible area as 0 px tall, then as a large negative number, with its top still about 409 px down. The view stayed shifted, and Reply and the rail stopped about halfway down the screen, with the rest of the message painted below them. It needs that keyboard-then-fold sequence, which is why it came and went. No part of the page was too tall for its box.
7
+ - **The fix.** From 600 px wide, an open message is now one screen, the way Sessions already is. The rail and the area under the top bar each scroll by themselves, and the page itself no longer scrolls. The sign-in notice, a question card and the message still scroll together, as in 7.0.30, and Reply stays at the bottom of that area. At the end of a message, the Reply section sits just above the Reply box, with nothing under it. With the keyboard up, the box sits above the keyboard as before. The screen's height comes from the page's own measurement, which falls back to the window's height when Safari's reading is broken.
8
+ - **What 7.0.31 does and does not fix.**
9
+ - **Fixed:** it stops the message showing behind and below Reply.
10
+ - **Not fixed:** Safari leaving the page pushed down after the keyboard and a fold. If that happens again, the app can sit shifted, with a blank band at the bottom of the screen and the top bar out of sight. It returns to the full screen when Safari's view moves back, for example on the next fold, rotation or keyboard. In a browser test, the page refilled the screen after a resize, with no reload.
11
+ - **Not done:** the page does not follow Safari's reported position. On his Duo that reading was 40 to 85 px off while the height read 0.
12
+ - **What's next:** we now record Safari's readings on every message opened, every fold and every keyboard close, to find out how often it happens.
13
+ - **Seen once in the readings we have:** today, from 07:42, the only stranded push came after a keyboard in the message reader. After five keyboard uses in Sessions and one in the COS chat, which were already one screen, every following reading was back at the top, through folds that broke the height readings the same way. One day of readings proves nothing either way.
14
+ - **On the open Duo with a question card showing**, a short message's Reply box starts just below the bottom of the screen, as in 7.0.30. The page stays one screen, and scrolling the message brings Reply up.
15
+ - **Folding or unfolding while reading keeps your place.** A fold or an unfold moves the message between the page's own scroll (narrow) and the framed area (wide). On its own the new view would open at the top, so the line you were reading is put back at the top, once, after the fold. It waits until your finger is off the screen and the page is still. If you scroll first, your scroll wins and it does nothing. Nothing else moves the text: not the keyboard, not Safari's bars, not a question card or notice appearing.
16
+ - **Phones and the folded Duo otherwise unchanged.** Under 600 px, with no fold, the message reader works as in 7.0.30, with the side rail on the folded Duo.
17
+ - **The Reply section stays.** The "Reply. Continues this conversation" block above the box is where replies sent from that message appear. It is not a second Reply box.
18
+ - **The rest of the app in the same Safari state** (Today, the Messages list, Meetings, Speakers, Work and Settings, at 600 px and wider): no pinned part has text under it, but the rail can end partway down with the page continuing below it. Sessions already stays inside one screen. Those panes are not changed in this hotfix.
19
+ - **New numbers in the page's own layout reports**, numbers only, to the Mac's diagnostics log:
20
+ - the height of 100dvh at each fold;
21
+ - for every message opened, at any width: the visible area, the message's box, the scrolling area's height and content height, where Reply sits, the page's height and scroll, and 100dvh. The page already sends the window's size and Safari's visible area with each report, so a shifted view shows as its top.
22
+ The 17:25 frame could not be measured; the next one will be.
23
+ - **Tested in a browser (WebKit)**, in light and dark:
24
+ - **His two Safari states, recreated:** the view shifted 409 px, and a window 430 px tall drawn 787 px tall. 7.0.30 paints text below Reply in both; this build paints none.
25
+ - **A long message at seven sizes:** a tall iPhone, the folded Duo at 85% zoom, 1000x703, the open Duo at 1119x787 and 951x539, an iPad at 1180x820, and a desktop at 1440x900. 7.0.30 fails 20 of 48 of these checks; this build passes all 48.
26
+ - **The Sessions check list on the message reader:**
27
+ - typing with the keyboard up, with two files attached;
28
+ - folding and unfolding without a reload;
29
+ - Back while reading, and the how-you-got-here line on a phone;
30
+ - the browser's find, and selecting text;
31
+ - the question card;
32
+ - a short message and a 14,000 px message;
33
+ - the scroll position kept when the page refreshes the same message.
34
+
35
+ These ran at 951x539 and 1119x787 at 85%, 382x557, 449x797, 1180x820 and 1440x900. The keyboard was simulated by shrinking the window, the way an Android keyboard does; an iPhone keyboard shifts the view instead, which was not simulated. A reply arriving while you read was not driven.
36
+ - **Not yet:** a real phone. This counts as done only when Miles has looked at it on the Duo.
37
+
38
+ **Default-on for every install:** yes, with no switch, two things: on screens 600 px and wider, an open message is one screen and the area under the top bar scrolls instead of the page; and an open message keeps your reading place across a fold or an unfold. Nothing else changes under 600 px.
39
+
40
+ ### If you roll back
41
+
42
+ - To 7.0.30: an open message is the whole page again, and after the keyboard and folds its Reply box and the rail can stop partway down the screen with the message below them. A fold or an unfold moves you a few paragraphs back.
43
+ - Further back: as in 7.0.30 below.
44
+
1
45
  ## 7.0.30 - Control Web: Back goes the way you came, a phone shows how you got here, a message shows who said what, Reply stays at the bottom, Meetings opens on today, and clearer copy buttons
2
46
 
3
47
  2026-11-12. 7.0.29 plus eight Control Web changes from a tester's phone feedback, one clearer sign-in error, and phone-approval changes that stay off unless switched on.
@@ -23,6 +67,8 @@ Server:
23
67
 
24
68
  **Default-on for every install:** yes, all eight, with no switch, at every screen size (the how-you-got-here line below 600 px only). The sign-in line is on for everyone. The phone-approval items do nothing while the switch is off, which is the default everywhere.
25
69
 
70
+ **Known fault, fixed in 7.0.31:** on a wide screen, after the keyboard and then folds on the Duo, a message's text could run behind and below the pinned Reply box. 7.0.31 stops that. Safari can still leave the page pushed down after the keyboard and a fold; 7.0.31 does not fix that and records it when it happens.
71
+
26
72
  ### If you roll back
27
73
 
28
74
  - To 7.0.29: Back goes to a fixed parent again, the top bar tucks away on a reader, the message reader's Reply is at the end of the page, the copy buttons say Summary and Full, Meetings opens on today only when today has meetings, Settings says iPhone, a message shows the YOU and COS cards, the how-you-got-here line is gone, and an expired Claude sign-in reads "provider process exited" again. Nothing is stored.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "7.0.30",
3
+ "version": "7.0.31",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,7 @@
3
3
  "algorithm": "sha256",
4
4
  "files": {
5
5
  "/app.css": "bc5e29be841629261889aebfd439a3b126a50041c4e079c9866eac93a3552fe1",
6
- "/app.js": "eb9e4b98ddf4982644c57ce7de11fb9a606e245b5e2169bb25adbe825c05773d",
6
+ "/app.js": "50ac6ccfcd483496a411ae6569217e5cf9574305642b628ec703aa4a6ee8b797",
7
7
  "/apple-touch-icon.png": "04ec7eca0bf6f53d89900d146ac32d4826759d6258bcdbd1ccdcc517f26f532c",
8
8
  "/control.css": "f5b7a2e0f800cc07072581b7e817fcd2b54bdb1d91f4662a3cdc9d80a7bc32ff",
9
9
  "/fonts/DMSans.ttf": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d",
@@ -16,7 +16,7 @@
16
16
  "/memories/graph-explorer.js": "98bde3df25f42022b098d047d2c8e7a22b972c1d817a18a24a0ab075d77871d3",
17
17
  "/memories/memory-workspace.js": "013f6c8c72c3474768f8c374edccde0e3656fc243e56528e258c3c29acca64f5",
18
18
  "/memory-review.js": "f27fe519f7ad7eccc3b44124fae724aa51e2eb3cb060d2f1795600e0e41b074f",
19
- "/panes/cos-chat.css": "079af47855641bf21aae75c34727f7c7be4357e0dde4ed78414d0e60776b71d4",
19
+ "/panes/cos-chat.css": "6edd123904869ff1d04093ffa8e45cf40863ac532054ce703eb44a59a2f395c4",
20
20
  "/panes/cos-chat.js": "b94fca907500be21f777c5d5fa23b7415be48dcbd41d9fa30a4295ed1e3415d3",
21
21
  "/panes/dictation.js": "e9434b353a1a3ab3f21d4cef5b2d5f927a733f6af5f58285fda6a78472761f91",
22
22
  "/panes/meetings.css": "635470e4d5f5665b046eab7382734c9772df569069c9b1eb10cfde6151014bfa",
@@ -742,7 +742,7 @@
742
742
  // The rail changes the reply box's width: an empty box re-fits its placeholder (autoGrow, F2).
743
743
  var si = typeof $ === 'function' ? $('session-input') : null; if (si && typeof sxPlaceholderFit === 'function' && si.phLong) sxPlaceholderFit(si); else if (si && !si.value && typeof autoGrow === 'function') autoGrow(si);
744
744
  // A change after boot (a fold, a rotation, a new screen) is reported once, with the screen's own numbers.
745
- if (RAIL.on !== null && RAIL.on !== on && typeof layoutCrumb === 'function') { var sc = window.screen || {}; layoutCrumb('rail', { sw: sc.width, sh: sc.height, rail: on, railHit: RAIL.hit }); }
745
+ if (RAIL.on !== null && RAIL.on !== on && typeof layoutCrumb === 'function') { var sc = window.screen || {}; layoutCrumb('rail', { sw: sc.width, sh: sc.height, rail: on, railHit: RAIL.hit, dvh: typeof lrProbe === 'function' ? lrProbe('height:100dvh', 'h') : null }); }
746
746
  RAIL.on = on;
747
747
  if (!RAIL.hooked && typeof window.matchMedia === 'function' && typeof window.addEventListener === 'function') {
748
748
  RAIL.hooked = true;
@@ -782,6 +782,7 @@
782
782
 
783
783
  var reading = r.args.length > 0 && !isTabRoute(r.args);
784
784
  if (typeof sxFrameSync === 'function') sxFrameSync();
785
+ if (typeof rdFrameSync === 'function') rdFrameSync(r);
785
786
  var paneEl = $('pane-' + r.pane);
786
787
  if (paneEl) paneEl.classList.toggle('reading', reading);
787
788
  var split = paneEl && paneEl.querySelector('.split');
@@ -905,6 +906,7 @@
905
906
  // hidden, so the gate could not scroll and Connect sat below a keyboard-height screen. The gate is a normal page;
906
907
  // showShell's route() puts the frame back (sxFrameSync) when the shell returns.
907
908
  document.documentElement.classList.remove('sx-frame');
909
+ document.documentElement.classList.remove('rd-frame');
908
910
  // A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
909
911
  // the reason it opened, or "That code did not match" while the person retypes.
910
912
  if (!message && alreadyOpen) return;
@@ -2935,6 +2937,150 @@
2935
2937
  if (on) sxFrameHook();
2936
2938
  return on;
2937
2939
  }
2940
+ // 7.0.31 hotfix (Miles 17:25, the open Duo): the message reader in its own frame from 600 px. html.rd-frame makes the
2941
+ // page one screen, as Sessions' frame does (panes/cos-chat.css): the app shell is the visible box (--vv-top, --vv-h
2942
+ // from viewportFit, which falls back to innerHeight when the visual viewport reads 0 or less), and the reader scrolls
2943
+ // in the workspace under the navigation bar, with Reply pinned to its bottom. Before, the reader was the document: its Reply stuck to the
2944
+ // document's scrollport and the rail was 100dvh, so when Safari's viewport went stale after folds (his Duo reported
2945
+ // visual viewport heights of 0 and -33554432 from 17:17 to 17:24) both ended mid-screen and the message drew below
2946
+ // them. Below 600 px (phones, the folded Duo's side rail) the reader is unchanged; the CSS is gated the same way, so a
2947
+ // class left on across a fold does nothing there. A message's image (#/messages/m/<no>/a/<id>) has no Reply: no frame.
2948
+ function rdFrameOn(r) {
2949
+ var shell = $('app-shell'), wide = !!(window.matchMedia && window.matchMedia('(min-width: 600px)').matches);
2950
+ return !!(r && r.pane === 'messages' && r.args && r.args[0] === 'm' && r.args.length === 2 && wide && !(shell && shell.classList.contains('hidden')));
2951
+ }
2952
+ var RD_FRAME = { hooked: false, n: 0 };
2953
+ function rdFrameSync(r) {
2954
+ var de = document.documentElement, on = rdFrameOn(r || parseRoute());
2955
+ if (de && de.classList) de.classList.toggle('rd-frame', on);
2956
+ if (!RD_FRAME.hooked && window.matchMedia) {
2957
+ RD_FRAME.hooked = true;
2958
+ var mq = window.matchMedia('(min-width: 600px)');
2959
+ if (mq && mq.addEventListener) mq.addEventListener('change', function () { rdFrameSync(); }); else if (mq && mq.addListener) mq.addListener(function () { rdFrameSync(); });
2960
+ }
2961
+ var r2 = r || parseRoute();
2962
+ rdPosHook();
2963
+ // A different message just opened: its place is its top until he scrolls. (This also runs when the width crosses
2964
+ // 600 px; the same message then keeps its anchor and its pending restore.)
2965
+ if (window.requestAnimationFrame) window.requestAnimationFrame(function () {
2966
+ var cx = rdPosReader(), key = cx ? cx.getAttribute('data-reader-key') : '';
2967
+ if (!RD_POS.a || RD_POS.a.key !== key) { RD_POS.pending = false; rdPosCapture(); }
2968
+ });
2969
+ if (r2 && r2.pane === 'messages' && r2.args && r2.args[0] === 'm' && r2.args.length === 2 && window.requestAnimationFrame) window.requestAnimationFrame(function () { rdFrameCrumb('open'); });
2970
+ return on;
2971
+ }
2972
+ // One report per message reader opened, at any width, at most 8 a page load (numbers only; lead 17:48): whether the frame
2973
+ // is on, the visible box viewportFit uses, the reader's box (top, bottom, height), the workspace's height and content height (the frame's scroller), Reply's top
2974
+ // and bottom, the document's height and scroll, and 100dvh. layoutCrumb adds innerWidth and innerHeight and the visual
2975
+ // viewport's height, top and scale, so a pan like 17:25's (top about 409) shows in the same row. The 17:25 frame could
2976
+ // not be measured; the next one will be.
2977
+ function rdFrameCrumb(why) {
2978
+ if (RD_FRAME.n >= 8 || typeof layoutCrumb !== 'function') return false;
2979
+ try {
2980
+ var v = viewportFit(), f = document.querySelector('#pane-messages .cx-reply-form'), cx = document.querySelector('#pane-messages > .cx'), ws = $('workspace');
2981
+ var fr = f && f.getBoundingClientRect ? f.getBoundingClientRect() : null, cr = cx && cx.getBoundingClientRect ? cx.getBoundingClientRect() : null;
2982
+ var se = document.scrollingElement || document.documentElement;
2983
+ var out = { why: String(why || '').slice(0, 24), frame: !!(document.documentElement.classList && document.documentElement.classList.contains('rd-frame')), fb: !!v.bad,
2984
+ boxT: v.top, boxB: v.top + v.height, rdT: cr ? cr.top : -1, rdB: cr ? cr.bottom : -1, rdH: cx ? cx.clientHeight : -1, wsH: ws ? ws.clientHeight : -1, wsSH: ws ? ws.scrollHeight : -1,
2985
+ replyT: fr ? fr.top : -1, replyB: fr ? fr.bottom : -1, docH: se ? se.scrollHeight : -1, sy: window.scrollY || 0, dvh: typeof lrProbe === 'function' ? lrProbe('height:100dvh', 'h') : null };
2986
+ if (layoutCrumb('reader', out)) { RD_FRAME.n++; return true; }
2987
+ } catch (_e) { /* a report never stops the page */ }
2988
+ return false;
2989
+ }
2990
+ // 7.0.31 (Q1 FIX FIRST, lead 20:41): ONE job. A fold or an unfold that crosses 600 px switches the message between the
2991
+ // page's scroll (under 600) and the frame's workspace (600 and up), and the new scroller would open at the top. So the
2992
+ // page keeps an anchor, the character at the top of the reading area, read as he scrolls, and applies it ONCE after a
2993
+ // switch: when no finger is down and the scroll has been still for RD_POS_STILL_MS. If he scrolls on purpose first (a
2994
+ // touch that moves, the wheel, a key), his scroll wins and the anchor is dropped. Nothing else moves the text: no
2995
+ // other resize, no keyboard, no card or notice. Under 600 px with no switch, the reader is 7.0.30's.
2996
+ var RD_POS_STILL_MS = 300, RD_POS_MOVE_PX = 10;
2997
+ var RD_POS = { a: null, mode: '', pending: false, timer: 0, touching: false, ty: 0, moved: false, scrollAt: 0, queued: false, hooked: false };
2998
+ function rdPosReader() {
2999
+ var r = parseRoute();
3000
+ return r && r.pane === 'messages' && r.args && r.args[0] === 'm' && r.args.length === 2 ? document.querySelector('#pane-messages > .cx[data-reader-key]') : null;
3001
+ }
3002
+ function rdPosMode() { var ws = $('workspace'); return ws && getComputedStyle(ws).overflowY === 'auto' ? 'frame' : 'page'; }
3003
+ // The top of the reading area: the workspace's top in the frame; on the page, below the pinned top bar.
3004
+ function rdPosTop(mode) {
3005
+ if (mode === 'frame') return $('workspace').getBoundingClientRect().top;
3006
+ var t = 0;
3007
+ ['.topbar', '.navbar'].forEach(function (sel) {
3008
+ var e = document.querySelector(sel), st = e && getComputedStyle(e), r = e && e.getBoundingClientRect();
3009
+ if (e && st.display !== 'none' && r.height > 0 && r.bottom > 0 && (st.position === 'sticky' || st.position === 'fixed')) t = Math.max(t, r.bottom);
3010
+ });
3011
+ return t;
3012
+ }
3013
+ // One character's line box (a collapsed range has none in every engine).
3014
+ function rdPosRect(node, offset) {
3015
+ var len = node && node.nodeValue ? node.nodeValue.length : 0;
3016
+ if (!len) return null;
3017
+ var rg = document.createRange(), at = Math.min(offset, len - 1);
3018
+ rg.setStart(node, at); rg.setEnd(node, at + 1);
3019
+ var r = rg.getBoundingClientRect();
3020
+ return r.height || r.width ? r : null;
3021
+ }
3022
+ function rdPosCapture() {
3023
+ var cx = rdPosReader();
3024
+ if (!cx) { RD_POS.a = null; RD_POS.mode = ''; return null; }
3025
+ var mode = rdPosMode(), top = rdPosTop(mode), key = cx.getAttribute('data-reader-key');
3026
+ RD_POS.mode = mode;
3027
+ if ((mode === 'frame' ? $('workspace').scrollTop : window.scrollY || 0) <= 2) return (RD_POS.a = { key: key, atTop: true });
3028
+ var c = cx.getBoundingClientRect(), body = cx.querySelector('.cx-said.cos .cx-card-body'), xs = [c.left + 8, c.left + c.width / 2];
3029
+ if (body) xs.unshift(body.getBoundingClientRect().left + 6);
3030
+ for (var dy = 2; dy <= 62; dy += 12) {
3031
+ for (var i = 0; i < xs.length; i++) {
3032
+ var hit = document.caretRangeFromPoint ? document.caretRangeFromPoint(xs[i], top + dy) : null, n = hit && hit.startContainer;
3033
+ if (!n || n.nodeType !== 3 || !cx.contains(n) || n.parentElement.closest('.cx-reply-form')) continue;
3034
+ var r = rdPosRect(n, hit.startOffset);
3035
+ if (r) return (RD_POS.a = { key: key, node: n, offset: hit.startOffset, dy: r.top - top });
3036
+ }
3037
+ }
3038
+ return null;
3039
+ }
3040
+ function rdPosApply() {
3041
+ var cx = rdPosReader(), a = RD_POS.a, mode = rdPosMode(), ws = $('workspace');
3042
+ RD_POS.mode = mode;
3043
+ if (!cx || !a || a.key !== cx.getAttribute('data-reader-key')) return false;
3044
+ if (a.atTop) { if (mode === 'frame') ws.scrollTop = 0; else window.scrollTo(0, 0); return true; }
3045
+ var r = a.node.isConnected ? rdPosRect(a.node, a.offset) : null;
3046
+ if (!r) return false;
3047
+ var want = Math.round(r.top - rdPosTop(mode) - a.dy);
3048
+ if (mode === 'frame') ws.scrollTop += want; else window.scrollBy(0, want);
3049
+ return true;
3050
+ }
3051
+ // The scroller changed under him: wait for no finger and a still scroll, then apply once; his own scroll drops it.
3052
+ function rdPosTick() {
3053
+ RD_POS.timer = 0;
3054
+ if (!RD_POS.pending) return;
3055
+ if (RD_POS.moved) { RD_POS.pending = false; rdPosCapture(); return; }
3056
+ if (RD_POS.touching || Date.now() - RD_POS.scrollAt < RD_POS_STILL_MS) { RD_POS.timer = setTimeout(rdPosTick, 80); return; }
3057
+ RD_POS.pending = false;
3058
+ rdPosApply();
3059
+ }
3060
+ function rdPosCheck() {
3061
+ var mode = rdPosReader() ? rdPosMode() : '';
3062
+ if (!mode || !RD_POS.mode || mode === RD_POS.mode || RD_POS.pending) return;
3063
+ RD_POS.pending = true; RD_POS.moved = false;
3064
+ if (!RD_POS.timer) RD_POS.timer = setTimeout(rdPosTick, 80);
3065
+ }
3066
+ function rdPosHook() {
3067
+ if (RD_POS.hooked || !document.addEventListener) return;
3068
+ RD_POS.hooked = true;
3069
+ var o = { capture: true, passive: true };
3070
+ document.addEventListener('scroll', function () {
3071
+ RD_POS.scrollAt = Date.now();
3072
+ if (!rdPosReader()) return;
3073
+ rdPosCheck();
3074
+ if (RD_POS.pending || RD_POS.queued) return;
3075
+ RD_POS.queued = true;
3076
+ requestAnimationFrame(function () { RD_POS.queued = false; rdPosCheck(); if (!RD_POS.pending) rdPosCapture(); });
3077
+ }, o);
3078
+ window.addEventListener('resize', rdPosCheck);
3079
+ document.addEventListener('touchstart', function (e) { RD_POS.touching = true; RD_POS.ty = e.touches && e.touches[0] ? e.touches[0].clientY : 0; }, o);
3080
+ document.addEventListener('touchmove', function (e) { var y = e.touches && e.touches[0] ? e.touches[0].clientY : RD_POS.ty; if (RD_POS.pending && Math.abs(y - RD_POS.ty) > RD_POS_MOVE_PX) RD_POS.moved = true; }, o);
3081
+ ['touchend', 'touchcancel'].forEach(function (t) { document.addEventListener(t, function () { RD_POS.touching = false; }, o); });
3082
+ ['wheel', 'keydown'].forEach(function (t) { document.addEventListener(t, function () { if (RD_POS.pending) RD_POS.moved = true; }, o); });
3083
+ }
2938
3084
  // Where the frame's parts sit against the VISIBLE area (step 1's position report, lead 23:00): on a reader's open, and
2939
3085
  // KB_AFTER_MS after an on-screen keyboard closes while the frame is on. The visible area is viewportFit's box (a
2940
3086
  // visible height under 80 px is no reading, so it is innerHeight from 0, and fb says so) and, across, visualViewport's
@@ -220,6 +220,27 @@ html.kb-up .cc-kb .cc-model .field { width: 100%; }
220
220
  html.kb-reply; app.js publishes --vv-below, the layout viewport under the visible box) it sits on the keyboard, the
221
221
  same way the chat's box does; the home indicator is under the keyboard, so its inset goes. */
222
222
  html.kb-reply .cx-reply-form { bottom: var(--vv-below, 0px); padding-bottom: 8px; }
223
+ /* 7.0.31 hotfix (Miles 17:25, the open Duo): from 600 px the message reader is its own frame (app.js rdFrameSync sets
224
+ html.rd-frame on #/messages/m/<no>). The page is one screen, never taller, as in Sessions (panes/sessions.css): the
225
+ app shell is the visible box and the rail a column of it that scrolls by itself. The workspace under the navigation
226
+ bar is the one thing that scrolls: the sign-in notice, a question card and the message scroll together as they did
227
+ in 7.0.30, and Reply sticks to the workspace's bottom. Before, the reader was the document, so Reply stuck to the
228
+ document's viewport and the rail was 100dvh: with Safari's view left panned or short after folds, both ended
229
+ mid-screen and the message drew below them. Nothing here reaches under 600 px, where the phone and the folded Duo
230
+ keep the reader as shipped. */
231
+ @media (min-width: 600px) {
232
+ html.rd-frame, html.rd-frame body { height: 100%; overflow: hidden; overscroll-behavior: none; }
233
+ html.rd-frame body { min-height: 0; }
234
+ html.rd-frame .app-shell { position: fixed; left: 0; right: 0; top: var(--vv-top, 0px); height: var(--vv-h, 100dvh); min-height: 0;
235
+ grid-template-rows: minmax(0, 1fr); overflow: hidden; }
236
+ html.rd-frame .rail { position: static; top: auto; height: auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
237
+ html.rd-frame .main-col { min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
238
+ html.rd-frame .topbar, html.rd-frame .navbar { flex: none; position: relative; }
239
+ /* The scroller. No bottom padding: at the end of the message, Reply is the last thing and sits on the frame's bottom. */
240
+ html.rd-frame .workspace { flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: 0; }
241
+ /* With the keyboard up the frame is already the visible box (--vv-top, --vv-h), so the box stays at the frame's bottom. */
242
+ html.rd-frame.kb-reply .cx-reply-form { bottom: 0; }
243
+ }
223
244
 
224
245
  /* ── the Messages badge in the rail and the section menu ─ */
225
246
  .inbox-count { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--hot); color: var(--panel); font: 600 11px/18px var(--body); text-align: center; }