@gotcos/glasses-server 7.0.29 → 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.
@@ -573,6 +573,133 @@
573
573
  var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
574
574
  if (location.hash === hash) route(); else location.hash = hash;
575
575
  }
576
+ // ── Back by path (7.0.30, item 1: Anand on the 11-12 HC call, Miles's pick) ──
577
+ // Back goes where the person came from. Each address this page makes is marked in its own history entry
578
+ // (history.state.cosNav): this page load's id, the entry's position, and the address it came from. Back is then the
579
+ // browser's own Back (history.back), so the system Back and ours agree and Back adds no entry. With no mark (a deep
580
+ // link, a reload, the first page, or an entry an overlay took over), Back keeps the parent chain it always had, and it
581
+ // REPLACES the entry rather than adding one, so the two can never bounce between each other. The image viewer and
582
+ // Today's sheet push entries of their own (cosImageViewer, cosTodayOverlay): those are never marked, never stepped into,
583
+ // and an address made while one of them still sat behind gets no mark, because the entry behind it is not an address
584
+ // (its position is not the last address's position plus one).
585
+ var NAV = { doc: Math.random().toString(36).slice(2, 10), last: null, replace: null };
586
+ function navState() { try { return history.state; } catch (_e) { return null; } }
587
+ function navOverlay(st) { return !!(st && (st.cosImageViewer || st.cosTodayOverlay)); }
588
+ function navStamp() {
589
+ if (typeof history === 'undefined' || !history || typeof location === 'undefined') return; // a page without them: the parent chain
590
+ var hash = location.hash || '', st = navState(), pos;
591
+ if (navOverlay(st)) { NAV.last = null; NAV.replace = null; return; }
592
+ if (st && st.cosNav && st.cosNav.doc === NAV.doc) { NAV.last = { hash: hash, pos: st.cosNav.pos, back: st.cosNav.back, trail: st.cosNav.trail || null, label: st.cosNav.label || '' }; NAV.replace = null; return; }
593
+ if (st && st.cosNav) {
594
+ // Marked by an earlier load of this page (a reload): what lies behind it belongs to that load, so no path.
595
+ pos = st.cosNav.pos;
596
+ } else if (st) { NAV.last = null; NAV.replace = null; return; } // someone else's state: leave it alone
597
+ else pos = NAV.replace ? NAV.replace.pos : history.length - 1;
598
+ var back = null;
599
+ if (!st && !NAV.replace && NAV.last && typeof NAV.last.pos === 'number' && pos === NAV.last.pos + 1) back = NAV.last.hash;
600
+ NAV.replace = null;
601
+ // 7.0.30, Miles's pick B: the steps before this one ride in the same mark, the last of them being where Back goes.
602
+ var trail = back ? (NAV.last.trail || []).concat([{ h: NAV.last.hash, t: NAV.last.label || '' }]).slice(-NAV_TRAIL_MAX) : null;
603
+ var mark = { doc: NAV.doc, pos: pos, back: back };
604
+ if (trail) mark.trail = trail;
605
+ try { history.replaceState(Object.assign({}, st || {}, { cosNav: mark }), ''); } catch (_e) { /* no history API: the parent chain */ }
606
+ NAV.last = { hash: hash, pos: pos, back: back, trail: trail, label: '' };
607
+ }
608
+ var NAV_TRAIL_MAX = 12;
609
+ // The page's own name, kept in its mark, so the next page's trail can name it. setCrumb calls this.
610
+ function navLabel(text) {
611
+ var st = navState();
612
+ if (navOverlay(st) || !st || !st.cosNav || st.cosNav.doc !== NAV.doc || (st.cosNav.label || '') === text) return;
613
+ try { history.replaceState(Object.assign({}, st, { cosNav: Object.assign({}, st.cosNav, { label: text }) }), ''); } catch (_e) { return; }
614
+ if (NAV.last && NAV.last.pos === st.cosNav.pos) NAV.last.label = text;
615
+ }
616
+ // The way he came, oldest first, from this entry's mark: [{ label, hash, back }], back being how many steps back that
617
+ // one is. Null when Back has no path (a deep link, a reload, the first page, an overlay).
618
+ function navTrail() {
619
+ var st = navState();
620
+ if (navOverlay(st) || !st || !st.cosNav || !st.cosNav.back || !st.cosNav.trail || !st.cosNav.trail.length) return null;
621
+ var t = st.cosNav.trail;
622
+ return t.map(function (s, i) { return { label: trailName(s.h, s.t), hash: s.h, back: t.length - i }; });
623
+ }
624
+ // A step is named by what kind of page it is, not its title (the title is the heading under the row): "Meeting",
625
+ // "Session", "Naming voices". A list is its pane. A message, a day and a chat keep their own short name.
626
+ function trailName(hash, crumb) {
627
+ var a = String(hash || '').replace(/^#\/?/, '').split('/').filter(function (x) { return x !== ''; }), p = ROOT_PANES.indexOf(a[0]) >= 0 ? a[0] : 'today';
628
+ if (a.length < 2 || isTabRoute(a.slice(1))) return TITLES[p] || 'Today';
629
+ if (p === 'meetings') return 'Meeting';
630
+ if (p === 'sessions') return 'Session';
631
+ if (p === 'speakers' && a[1] === 'review') return 'Naming voices';
632
+ return crumb || TITLES[p] || 'Today';
633
+ }
634
+ // One line that never wraps. When it is too wide, steps leave from the middle (the one after the first, then the next),
635
+ // never the first, the one before this page, or this page; "…" stands where they were. widths: each step's, then this
636
+ // page's; more: the width "…" adds. Returns the indexes to leave out.
637
+ function trailCut(widths, more, avail) {
638
+ var n = widths.length - 1, total = widths.reduce(function (a, b) { return a + b; }, 0), cut = [];
639
+ if (total <= avail) return cut;
640
+ total += more;
641
+ for (var i = 1; i < n - 1 && total > avail; i++) { cut.push(i); total -= widths[i]; }
642
+ return cut;
643
+ }
644
+ // The row under the top bar, on a phone (below 600 px; wider screens have the navigation bar's crumbs). Only on a page
645
+ // you opened (a reader, a meeting, a session), and only when there is a path.
646
+ function paintTrail(r) {
647
+ var el = typeof document !== 'undefined' && document.getElementById('nav-trail');
648
+ if (!el) return;
649
+ r = r || parseRoute();
650
+ var steps = r.args.length && !isTabRoute(r.args) ? navTrail() : null;
651
+ if (!steps) { el.hidden = true; el.innerHTML = ''; return; }
652
+ var here = trailName(location.hash, crumbDetail);
653
+ el.innerHTML = '<ol>' + steps.map(function (s, i) {
654
+ return '<li class="trail-step"><a href="' + esc(s.hash) + '" data-trail-back="' + s.back + '">' + esc(s.label) + '</a></li>' +
655
+ (i === 0 ? '<li class="trail-more" aria-hidden="true" hidden>…</li>' : '');
656
+ }).join('') + '<li class="trail-here" aria-current="page"><span>' + esc(here) + '</span></li></ol>';
657
+ el.hidden = false;
658
+ var items = Array.prototype.slice.call(el.querySelectorAll('.trail-step,.trail-here')), more = el.querySelector('.trail-more');
659
+ if (!more || !el.clientWidth) return;
660
+ el.classList.add('trail-fit'); // each step at its full width, to measure
661
+ more.hidden = false;
662
+ var moreW = more.offsetWidth, widths = items.map(function (li) { return li.offsetWidth; });
663
+ more.hidden = true;
664
+ el.classList.remove('trail-fit');
665
+ var cut = trailCut(widths, moreW, el.clientWidth);
666
+ // A left-out step stays for a screen reader but takes no keyboard focus (Q1 L1): it is not on screen.
667
+ cut.forEach(function (i) { items[i].classList.add('trail-cut'); var a = items[i].querySelector('a'); if (a) a.setAttribute('tabindex', '-1'); });
668
+ more.hidden = !cut.length;
669
+ }
670
+ // Where Back goes from here: { pane, path } (path true: the way he came; false: the parent chain).
671
+ function navBackTo() {
672
+ var st = navState();
673
+ // navStamp has run on this entry (every landing routes), so its mark is this load's: an earlier load's was re-marked
674
+ // with no path there.
675
+ if (!navOverlay(st) && st && st.cosNav && st.cosNav.back) {
676
+ var p = String(st.cosNav.back).replace(/^#\/?/, '').split('/')[0];
677
+ return { pane: ROOT_PANES.indexOf(p) >= 0 ? p : 'today', path: true };
678
+ }
679
+ var r = parseRoute(), up = PANES[r.pane] && PANES[r.pane].up ? PANES[r.pane].up(r.args) : null;
680
+ return { pane: up || (r.args.length && !isTabRoute(r.args)) ? r.pane : 'today', path: false };
681
+ }
682
+ // The one way this page replaces an address (W.replace for the panes; Q1 7.0.30 H1). It tells navStamp the entry was
683
+ // replaced, never pushed, so a replaced entry never gets a path: a pane switching a tab with location.replace after a
684
+ // Back (a forward entry left) was stamped as a push at history.length - 1, and Back then called history.back() from
685
+ // the first entry, out of the app. Its position is this entry's mark, or one above the last mark when an overlay's
686
+ // entry (Today's sheet, the image viewer) is the one replaced. Returns false, doing nothing, when the address is the
687
+ // same (location.replace would not route, and the flag would wrongly mark the next address).
688
+ function navReplace(hash) {
689
+ if (location.hash === hash) return false;
690
+ var st = navState(), pos;
691
+ if (navOverlay(st)) pos = NAV.last && typeof NAV.last.pos === 'number' ? NAV.last.pos + 1 : history.length - 1;
692
+ else pos = st && st.cosNav && typeof st.cosNav.pos === 'number' ? st.cosNav.pos : history.length - 1;
693
+ NAV.replace = { pos: pos };
694
+ try { location.replace(location.pathname + location.search + hash); } catch (_e) { NAV.replace = null; location.hash = hash; }
695
+ return true;
696
+ }
697
+ // The parent chain, replacing this entry (never adding one).
698
+ function goReplace(pane, args) {
699
+ var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
700
+ if (!navReplace(hash)) route();
701
+ }
702
+ // ── end back by path
576
703
  // 7.0.2 (A8): a phone is under 600 px wide, or a short touch screen at any width (the unfolded Duo is 951x539 in
577
704
  // Comet): height is its scarce axis, so it gets the phone Ask sheet and the 44 px tap floor (app.css), as folded does.
578
705
  var SHORT_TOUCH_MQ = '(pointer: coarse) and (max-height: 720px)';
@@ -615,7 +742,7 @@
615
742
  // The rail changes the reply box's width: an empty box re-fits its placeholder (autoGrow, F2).
616
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);
617
744
  // A change after boot (a fold, a rotation, a new screen) is reported once, with the screen's own numbers.
618
- 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 }); }
619
746
  RAIL.on = on;
620
747
  if (!RAIL.hooked && typeof window.matchMedia === 'function' && typeof window.addEventListener === 'function') {
621
748
  RAIL.hooked = true;
@@ -639,6 +766,7 @@
639
766
  return !!(e.metaKey || e.ctrlKey || keyboardFirst());
640
767
  }
641
768
  function route(forceChanged) {
769
+ if (typeof navStamp === 'function') navStamp();
642
770
  var r = parseRoute();
643
771
  var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
644
772
  if (state.pane !== r.pane && PANES[state.pane] && PANES[state.pane].leave) { try { PANES[state.pane].leave(); } catch (err) { console.error(err); } }
@@ -654,6 +782,7 @@
654
782
 
655
783
  var reading = r.args.length > 0 && !isTabRoute(r.args);
656
784
  if (typeof sxFrameSync === 'function') sxFrameSync();
785
+ if (typeof rdFrameSync === 'function') rdFrameSync(r);
657
786
  var paneEl = $('pane-' + r.pane);
658
787
  if (paneEl) paneEl.classList.toggle('reading', reading);
659
788
  var split = paneEl && paneEl.querySelector('.split');
@@ -664,8 +793,18 @@
664
793
  var railBack = $('rail-back');
665
794
  if (railBack) railBack.hidden = !reading;
666
795
  $('section-btn').classList.toggle('hidden', reading);
667
- $('topbar-back-label').textContent = TITLES[r.pane] || 'Back';
796
+ // 7.0.30: Back names where it goes, the way he came or the parent (navBackTo).
797
+ var backTo = typeof navBackTo === 'function' ? navBackTo() : { pane: r.pane };
798
+ $('topbar-back-label').textContent = TITLES[backTo.pane] || 'Back';
799
+ var topBack = $('topbar-back');
800
+ if (topBack && topBack.setAttribute) topBack.setAttribute('aria-label', 'Back to ' + (TITLES[backTo.pane] || 'the list'));
801
+ if (railBack && railBack.setAttribute) railBack.setAttribute('aria-label', 'Back to ' + (TITLES[backTo.pane] || 'the list'));
802
+ // From 600 px Back is the navigation bar's (shell.js): its word stays "Back", as on the Mac's bar, and its name and
803
+ // tooltip say where it goes, as the rail's do (7.0.30 counted run, items_check B1 and B2 at the open Duo).
804
+ var navBack = $('nav-back');
805
+ if (navBack && navBack.setAttribute) { navBack.setAttribute('aria-label', 'Back to ' + (TITLES[backTo.pane] || 'the list')); navBack.setAttribute('title', 'Back to ' + (TITLES[backTo.pane] || 'the list')); }
668
806
  $('section-name').textContent = TITLES[r.pane] || 'Today';
807
+ if (typeof paintTrail === 'function') paintTrail(r);
669
808
  closeMenu();
670
809
  if (changedPane) window.scrollTo(0, 0);
671
810
  $('topbar').classList.remove('tucked');
@@ -701,26 +840,32 @@
701
840
  runHooks(routeHooks, r);
702
841
  }
703
842
  function runHooks(list, arg) { list.forEach(function (fn) { try { fn(arg); } catch (err) { console.error(err); } }); }
704
- // Back unwinds one step, the way Control's Back does: a pane module may name the step above a
705
- // nested reader (an archive chat goes back to its day, not to the list).
843
+ // Back (7.0.30): the way he came, when this page made the entry behind (navBackTo). Otherwise one step up, the way
844
+ // Control's Back does: a pane module may name the step above a nested reader (an archive chat goes back to its day,
845
+ // not to the list), replacing this entry.
706
846
  function goBack() {
847
+ if (typeof navBackTo === 'function' && navBackTo().path) { history.back(); return; }
707
848
  var r = parseRoute();
708
849
  var up = PANES[r.pane] && PANES[r.pane].up ? PANES[r.pane].up(r.args) : null;
709
- if (up) go(r.pane, up);
710
- else if (r.args.length && !isTabRoute(r.args)) go(r.pane);
711
- else go('today');
850
+ var step = typeof goReplace === 'function' ? goReplace : go;
851
+ if (up) step(r.pane, up);
852
+ else if (r.args.length && !isTabRoute(r.args)) step(r.pane);
853
+ else step('today');
712
854
  }
713
855
  // '#/<pane>/t/<tab>' is the pane's list on one of its tabs, not a reader: the phone keeps the section switcher and Back
714
856
  // leaves the pane. Memories keeps its tab in the address this way, so a reload or a new browser lands on the same tab.
715
857
  function isTabRoute(args) { return args.length === 2 && args[0] === 't'; }
716
- // On the closed Duo every vertical pixel counts: the top bar slides away while you scroll
717
- // down and comes back the moment you scroll up.
858
+ // On a phone every vertical pixel counts: the top bar slides away while you scroll down a list and comes back the
859
+ // moment you scroll up. 7.0.30 (item 1): never on a reader, where the top bar holds Back, so Back stays in reach on a
860
+ // tall phone however far down he reads.
718
861
  var lastY = 0;
719
862
  function onScroll() {
720
863
  var y = Math.max(0, window.scrollY || 0);
721
- var bar = $('topbar');
864
+ // On a reader the bar stays (its Back is there). The folded Duo's Back is on the side rail (the bar's is hidden by CSS,
865
+ // not the class), so there the bar tucks as before (Q1 7.0.30 M1).
866
+ var bar = $('topbar'), back = $('topbar-back'), onReader = !!(back && !back.classList.contains('hidden')) && !(typeof sideRail === 'function' && sideRail());
722
867
  bar.classList.toggle('scrolled', y > 8);
723
- if (y < 60 || y < lastY - 4) bar.classList.remove('tucked');
868
+ if (y < 60 || y < lastY - 4 || onReader) bar.classList.remove('tucked');
724
869
  else if (y > lastY + 4 && isPhone()) bar.classList.add('tucked');
725
870
  lastY = y;
726
871
  }
@@ -760,7 +905,8 @@
760
905
  // 7.0.23 regression (lapse_check, 08:2x): a frame class left on keeps html and body at one screen with overflow
761
906
  // hidden, so the gate could not scroll and Connect sat below a keyboard-height screen. The gate is a normal page;
762
907
  // showShell's route() puts the frame back (sxFrameSync) when the shell returns.
763
- document.documentElement.classList.remove('sx-frame', 'mx-frame');
908
+ document.documentElement.classList.remove('sx-frame');
909
+ document.documentElement.classList.remove('rd-frame');
764
910
  // A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
765
911
  // the reason it opened, or "That code did not match" while the person retypes.
766
912
  if (!message && alreadyOpen) return;
@@ -2588,7 +2734,7 @@
2588
2734
  var dest = session ? ['sessions', s[0], s.slice(1).join('|')] : kind === 'pet' ? ['sessions'] : ['work'].concat(kind === 'need' ? [] : ['sort']);
2589
2735
  console.error('[today] the overlay did not open (' + kind + '); opening ' + dest.join('/') + ' instead.', err);
2590
2736
  toast(session ? 'Could not open that here. Opening the session.' : kind === 'pet' ? 'Could not open that here. Opening Sessions.' : 'Could not open that here. Opening it in Work.');
2591
- if (history.state && history.state.cosTodayOverlay) location.replace('#/' + dest.map(encodeURIComponent).join('/'));
2737
+ if (history.state && history.state.cosTodayOverlay) navReplace('#/' + dest.map(encodeURIComponent).join('/'));
2592
2738
  else go(dest[0], dest.slice(1));
2593
2739
  });
2594
2740
  }
@@ -2791,6 +2937,150 @@
2791
2937
  if (on) sxFrameHook();
2792
2938
  return on;
2793
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
+ }
2794
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
2795
3085
  // KB_AFTER_MS after an on-screen keyboard closes while the frame is on. The visible area is viewportFit's box (a
2796
3086
  // visible height under 80 px is no reading, so it is innerHeight from 0, and fb says so) and, across, visualViewport's
@@ -5591,7 +5881,7 @@
5591
5881
  // estimates into them, and its rows keep Control's category titles.
5592
5882
  function loadUsage() {
5593
5883
  SX.view = 'usage'; sxSave();
5594
- location.replace('#/sessions');
5884
+ navReplace('#/sessions');
5595
5885
  }
5596
5886
  function renderUsageView(force) {
5597
5887
  var box = $('sessions-usage'); if (!box) return;
@@ -7366,11 +7656,13 @@
7366
7656
 
7367
7657
  // ── events (one delegated listener) ─────────────────────
7368
7658
  document.addEventListener('click', function (e) {
7369
- var t = e.target.closest('[data-today-next],[data-next-prep],[data-next-prep-cancel],[data-next-prep-remove],[data-today-needs],[data-today],[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],#topbar-back,#rail-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#auth-retry,#device-code-btn');
7659
+ var t = e.target.closest('[data-today-next],[data-next-prep],[data-next-prep-cancel],[data-next-prep-remove],[data-today-needs],[data-today],[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],[data-trail-back],#topbar-back,#rail-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#auth-retry,#device-code-btn');
7370
7660
  if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
7371
7661
  var inSheet = !!t.closest('#ask-sheet');
7372
7662
  if (t.id === 'topbar-back') return goBack();
7373
7663
  if (t.id === 'rail-back') return goBack();
7664
+ // 7.0.30 (pick B): a trail step is the browser's own Back, that many steps.
7665
+ if (t.hasAttribute('data-trail-back')) { e.preventDefault(); history.go(-Number(t.getAttribute('data-trail-back'))); return; }
7374
7666
  if (t.id === 'section-btn') return openMenu();
7375
7667
  if (t.id === 'auth-retry') { t.disabled = true; t.textContent = 'Checking'; bootStatus().then(function () { t.disabled = false; t.textContent = 'Try again'; }); return; }
7376
7668
  if (t.id === 'auth-switch') {
@@ -7786,7 +8078,7 @@
7786
8078
  plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
7787
8079
  skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
7788
8080
  readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
7789
- state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
8081
+ state: state, titles: TITLES, go: go, replace: navReplace, parseRoute: parseRoute, goBack: goBack,
7790
8082
  registerPane: function (name, def) { PANES[name] = def; },
7791
8083
  sessionList: sessionList,
7792
8084
  petPrefs: petPrefs,
@@ -7796,7 +8088,7 @@
7796
8088
  onShell: function (fn) { shellHooks.push(fn); },
7797
8089
  // The detail title after "COS Control > Section" (Control's breadcrumb). Pane modules set it
7798
8090
  // once a detail has loaded; route() clears it on every navigation.
7799
- setCrumb: function (text) { crumbDetail = String(text || ''); runHooks(routeHooks, parseRoute()); },
8091
+ setCrumb: function (text) { crumbDetail = String(text || ''); navLabel(crumbDetail); paintTrail(); runHooks(routeHooks, parseRoute()); },
7800
8092
  crumb: function () { return crumbDetail; },
7801
8093
  // Panes that read with their own fetch call this on a 401 so the sign-in screen shows.
7802
8094
  showAuth: function (message) { showAuth(message); },
@@ -1,7 +1,7 @@
1
1
  /* COS Control Web: Control's own chrome and pane kit, loaded after app.css.
2
2
  Measurements follow Control 0.5.366 (ActivityWindow.swift, COSBrand.swift): the 248pt rail with a
3
3
  story under each name from 1100px, the icon rail below that, the navigation bar off Today, and
4
- the shared pane pieces (tinted hero, text switch with a gold rule, rows, tags, YOU/COS cards).
4
+ the shared pane pieces (tinted hero, text switch with a gold rule, rows, tags, and the reader's sides for who said what).
5
5
  Phone sizes are set for reading on the iPhone Duo; the desktop steps down toward Control's. */
6
6
 
7
7
  :root {
@@ -236,6 +236,18 @@
236
236
  .cx-card { background: var(--panel); border: 1px solid var(--panel-border); border-radius: 14px; padding: 12px 14px 14px; margin-bottom: 12px; }
237
237
  .cx-card.you { background: var(--qopen); }
238
238
  .cx-card-label { font: 600 9.5px/1 var(--mono); letter-spacing: 0.08em; color: var(--tertiary); margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
239
+ /* 7.0.30, Miles's pick A (who said what): sides, in the COS chat's shapes (.cc-bubble, .cc-line.cos). His bubble's edge
240
+ is opaque in light, gold 85% with ink, 3.48:1 against the page; the chat's 45% gold edge composites to 1.52:1 there.
241
+ Dark keeps the chat's edge as it is (2.79:1 composited). */
242
+ :root { --said-edge: color-mix(in srgb, var(--gold) 85%, var(--ink)); }
243
+ :root[data-theme='dark'] { --said-edge: color-mix(in srgb, var(--gold) 45%, transparent); }
244
+ @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { --said-edge: color-mix(in srgb, var(--gold) 45%, transparent); } }
245
+ .cx-said.you { display: flex; flex-direction: column; align-items: flex-end; margin: 4px 0 16px; }
246
+ .cx-said-bubble { max-width: min(80%, 560px); background: color-mix(in srgb, var(--gold) 22%, var(--panel)); border: 1px solid var(--said-edge);
247
+ border-radius: 18px 18px 6px 18px; padding: 10px 14px; line-height: 1.45; }
248
+ .cx-said-who { font-size: 12px; color: var(--sub); margin: 4px 4px 0; }
249
+ .cx-said.cos { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; align-items: start; margin: 0 0 14px; }
250
+ .cx-said-text { min-width: 0; }
239
251
  .cx-card-body { font-size: 15px; line-height: 1.5; color: var(--ink); overflow-wrap: anywhere; }
240
252
  /* md() keeps line breaks as text (shared with the session reader), so the body keeps them too. */
241
253
  .cx-card-body.md { white-space: pre-wrap; }
@@ -262,9 +274,27 @@
262
274
  .cx-find .cx-find-count { font: 400 11.5px/1 var(--mono); color: var(--sub); }
263
275
  .cx-find button { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--panel-border); display: grid; place-items: center; color: var(--ink); }
264
276
  .cx-find button .icon { width: 14px; height: 14px; }
265
- .cx-turn.match .cx-card { background: color-mix(in srgb, var(--green) 7%, var(--panel)); }
277
+ .cx-turn.match .cx-said-bubble, .cx-turn.match .cx-said-text { background: color-mix(in srgb, var(--green) 7%, var(--panel)); }
278
+ .cx-turn.match .cx-said-text { border-radius: 10px; }
266
279
  @media (max-width: 599px) { .cx-find { top: calc(var(--topbar-h) + var(--safe-top)); } .cx-thumb { width: 132px; height: 92px; } }
267
280
 
281
+ /* 7.0.30, Miles's pick B (how you got here): one plain row of text links under the top bar, on a phone only (from 600 px
282
+ the navigation bar has its crumbs). No box, border or label. It never wraps: steps leave from the middle (app.js
283
+ trailCut) and a long name ends in an ellipsis. A left-out step stays for a screen reader. */
284
+ .trail { display: none; }
285
+ @media (max-width: 599px) {
286
+ .trail:not([hidden]) { display: block; margin: 0 0 2px; }
287
+ .trail ol { display: flex; align-items: center; list-style: none; margin: 0; padding: 0; overflow: hidden; white-space: nowrap; font-size: 13.5px; color: var(--sub); }
288
+ .trail li { display: flex; align-items: center; min-width: 0; flex: 0 1 auto; }
289
+ .trail li + li::before { content: '/'; content: '/' / ''; flex: none; padding: 0 6px; color: var(--tertiary); }
290
+ .trail a, .trail-here span { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; line-height: 32px; }
291
+ .trail a { color: var(--accent); }
292
+ .trail-here span { color: var(--ink); font-weight: 600; }
293
+ .trail .trail-more[hidden] { display: none; }
294
+ .trail.trail-fit li { flex-shrink: 0; }
295
+ .trail .trail-cut { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
296
+ }
297
+
268
298
  /* ── Dictation and Dictionary ─────────────────────────── */
269
299
  .cx-pair { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
270
300
  .cx-pair .heard { color: var(--sub); text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--sub) 60%, transparent); }
@@ -173,6 +173,8 @@
173
173
  </header>
174
174
 
175
175
  <main class="workspace" id="workspace">
176
+ <!-- 7.0.30, Miles's pick B: how you got here, on a phone (app.js paintTrail), from Back's own marks. -->
177
+ <nav class="trail" id="nav-trail" aria-label="How you got here" hidden></nav>
176
178
  <!-- W1: the Mac not answering, and the sign-in ending soon (app.js "Mac reachability and sign-in lifetime") -->
177
179
  <div id="conn-notice" class="conn-notice" hidden></div>
178
180
  <!-- SESSION QUESTIONS (panes/questions.js, 6.99.12): over every pane while one waits -->
@@ -215,7 +215,32 @@ html.kb-up .cc-kb .cc-model .field { width: 100%; }
215
215
  .cx-reply-sub { margin: 2px 0 0; font-size: 13px; line-height: 1.4; color: var(--sub); }
216
216
  .cx-reply-feed { min-height: 0; padding: 10px 0 0; }
217
217
  .cx-reply-feed:empty { display: none; }
218
- .cx-reply .cc-composer { position: static; background: transparent; border-top: 0; padding: 10px 0 0; }
218
+ /* 7.0.30 item 2: the Reply box is the reader's last child, outside .cx-reply, so .cc-composer's own rule pins it to the
219
+ bottom of the screen (sticky, the page's colour, a hairline above). With the keyboard up for it (cos-chat.js sets
220
+ html.kb-reply; app.js publishes --vv-below, the layout viewport under the visible box) it sits on the keyboard, the
221
+ same way the chat's box does; the home indicator is under the keyboard, so its inset goes. */
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
+ }
219
244
 
220
245
  /* ── the Messages badge in the rail and the section menu ─ */
221
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; }
@@ -1549,8 +1549,11 @@
1549
1549
  return '<section class="cc cx-reply" data-reply-host="' + esc(key) + '" aria-label="' + esc(label) + '">' +
1550
1550
  '<h3 class="cx-reply-title">Reply</h3>' +
1551
1551
  '<p class="cx-reply-sub">' + esc(k.sessionId ? 'Continues this conversation. The answer lands in Messages with its own number.' : 'Starts a conversation. The answer lands in Messages with its own number.') + '</p>' +
1552
- '<div class="cc-feed cx-reply-feed" data-reply-feed="' + esc(key) + '" aria-live="polite">' + replyFeedHtml(key) + '</div>' +
1553
- composerHtml(k, { drop: false, placeholder: label, label: label }) + '</section>';
1552
+ '<div class="cc-feed cx-reply-feed" data-reply-feed="' + esc(key) + '" aria-live="polite">' + replyFeedHtml(key) + '</div>' + '</section>' +
1553
+ // 7.0.30 item 2 (Anand: "the reply box stays at the bottom"): the box sits OUTSIDE the section, as a child of the
1554
+ // reader's column, so the composer's own sticky rule keeps it at the bottom of the screen while he reads the
1555
+ // message above (a sticky box cannot leave its parent, and the section is only at the end of the page).
1556
+ composerHtml(k, { drop: false, placeholder: label, label: label }).replace('class="cc-composer"', 'class="cc-composer cx-reply-form"');
1554
1557
  }
1555
1558
  function replyFeedHtml(key) {
1556
1559
  return C.asks.filter(function (a) { return a.replyKey === replyName(key); }).map(function (a) { return askHtml(a, false); }).join('');
@@ -2396,6 +2399,9 @@
2396
2399
  if (form && form.classList) form.classList.add('cc-kb');
2397
2400
  var inChat = !!(a && C.host && C.host.contains && C.host.contains(a));
2398
2401
  if (de && de.classList) de.classList.toggle('kb-chat', inChat);
2402
+ // 7.0.30 item 2: the message reader's Reply box, the same kit (cos-chat.css lifts it onto the keyboard).
2403
+ var inReply = !!(form && form.classList && typeof form.classList.contains === 'function' && form.classList.contains('cx-reply-form'));
2404
+ if (de && de.classList) de.classList.toggle('kb-reply', inReply);
2399
2405
  return inChat;
2400
2406
  }
2401
2407
  // The composer that has the keyboard, brought into view on open and on every later change of the visible box (a fold
@@ -2403,7 +2409,7 @@
2403
2409
  // (a steer box or the search box is left where it is).
2404
2410
  function revealComposer() {
2405
2411
  var a = document.activeElement, de = document.documentElement;
2406
- if (!a || !de || !de.classList || !de.classList.contains('kb-chat')) return false;
2412
+ if (!a || !de || !de.classList || !(de.classList.contains('kb-chat') || de.classList.contains('kb-reply'))) return false;
2407
2413
  var form = a.closest ? a.closest('[data-cos-form]') : null;
2408
2414
  if (!form || !form.scrollIntoView) return false;
2409
2415
  form.scrollIntoView({ block: 'nearest' });
@@ -28,6 +28,10 @@
28
28
  .mt-day:not(:disabled):hover { background: var(--qopen); }
29
29
  .mt-day[aria-pressed='true'] { background: color-mix(in srgb, var(--tint) 18%, transparent); font-weight: 600; }
30
30
  .mt-day:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
31
+ /* 7.0.30 item 4: today is marked with a ring in the theme's accent (no fill, so a picked day still reads as picked). */
32
+ .mt-day.mt-today { box-shadow: inset 0 0 0 1.5px var(--accent); }
33
+ .mt-day.mt-today .mt-dnum { font-weight: 600; }
34
+ .mt-jump-row { margin: 0 2px 12px; }
31
35
  .mt-dot { width: 5px; height: 5px; border-radius: 50%; background: transparent; }
32
36
  .mt-dot.on { background: var(--tint); }
33
37
  .mt-all { margin-top: 8px; }