@leftoff/cli 0.9.17-beta.5 → 0.9.17-beta.6

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
@@ -4,6 +4,7 @@ What changed in Leftoff (called Checkpoints until 0.4.5), newest first. The boar
4
4
 
5
5
  ## Unreleased
6
6
  - **Walkthrough: file status, standard diff colours, steadier scrolling.** Files show added, modified, deleted or renamed as GitHub-style square icons (+, dot, −, arrow) with the word in a tip. Diffs use GitHub's green and red everywhere, a step stronger in the line numbers. In Files, the file list and the diff scroll separately (on a phone the list folds above the diff). A slide no longer jumps back to the top when the session updates in the background, and the slides / one-page button has a tip.
7
+ - **The left nav on every screen.** The sidebar no longer steps aside in full screen: it stays beside a session, the walkthrough, Settings and PRs, so there's no detour through Board. Its button at the bottom (or ⌘B, Ctrl+B off a Mac, or [ when you're not typing) collapses it to a slim rail of icons, each with its name on hover, and expands it again; it's remembered on this device and opening a session never changes it. The current page is marked by a filled pill and a bar, not colour alone. When the window is too narrow, or the session panel leaves the chat too little room, it collapses for that view and comes back when there's room, without changing your choice. On a phone, ☰ in full screen's bar opens the nav as a drawer (close it with the scrim, Esc, a swipe left or by picking a page); in a Home Screen app or a browser other than Safari, a swipe in from the left edge opens it too (Safari keeps that edge for Back). Reload the board after updating.
7
8
  - **The downloaded walkthrough shows its code again.** In the .html slideshow, the hidden slide switches shared a style with the diff lines, so every code line was hidden and slides showed only their notes.
8
9
  - **Walk me through it.** Next to Catch me up in a session's panel, **Walk me through it** opens the session's changes as a story in place of the chat (‹ Chat goes back): a one-line summary ("9 files · 2 that matter · ~2 min read"), a bar of Core, Supporting, Mechanical and Generated drawn with a pattern and a word as well as a colour, then the changes in reading order. Core steps show their code; Supporting steps are one line with **Show code**. Things worth a second look sit under the line they mean with a glyph and a word (Look closely, Assumption, Untested), and the line is marked in the gutter. Tests that only follow along, imports, whitespace, renames, lockfiles and the same edit repeated in many files fold into **Safe to skip** groups, and a line at the end says every changed line is in a step or a group. Read since the last checkpoint, the whole session, or the session's PR (the newest checkpoint on the timeline also has **Walk through since here**). **Files** shows the plain diff file by file. Filter to **Look closely**, **Mark reviewed** each step ("1 of 4 reviewed", kept in the session's record like a pin, on every Mac), move with j and k, and **Ask about this change…** puts your question, with its file and line, in the chat box. The eye on the session's tab and board card says how many things are left to look closely at. A big change gets a file map, and is read folder by folder. **Download .html** saves one self-contained page; **Copy as Markdown** is ready to paste into a PR. On a phone it's a tall sheet. Catch me up and Walk me through it always fit in the panel, never clipped: in a narrow panel "Away 3h" becomes a small line under Catch me up, and narrower still they stack. On a desktop its controls are compact and sit on one row with the exports (dimmed, with a reason, when there's nothing to export yet); if nothing changed since the last checkpoint it shows the whole session instead of a dead end. Who writes it is set under Settings → Models & summaries (below); with no model, the plain diff and a note. After updating, run `leftoff restart`.
9
10
  - **A session opens full screen, with its page beside the chat.** Opening a session from a card, an Open chip, a tab or a link lands in full-screen Live. On the right, a panel for that session: its name, status, repo · branch and machine; **Catch me up** at the top ("Away 3 hours · Catch me up" after time away) with **Checkpoint now** beside it; then checkpoints (newest first), what it did and details; a Terminal toggle at the bottom. Timeline, Copy resume, Copy Live resume and Done are in its ⋯. Hide it to a slim rail with ⌘\ or its chevron; it stays the way you left it. On a phone it's a sheet along the bottom that shows one line ("Working · 4 checkpoints") and Catch me up; tap it to pull it up. Between phone and laptop widths it starts as the rail, so the chat has room, until you open it. The separate session page is gone, so there's one layout. The Board tab (or Back) goes to the board; Esc in the message box clears a suggestion and no longer leaves.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.17-beta.5",
3
+ "version": "0.9.17-beta.6",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "One Silly App"
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
 
30
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.17-beta.5' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.17-beta.6' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.17-beta.5",
3
+ "version": "0.9.17-beta.6",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -2862,12 +2862,21 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2862
2862
  .tab-all[hidden] { display: none; }
2863
2863
  }
2864
2864
  @media (min-width: 700px) and (pointer: coarse) { .tab-keep { opacity: 1; } }
2865
- /* the rail as icons (700–1099px): the labels stay for screen readers */
2865
+ /* the rail as icons: collapsed by you or by a short room (html.nav-slim, ui/navrail.js); before the
2866
+ script runs, 700–1099px starts that way. The labels stay for screen readers. */
2866
2867
  @media (min-width: 700px) and (max-width: 1099.98px) {
2867
- :root { --rail-w: 64px; }
2868
- .tabs a .lbl, .topright .lbl, .topright .sb-lbl, .topright .health .label { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
2869
- .tabs a, .topright .searchbtn, .topright .iconbtn, .topright .health { min-height: 48px; padding: 6px 2px; }
2870
- .navcount { left: calc(50% + 2px); }
2868
+ html:not(.nav-ready) { --rail-w: 56px; }
2869
+ html:not(.nav-ready) :is(.tabs a .lbl, .topright .lbl, .topright .sb-lbl, .topright .health .label) { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
2870
+ }
2871
+ @media (min-width: 700px) {
2872
+ html.nav-slim { --rail-w: 56px; }
2873
+ html.nav-slim :is(.tabs a .lbl, .topright .lbl, .topright .sb-lbl, .topright .health .label) { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
2874
+ html.nav-slim :is(.tabs a, .topright .searchbtn, .topright .iconbtn, .topright .health) { min-height: 44px; padding: 6px 2px; }
2875
+ html.nav-slim .navcount { left: calc(50% + 2px); top: 1px; }
2876
+ html.nav-slim .brand .logo { width: 26px; height: 26px; }
2877
+ }
2878
+ @media (min-width: 700px) and (hover: hover) and (pointer: fine) { /* 44px is for fingers */
2879
+ html.nav-slim :is(.tabs a, .topright .searchbtn, .topright .iconbtn, .topright .health) { min-height: 34px; padding: 4px 2px; }
2871
2880
  }
2872
2881
  /* phones (under 700px): the Open row on the board, ‹ N of M › on a session page */
2873
2882
  @media (max-width: 699.98px) {
@@ -3980,13 +3989,11 @@ a.fact.fact-link[data-tip]::after { content: attr(data-tip); display: none; posi
3980
3989
 
3981
3990
  /* ------------------------------------------------------------ 0.9.14 full screen keeps the open tabs
3982
3991
  From 700px: the board's own tab bar stays pinned at the top, full screen fills the
3983
- space under it, and the left rail steps aside until you leave full screen.
3992
+ space under it and beside the left rail (0.9.18: the rail no longer steps aside).
3984
3993
  Under 700px (no tab bar there): one row of compact chips under the bar, 44px tall,
3985
3994
  never two rows: it scrolls sideways. */
3986
3995
  @media (min-width: 700px) {
3987
- html.live-full body { padding-left: 0; }
3988
- html.live-full .top { display: none !important; }
3989
- html.live-full .tabstrip { position: fixed; top: 0; left: 0; right: 0; z-index: 33; }
3996
+ html.live-full .tabstrip { position: fixed; top: 0; left: calc(var(--rail-w) + env(safe-area-inset-left)); right: 0; z-index: 33; } /* 0.9.18: the rail stays beside full screen */
3990
3997
  html.live-full .live-box.full { top: calc(var(--lf-top, 0px) + var(--lf-bar, 44px)); height: calc(var(--lf-h, 100vh) - var(--lf-bar, 44px)); height: calc(var(--lf-h, 100dvh) - var(--lf-bar, 44px)); }
3991
3998
  .lf-tabs { display: none !important; }
3992
3999
  }
@@ -4935,3 +4942,64 @@ details[open] > summary .wt-g-chev { transform: rotate(90deg); }
4935
4942
  /* narrow / a phone: arrows and dots only */
4936
4943
  @media (max-width: 699.98px) { .wt-dnow, .wt-dcount, .wt-dl { display: none; } .wt-dnav { gap: 4px; padding: 4px 6px calc(4px + env(safe-area-inset-bottom)); } .wt-dnav .btn { width: 44px; padding: 0; justify-content: center; } }
4937
4944
  @container (max-width: 560px) { .wt-dnow { display: none; } }
4945
+
4946
+ /* ------------------------------------------------------------ 0.9.18 the left nav on every screen (ui/navrail.js)
4947
+ From 700px the rail stays beside every page, full screen and the walkthrough too: expanded
4948
+ (84px, icon over label) or collapsed (56px, icons with a tip), its toggle at the foot or [.
4949
+ The current item: a filled pill and a bar at the rail's edge (shape, not color alone).
4950
+ Under 700px: a menu button in full screen's bar (and an edge swipe) opens it as a drawer. */
4951
+ .navtoggle, .lf-menu { display: none; }
4952
+ .railtip { position: fixed; z-index: 60; left: 0; top: 0; transform: translateY(-50%); width: max-content; max-width: 260px; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; white-space: nowrap; pointer-events: none; animation: railtip-in .12s ease-out both; }
4953
+ @keyframes railtip-in { from { opacity: 0; } to { opacity: 1; } }
4954
+ .railtip[hidden] { display: none; }
4955
+ @media (min-width: 700px) {
4956
+ html.live-full .top { z-index: 34; }
4957
+ html.live-full .live-box.full { left: calc(var(--rail-w) + env(safe-area-inset-left)); }
4958
+ .term-dock { left: calc(var(--rail-w) + env(safe-area-inset-left)); } /* the terminal docks beside the rail, its toggle stays reachable */
4959
+ .top [data-tip]::after { display: none !important; } /* the rail scrolls and would cut a CSS tip off: one floating .railtip instead */
4960
+ .tabs a.on::before, .topright .iconbtn.on::before { content: ''; position: absolute; left: -8px; top: 50%; width: 3px; height: 22px; transform: translateY(-50%); border-radius: 0 3px 3px 0; background: var(--accent); }
4961
+ .topright .navtoggle { order: 10; display: flex; color: var(--faint); }
4962
+ .topright .navtoggle:hover { color: var(--text); }
4963
+ .topright .navtoggle[aria-disabled="true"] { opacity: .55; cursor: default; }
4964
+ .topright .navtoggle[aria-disabled="true"]:hover { background: none; color: var(--faint); }
4965
+ .top :is(a, button):focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
4966
+ html.nav-anim .top { transition: width .18s ease; }
4967
+ html.nav-anim body { transition: padding-left .18s ease; }
4968
+ html.nav-anim .live-box.full, html.nav-anim .tabstrip, html.nav-anim .term-dock { transition: left .18s ease; }
4969
+ .navdrawer, .navscrim { display: none !important; }
4970
+ }
4971
+ @media (prefers-reduced-motion: reduce) {
4972
+ .railtip { animation: none; }
4973
+ html.nav-anim .top, html.nav-anim body, html.nav-anim .live-box.full, html.nav-anim .tabstrip, html.nav-anim .term-dock { transition: none; }
4974
+ }
4975
+ @media (max-width: 699.98px) {
4976
+ .live-fbar .lf-menu { display: inline-flex; align-items: center; justify-content: center; flex: none; position: relative; width: 44px; height: 44px; min-height: 44px; padding: 0; border-color: transparent; background: none; box-shadow: none; color: var(--muted); }
4977
+ .live-fbar .lf-menu .ic { width: 20px; height: 20px; margin: 0; }
4978
+ .live-fbar .lf-menu:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
4979
+ .live-fbar .lf-menu + .live-fs-x { margin-left: -6px; }
4980
+ .navtoggle { display: none !important; }
4981
+ }
4982
+ .navscrim { position: fixed; inset: 0; z-index: 70; background: rgba(8, 10, 16, .42); opacity: 0; transition: opacity .2s ease; -webkit-tap-highlight-color: transparent; }
4983
+ .navscrim.open { opacity: 1; }
4984
+ .navscrim[hidden], .navdrawer[hidden] { display: none; }
4985
+ .navdrawer { position: fixed; z-index: 71; top: 0; bottom: 0; left: 0; width: min(300px, 84vw); display: flex; flex-direction: column; box-sizing: border-box; padding: max(10px, env(safe-area-inset-top)) 10px max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); background: var(--bg); border-right: 1px solid var(--line); box-shadow: 0 0 40px rgba(0, 0, 0, .25); overflow-y: auto; overscroll-behavior: contain; transform: translateX(-100%); transition: transform .2s ease; outline: none; }
4986
+ .navdrawer.open { transform: none; }
4987
+ @media (prefers-reduced-motion: reduce) { .navdrawer, .navscrim { transition: none; } }
4988
+ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
4989
+ .nd-head { display: flex; align-items: center; gap: 10px; min-height: 44px; margin: 0 0 6px; padding: 0 0 6px 8px; border-bottom: 1px solid var(--line); }
4990
+ .nd-head .logo { width: 22px; height: 22px; }
4991
+ .nd-title { flex: 1 1 auto; font-size: 15px; font-weight: 650; color: var(--text); }
4992
+ .nd-x { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 10px; background: none; color: var(--muted); cursor: pointer; }
4993
+ .nd-x:hover { color: var(--text); background: var(--bg2); }
4994
+ .nd-x .ic { width: 18px; height: 18px; }
4995
+ .nd-list { display: flex; flex-direction: column; gap: 2px; }
4996
+ .nd-item { position: relative; display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 12px; border-radius: 10px; color: var(--text2); font-size: 15px; font-weight: 550; text-decoration: none; -webkit-tap-highlight-color: transparent; }
4997
+ .nd-item .ic { flex: none; width: 20px; height: 20px; }
4998
+ .nd-item:hover { background: var(--bg2); color: var(--text); }
4999
+ .nd-item.on { background: var(--rail-on); color: var(--text); }
5000
+ .nd-item.on .ic { color: var(--accent); }
5001
+ .nd-item.on::before { content: ''; position: absolute; left: 0; top: 50%; width: 3px; height: 22px; transform: translateY(-50%); border-radius: 0 3px 3px 0; background: var(--accent); }
5002
+ .nd-lbl { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5003
+ .nd-cur { flex: none; font-size: 12px; font-weight: 500; color: var(--muted); }
5004
+ .nd-n { flex: none; display: inline-flex; align-items: center; justify-content: center; height: 20px; min-width: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 10px; background: var(--red); color: #fff; font-size: 11.5px; font-weight: 650; line-height: 1; }
5005
+ .nd-item:focus-visible, .nd-x:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
package/ui/app.js CHANGED
@@ -71,6 +71,209 @@ import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claud
71
71
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
72
72
  import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
73
73
  import { glyphState, glyphHtml, glyphWords, lastReplyOf, seenAtFor, ensureSeenBase, markSeen, GLYPH_LABEL, offlineWords, offlineTip, waitingOnYou } from './sglyph.js' // 0.9.14 status glyphs
74
+ import { createNavStore, navState, toggleWords, isNavKey, navKeyLabel, safariOwnsEdge, edgeStart, swipeIntent, drawerHtml, RAIL_MIN, RAIL_W_SLIM } from './navrail.js'
75
+
76
+ // ------------------------------------------------------------ 0.9.18 the left nav on every screen (ui/navrail.js)
77
+ // From 700px the rail stays beside every page (full screen and the walkthrough
78
+ // too), expanded or collapsed by its toggle or [ and remembered; a short room
79
+ // collapses it for that view without touching the choice. Under 700px a menu
80
+ // button (or a swipe in from the left edge where Safari doesn't own it) opens
81
+ // the nav as a drawer.
82
+ const navStore = createNavStore(window.localStorage)
83
+ const navUi = { shown: null, drawer: false, opener: null, closeT: 0, animT: 0, tipT: 0, tipFor: null, sw: null }
84
+ const navRailQ = window.matchMedia('(min-width: ' + RAIL_MIN + 'px)')
85
+ const navReduced = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
86
+ const navMac = /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent)
87
+ const navEdgeOk = !safariOwnsEdge(navigator.userAgent, navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches)
88
+ // the room the session panel works in: the window less the (at least slim) rail
89
+ function fsViewport() { return window.innerWidth - (navRailQ.matches ? RAIL_W_SLIM : 0) }
90
+ // the session panel open beside a full-screen chat, else 0
91
+ function navSideWidth() {
92
+ const de = document.documentElement, side = document.getElementById('fsSide')
93
+ if (!side || !de.classList.contains('live-full') || side.classList.contains('collapsed')) return 0
94
+ return Math.round(side.getBoundingClientRect().width) || 0
95
+ }
96
+ function applyNav() {
97
+ const st = navState({ width: window.innerWidth, saved: navStore.collapsed(), side: navSideWidth() })
98
+ const de = document.documentElement
99
+ de.classList.add('nav-ready')
100
+ de.classList.toggle('nav-slim', st.rail && st.collapsed)
101
+ de.classList.toggle('nav-auto', st.rail && st.auto)
102
+ const b = document.getElementById('navToggle')
103
+ if (b) {
104
+ const w = toggleWords(st, navKeyLabel(navMac))
105
+ b.setAttribute('aria-expanded', st.collapsed ? 'false' : 'true')
106
+ b.setAttribute('aria-label', w.label)
107
+ b.setAttribute('aria-disabled', st.auto ? 'true' : 'false')
108
+ b.dataset.tip = w.tip
109
+ const lbl = b.querySelector('.lbl')
110
+ if (lbl) lbl.textContent = st.collapsed ? 'Expand' : 'Collapse'
111
+ if (b.dataset.icon !== w.icon) { b.dataset.icon = w.icon; const svg = b.querySelector('svg'); if (svg) svg.outerHTML = icon(w.icon) }
112
+ if (navUi.tipFor === b) railTipShow(b, true)
113
+ }
114
+ const changed = !navUi.shown || navUi.shown.collapsed !== st.collapsed || navUi.shown.rail !== st.rail
115
+ navUi.shown = st
116
+ if (st.rail && navUi.drawer) closeDrawer(false)
117
+ return changed
118
+ }
119
+ function toggleNav() {
120
+ const st = navUi.shown || navState({ width: window.innerWidth, saved: navStore.collapsed(), side: navSideWidth() })
121
+ if (!st.rail) return false
122
+ if (st.auto) { const b = document.getElementById('navToggle'); if (b) railTipShow(b, true); return false } // no room: say so, change nothing
123
+ const de = document.documentElement
124
+ if (!navReduced()) { de.classList.add('nav-anim'); clearTimeout(navUi.animT); navUi.animT = setTimeout(() => de.classList.remove('nav-anim'), 260) }
125
+ navStore.set(!st.collapsed)
126
+ applyNav()
127
+ return true
128
+ }
129
+ // one floating tip for the rail (the rail scrolls, so a CSS ::after would be cut off at its edge)
130
+ function railTipShow(el, now) {
131
+ const tip = document.getElementById('railTip')
132
+ if (!tip || !el || !navRailQ.matches) return
133
+ const text = el.dataset.tip || ''
134
+ const lbl = el.querySelector('.lbl, .sb-lbl, .label')
135
+ const words = lbl ? lbl.textContent.trim() : ''
136
+ // expanded, the label's already there; the tip shows only when it says more
137
+ if (!text || (!document.documentElement.classList.contains('nav-slim') && text === words)) { railTipHide(); return }
138
+ clearTimeout(navUi.tipT)
139
+ const show = () => {
140
+ if (!el.isConnected) return
141
+ tip.textContent = text
142
+ tip.hidden = false
143
+ const r = el.getBoundingClientRect()
144
+ const rail = document.getElementById('navRail')
145
+ tip.style.left = Math.round((rail ? rail.getBoundingClientRect().right : r.right) + 6) + 'px'
146
+ tip.style.top = Math.round(r.top + r.height / 2) + 'px'
147
+ navUi.tipFor = el
148
+ }
149
+ if (now) show(); else navUi.tipT = setTimeout(show, 350)
150
+ }
151
+ function railTipHide() {
152
+ clearTimeout(navUi.tipT)
153
+ const tip = document.getElementById('railTip')
154
+ if (tip) tip.hidden = true
155
+ navUi.tipFor = null
156
+ }
157
+ const navTipTarget = (t) => t && t.closest && t.closest('#navRail [data-tip]:not(#needsTab)') // Needs you has Quick look on hover
158
+ document.addEventListener('pointerover', (e) => {
159
+ if (e.pointerType === 'touch') return
160
+ const el = navTipTarget(e.target)
161
+ if (!el) return
162
+ if (el !== navUi.tipFor) railTipShow(el, false)
163
+ })
164
+ document.addEventListener('pointerout', (e) => {
165
+ const el = navTipTarget(e.target)
166
+ if (el && !(e.relatedTarget && el.contains(e.relatedTarget)) && document.activeElement !== el) railTipHide()
167
+ })
168
+ document.addEventListener('focusin', (e) => {
169
+ const el = navTipTarget(e.target)
170
+ if (el && el.matches(':focus-visible')) railTipShow(el, true)
171
+ else if (navUi.tipFor && navUi.tipFor !== el) railTipHide()
172
+ })
173
+ document.addEventListener('focusout', (e) => { if (navTipTarget(e.target)) railTipHide() })
174
+ window.addEventListener('scroll', railTipHide, true)
175
+ document.addEventListener('click', (e) => {
176
+ const t = e.target
177
+ if (!t || !t.closest) return
178
+ if (t.closest('#navToggle')) { e.preventDefault(); toggleNav(); return }
179
+ const open = t.closest('[data-nav-drawer]')
180
+ if (open) { e.preventDefault(); if (navUi.drawer) closeDrawer(true); else openDrawer(open); return }
181
+ if (!navUi.drawer) return
182
+ if (t.closest('#navScrim') || t.closest('[data-nd-close]')) { e.preventDefault(); closeDrawer(true); return }
183
+ if (t.closest('#navDrawer .nd-item')) closeDrawer(false) // the link navigates; the page it opens takes focus
184
+ })
185
+ // the drawer (phones): the rail's destinations over the page, a scrim behind
186
+ function navDrawerItems() {
187
+ const items = []
188
+ const take = (a) => {
189
+ if (!a) return
190
+ const lbl = a.querySelector('.lbl')
191
+ const n = a.querySelector('.navcount')
192
+ items.push({ href: a.getAttribute('href'), route: a.dataset.route || '', label: lbl ? lbl.textContent.trim() : a.getAttribute('aria-label') || '', svg: (a.querySelector('svg') || {}).outerHTML || '', on: a.classList.contains('on'), count: n && !n.hidden ? n.textContent.trim() : '' })
193
+ }
194
+ for (const a of document.querySelectorAll('#navRail .tabs a[data-route]')) take(a)
195
+ for (const id of ['newTab', 'wnTab', 'setTab']) take(document.getElementById(id))
196
+ return items
197
+ }
198
+ function openDrawer(opener) {
199
+ if (navRailQ.matches || navUi.drawer) return
200
+ const d = document.getElementById('navDrawer'), s = document.getElementById('navScrim')
201
+ if (!d || !s) return
202
+ clearTimeout(navUi.closeT)
203
+ d.innerHTML = drawerHtml(navDrawerItems())
204
+ navUi.opener = opener || (document.activeElement !== document.body ? document.activeElement : null)
205
+ d.hidden = false; s.hidden = false
206
+ void d.offsetWidth
207
+ d.classList.add('open'); s.classList.add('open')
208
+ document.documentElement.classList.add('nav-drawer-open')
209
+ navUi.drawer = true
210
+ for (const b of document.querySelectorAll('[data-nav-drawer]')) b.setAttribute('aria-expanded', 'true')
211
+ const f = d.querySelector('.nd-item.on') || d.querySelector('.nd-item')
212
+ if (f) f.focus({ preventScroll: true })
213
+ }
214
+ function closeDrawer(restore) {
215
+ if (!navUi.drawer) return
216
+ const d = document.getElementById('navDrawer'), s = document.getElementById('navScrim')
217
+ navUi.drawer = false
218
+ d.classList.remove('open'); s.classList.remove('open')
219
+ document.documentElement.classList.remove('nav-drawer-open')
220
+ for (const b of document.querySelectorAll('[data-nav-drawer]')) b.setAttribute('aria-expanded', 'false')
221
+ const done = () => { if (navUi.drawer) return; d.hidden = true; s.hidden = true; d.innerHTML = '' }
222
+ clearTimeout(navUi.closeT)
223
+ if (navReduced()) done(); else navUi.closeT = setTimeout(done, 200)
224
+ const o = navUi.opener
225
+ navUi.opener = null
226
+ if (restore && o && o.isConnected) o.focus({ preventScroll: true })
227
+ else if (restore) { const m = document.querySelector('[data-nav-drawer]'); if (m && m.offsetParent) m.focus({ preventScroll: true }) }
228
+ }
229
+ document.addEventListener('keydown', (e) => {
230
+ if (navUi.drawer) {
231
+ const d = document.getElementById('navDrawer')
232
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeDrawer(true); return }
233
+ if (e.key === 'Tab') { // focus stays inside while it's open
234
+ const f = [...d.querySelectorAll('a[href], button:not([disabled])')]
235
+ if (!f.length) return
236
+ const i = f.indexOf(document.activeElement)
237
+ if (e.shiftKey && (i <= 0)) { e.preventDefault(); f[f.length - 1].focus() }
238
+ else if (!e.shiftKey && (i === -1 || i === f.length - 1)) { e.preventDefault(); f[0].focus() }
239
+ }
240
+ return
241
+ }
242
+ if (e.defaultPrevented) return
243
+ const t = e.target
244
+ const term = !!(t && t.closest && t.closest('.xterm, .term-dock'))
245
+ const typing = term || !!(t && t.closest && t.closest('input, textarea, select, [contenteditable]'))
246
+ if (!isNavKey(e, { typing, term, mac: navMac }) || !navRailQ.matches) return
247
+ if (document.querySelector('.lo-menu, .cmdk:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden])')) return
248
+ if ([...document.querySelectorAll('[role="dialog"][aria-modal="true"]')].some((d) => d.id !== 'navDrawer' && !d.closest('[hidden]') && d.getClientRects().length)) return
249
+ e.preventDefault()
250
+ toggleNav()
251
+ }, true)
252
+ // a swipe in from the left edge opens it, a swipe left on it closes it (phones; not where Safari's back-swipe owns the edge)
253
+ function navOwnsTouch(x) { return !navRailQ.matches && (navUi.drawer || (navEdgeOk && edgeStart(x))) }
254
+ document.addEventListener('touchstart', (e) => {
255
+ navUi.sw = null
256
+ if (navRailQ.matches || e.touches.length !== 1) return
257
+ const p = e.touches[0]
258
+ if (navUi.drawer) { navUi.sw = { x: p.clientX, y: p.clientY, open: true }; return }
259
+ if (navEdgeOk && edgeStart(p.clientX)) navUi.sw = { x: p.clientX, y: p.clientY, open: false }
260
+ }, { passive: true })
261
+ document.addEventListener('touchend', (e) => {
262
+ const sw = navUi.sw
263
+ navUi.sw = null
264
+ if (!sw || !e.changedTouches.length) return
265
+ const p = e.changedTouches[0]
266
+ const what = swipeIntent(p.clientX - sw.x, p.clientY - sw.y, sw.open)
267
+ if (what === 'open') openDrawer(null)
268
+ else if (what === 'close') closeDrawer(true)
269
+ }, { passive: true })
270
+ {
271
+ let raf = 0
272
+ window.addEventListener('resize', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; railTipHide(); applyNav() }) })
273
+ navRailQ.addEventListener('change', () => { if (navRailQ.matches) closeDrawer(false) })
274
+ }
275
+ applyNav()
276
+
74
277
  const $app = document.getElementById('app')
75
278
  // 0.9.17 Walk me through it: which session shows it, and its view (this page only; the open one survives a reload)
76
279
  const wtUi = { sid: null, scope: new Map(), view: 'story', filter: 'all', data: new Map(), loading: new Set(), error: new Map(), cur: new Map(), code: new Set(), groups: new Set(), pending: [], ask: '', node: null, sig: '', restored: false, fellBack: new Set() }
@@ -1887,7 +2090,7 @@ function paintNeedsCount(n) {
1887
2090
  const c = document.getElementById('needsCount')
1888
2091
  if (c) { c.hidden = !n; c.textContent = badgeText(n) }
1889
2092
  const tab = document.getElementById('needsTab')
1890
- if (tab) { tab.classList.toggle('has-needs', !!n); tab.title = n ? n + ' waiting on you' : 'Nothing waiting on you' }
2093
+ if (tab) { tab.classList.toggle('has-needs', !!n); tab.dataset.tip = n ? 'Needs you · ' + n + ' waiting' : 'Needs you' }
1891
2094
  if (quickLookOpen() && paintNeedsCount.count !== n) loadQuickLook()
1892
2095
  paintNeedsCount.count = n
1893
2096
  document.title = (n ? '(' + n + ') ' : '') + 'Leftoff'
@@ -2380,7 +2583,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2380
2583
  side += answeredList(s)
2381
2584
  const foot = '<div class="fs-foot"><button type="button" class="btn fs-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + 'Terminal</button>' + termTabButtonHtml(s) + '</div>' // Open terminal (a window on the Mac): the head's ⋯
2382
2585
  const label = fsPeekWords(s)
2383
- return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayMs)) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(window.innerWidth), viewport: window.innerWidth }, { esc, icon })
2586
+ return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayMs)) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2384
2587
  }
2385
2588
  // The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
2386
2589
  // "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
@@ -2606,26 +2809,27 @@ function exitFull(id) {
2606
2809
  const fsResize = { drag: null, raf: 0 }
2607
2810
  function applyFsWidth(w) {
2608
2811
  const de = document.documentElement
2609
- const n = clampSideWidth(w, window.innerWidth)
2812
+ const n = clampSideWidth(w, fsViewport())
2610
2813
  if (n === SIDE_W_DEFAULT && fsWidth.stored() == null) de.style.removeProperty('--fs-uw')
2611
2814
  else de.style.setProperty('--fs-uw', n + 'px')
2612
- for (const h of document.querySelectorAll('.fs-resize')) { h.setAttribute('aria-valuenow', String(n)); h.setAttribute('aria-valuemax', String(sideMax(window.innerWidth))) }
2815
+ for (const h of document.querySelectorAll('.fs-resize')) { h.setAttribute('aria-valuenow', String(n)); h.setAttribute('aria-valuemax', String(sideMax(fsViewport()))) }
2816
+ applyNav()
2613
2817
  return n
2614
2818
  }
2615
- applyFsWidth(fsWidth.shown(window.innerWidth))
2616
- window.addEventListener('resize', () => { if (!fsResize.drag) applyFsWidth(fsWidth.shown(window.innerWidth)) })
2819
+ applyFsWidth(fsWidth.shown(fsViewport()))
2820
+ window.addEventListener('resize', () => { if (!fsResize.drag) applyFsWidth(fsWidth.shown(fsViewport())) })
2617
2821
  document.addEventListener('pointerdown', (e) => {
2618
2822
  const h = e.target.closest && e.target.closest('.fs-resize')
2619
2823
  if (!h || e.button !== 0 || window.innerWidth < WIDE_MIN) return
2620
2824
  e.preventDefault()
2621
2825
  try { h.setPointerCapture(e.pointerId) } catch {}
2622
- fsResize.drag = { id: e.pointerId, x: e.clientX, w: fsWidth.shown(window.innerWidth), now: null, h }
2826
+ fsResize.drag = { id: e.pointerId, x: e.clientX, w: fsWidth.shown(fsViewport()), now: null, h }
2623
2827
  document.documentElement.classList.add('fs-resizing')
2624
2828
  })
2625
2829
  document.addEventListener('pointermove', (e) => {
2626
2830
  const d = fsResize.drag
2627
2831
  if (!d || e.pointerId !== d.id) return
2628
- d.now = clampSideWidth(d.w + (d.x - e.clientX), window.innerWidth) // the handle is the panel's left edge: left is wider
2832
+ d.now = clampSideWidth(d.w + (d.x - e.clientX), fsViewport()) // the handle is the panel's left edge: left is wider
2629
2833
  if (!fsResize.raf) fsResize.raf = requestAnimationFrame(() => { fsResize.raf = 0; if (fsResize.drag && fsResize.drag.now != null) applyFsWidth(fsResize.drag.now) })
2630
2834
  })
2631
2835
  const fsResizeEnd = (e) => {
@@ -2634,7 +2838,7 @@ const fsResizeEnd = (e) => {
2634
2838
  fsResize.drag = null
2635
2839
  document.documentElement.classList.remove('fs-resizing')
2636
2840
  try { d.h.releasePointerCapture(d.id) } catch {}
2637
- if (d.now != null && d.now !== d.w) applyFsWidth(fsWidth.set(d.now, window.innerWidth))
2841
+ if (d.now != null && d.now !== d.w) applyFsWidth(fsWidth.set(d.now, fsViewport()))
2638
2842
  }
2639
2843
  document.addEventListener('pointerup', fsResizeEnd)
2640
2844
  document.addEventListener('pointercancel', fsResizeEnd)
@@ -2642,7 +2846,7 @@ document.addEventListener('dblclick', (e) => { if (e.target.closest && e.target.
2642
2846
  document.addEventListener('keydown', (e) => {
2643
2847
  const h = e.target.closest && e.target.closest('.fs-resize')
2644
2848
  if (!h || e.metaKey || e.ctrlKey || e.altKey) return
2645
- const cur = fsWidth.shown(window.innerWidth)
2849
+ const cur = fsWidth.shown(fsViewport())
2646
2850
  let next = null
2647
2851
  if (e.key === 'ArrowLeft') next = cur + SIDE_W_STEP
2648
2852
  else if (e.key === 'ArrowRight') next = cur - SIDE_W_STEP
@@ -2651,7 +2855,7 @@ document.addEventListener('keydown', (e) => {
2651
2855
  else if (e.key === 'Enter') { e.preventDefault(); fsWidth.reset(); applyFsWidth(SIDE_W_DEFAULT); return }
2652
2856
  if (next == null) return
2653
2857
  e.preventDefault()
2654
- applyFsWidth(fsWidth.set(Number.isFinite(next) ? next : sideMax(window.innerWidth), window.innerWidth))
2858
+ applyFsWidth(fsWidth.set(Number.isFinite(next) ? next : sideMax(fsViewport()), fsViewport()))
2655
2859
  })
2656
2860
  // 0.9.16 the session panel: open, collapsed to its rail (⌘\, remembered), or on a phone a sheet
2657
2861
  function fsSideClasses() {
@@ -2660,6 +2864,7 @@ function fsSideClasses() {
2660
2864
  de.classList.toggle('fs-on', !!side)
2661
2865
  de.classList.toggle('fs-collapsed', !!side && side.classList.contains('collapsed'))
2662
2866
  de.classList.toggle('fs-sheet-up', !!side && side.classList.contains('sheet-up'))
2867
+ applyNav() // 0.9.18: a session panel beside the chat can leave too little room for the expanded rail
2663
2868
  }
2664
2869
  function toggleFsSide(force) {
2665
2870
  const side = document.getElementById('fsSide')
@@ -2730,6 +2935,7 @@ document.addEventListener('keydown', (ev) => {
2730
2935
  function setFullClass(on) {
2731
2936
  document.documentElement.classList.toggle('live-full', !!on)
2732
2937
  if (on) fitFullViewport()
2938
+ applyNav()
2733
2939
  }
2734
2940
  // The window as you see it: on a phone the keyboard takes the bottom of it,
2735
2941
  // and the composer stays just above the keyboard.
@@ -6637,10 +6843,10 @@ async function refreshHealth() {
6637
6843
  // the header shows this Mac's name; the rest is in the status popover (v0.5.9)
6638
6844
  const summary = h.sessionsOpen + ' open · ' + h.machine + ' · ' + h.summarizer
6639
6845
  el.querySelector('.label').textContent = h.machine
6640
- el.title = summary
6846
+ el.dataset.tip = summary
6641
6847
  el.setAttribute('aria-label', 'Status: ' + summary)
6642
6848
  const brand = document.getElementById('brand')
6643
- if (brand) brand.title = 'Leftoff v' + h.version + (h.update && h.update.available ? ' · update available: v' + (h.update.remoteVersion || '?') : '')
6849
+ if (brand) brand.dataset.tip = 'Leftoff v' + h.version + (h.update && h.update.available ? ' · update available: v' + (h.update.remoteVersion || '?') : '')
6644
6850
  paintTopDot()
6645
6851
  if (statusOpen()) paintStatus()
6646
6852
  paintNeedsCount(needsCountOf(h))
@@ -6656,8 +6862,8 @@ async function refreshHealth() {
6656
6862
  if (updateBar) updateBar.check(null) // a failed check changes nothing
6657
6863
  el.className = 'health down'
6658
6864
  el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
6659
- el.title = el.querySelector('.label').textContent
6660
- el.setAttribute('aria-label', 'Status: ' + el.title)
6865
+ el.dataset.tip = el.querySelector('.label').textContent
6866
+ el.setAttribute('aria-label', 'Status: ' + el.dataset.tip)
6661
6867
  }
6662
6868
  }
6663
6869
 
@@ -6667,7 +6873,7 @@ async function refreshHealth() {
6667
6873
  // Settings as icons. Nothing wraps or is cut off from 900px up.
6668
6874
 
6669
6875
  const isMac = /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent)
6670
- { const k = document.getElementById('searchKbd'); if (k) k.textContent = isMac ? '⌘K' : 'Ctrl K'; const b = document.getElementById('searchBtn'); if (b) b.title = 'Search (' + (isMac ? '⌘K' : 'Ctrl+K') + ' or /)' }
6876
+ { const k = document.getElementById('searchKbd'); if (k) k.textContent = isMac ? '⌘K' : 'Ctrl K'; const b = document.getElementById('searchBtn'); if (b) b.dataset.tip = 'Search (' + (isMac ? '⌘K' : 'Ctrl+K') + ' or /)' }
6671
6877
 
6672
6878
  // The gear's dot: anything a Settings section would flag (an update, a failed digest, a feed that can't be pulled, sync trouble).
6673
6879
  function paintTopDot() {
@@ -6681,7 +6887,7 @@ function paintTopDot() {
6681
6887
  el.hidden = !all.length || (!warn && !(h.update && h.update.available))
6682
6888
  el.className = 'topdot' + (warn ? ' warn' : '')
6683
6889
  const why = all.flatMap((x) => x.why)
6684
- document.getElementById('setTab').title = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
6890
+ document.getElementById('setTab').dataset.tip = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
6685
6891
  paintMoreDots()
6686
6892
  }
6687
6893
 
@@ -7206,6 +7412,7 @@ document.addEventListener('click', (e) => { if (e.target.closest && $app.contain
7206
7412
  document.addEventListener('touchstart', (e) => {
7207
7413
  sw = null
7208
7414
  if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
7415
+ if (navOwnsTouch(e.touches[0].clientX)) return // 0.9.18: the left edge (and the open drawer) belong to the nav drawer
7209
7416
  const t = e.target
7210
7417
  // a table (even one that fits) or a code block is for reading and scrolling sideways, not for changing sessions
7211
7418
  if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .lf-tabs, .moremenu, table, .doc-table, pre, #wtDeck, .wt-dnav') || scrollsSideways(t)) return
package/ui/icons.js CHANGED
@@ -3,6 +3,9 @@
3
3
  // so they follow light and dark; always aria-hidden next to a text label.
4
4
  // index.html carries the nav's copies inline (no script needed to draw them).
5
5
  export const ICONS = {
6
+ "panel-left-close": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m16 15-3-3 3-3\"/>",
7
+ "panel-left-open": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m14 9 3 3-3 3\"/>",
8
+ "menu": "<path d=\"M4 5h16\"/> <path d=\"M4 12h16\"/> <path d=\"M4 19h16\"/>",
6
9
  "arrow-up": "<path d=\"m5 12 7-7 7 7\"/> <path d=\"M12 19V5\"/>",
7
10
  "eye": "<path d=\"M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z\"/> <circle cx=\"12\" cy=\"12\" r=\"3\"/>",
8
11
  "circle-help": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3\"/> <path d=\"M12 17h.01\"/>",
package/ui/index.html CHANGED
@@ -20,29 +20,33 @@
20
20
  <link rel="stylesheet" href="app.css">
21
21
  </head>
22
22
  <body>
23
- <header class="top">
24
- <a class="brand" href="#/" id="brand" title="Leftoff" aria-label="Leftoff">
23
+ <header class="top" id="navRail">
24
+ <a class="brand" href="#/" id="brand" data-tip="Leftoff" aria-label="Leftoff">
25
25
  <img src="favicon.svg" class="logo" alt="" width="22" height="22">
26
26
  <span>Leftoff</span>
27
27
  </a>
28
28
  <nav class="tabs" aria-label="Sections">
29
- <a href="#/" data-route="board"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="7" height="9" x="3" y="3" rx="1"/> <rect width="7" height="5" x="14" y="3" rx="1"/> <rect width="7" height="9" x="14" y="12" rx="1"/> <rect width="7" height="5" x="3" y="16" rx="1"/></svg><span class="lbl">Board</span></a>
30
- <a href="#/waiting" data-route="waiting" id="needsTab" aria-haspopup="dialog" aria-expanded="false" aria-controls="quickLook"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
31
- <a href="#/lists" data-route="lists" id="listsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M13 5h8"/> <path d="M13 12h8"/> <path d="M13 19h8"/> <path d="m3 17 2 2 4-4"/> <path d="m3 7 2 2 4-4"/></svg><span class="lbl">Lists</span><span class="navcount" id="listsCount" hidden></span></a>
32
- <a href="#/prs" data-route="prs" id="prsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="18" cy="18" r="3"/> <circle cx="6" cy="6" r="3"/> <path d="M13 6h3a2 2 0 0 1 2 2v7"/> <line x1="6" x2="6" y1="9" y2="21"/></svg><span class="lbl">PRs</span></a>
33
- <a href="#/timeline" data-route="timeline" class="tab-extra"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
34
- <a href="#/digest" data-route="digest" class="tab-extra"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span class="lbl">Digest</span></a>
29
+ <a href="#/" data-route="board" data-tip="Board"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="7" height="9" x="3" y="3" rx="1"/> <rect width="7" height="5" x="14" y="3" rx="1"/> <rect width="7" height="9" x="14" y="12" rx="1"/> <rect width="7" height="5" x="3" y="16" rx="1"/></svg><span class="lbl">Board</span></a>
30
+ <a href="#/waiting" data-route="waiting" id="needsTab" data-tip="Needs you" aria-haspopup="dialog" aria-expanded="false" aria-controls="quickLook"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
31
+ <a href="#/lists" data-route="lists" id="listsTab" data-tip="Lists"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M13 5h8"/> <path d="M13 12h8"/> <path d="M13 19h8"/> <path d="m3 17 2 2 4-4"/> <path d="m3 7 2 2 4-4"/></svg><span class="lbl">Lists</span><span class="navcount" id="listsCount" hidden></span></a>
32
+ <a href="#/prs" data-route="prs" id="prsTab" data-tip="PRs"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="18" cy="18" r="3"/> <circle cx="6" cy="6" r="3"/> <path d="M13 6h3a2 2 0 0 1 2 2v7"/> <line x1="6" x2="6" y1="9" y2="21"/></svg><span class="lbl">PRs</span></a>
33
+ <a href="#/timeline" data-route="timeline" class="tab-extra" data-tip="Timeline"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
34
+ <a href="#/digest" data-route="digest" class="tab-extra" data-tip="Digest"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span class="lbl">Digest</span></a>
35
35
  <button type="button" class="tab-more" id="moreBtn" aria-haspopup="menu" aria-expanded="false" aria-controls="moreMenu"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="1"/> <circle cx="19" cy="12" r="1"/> <circle cx="5" cy="12" r="1"/></svg><span class="lbl">More</span><span class="moredot" hidden></span></button>
36
36
  </nav>
37
37
  <div class="topright">
38
- <button type="button" class="searchbtn" id="searchBtn" title="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
38
+ <button type="button" class="searchbtn" id="searchBtn" data-tip="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
39
39
  <a href="#/new" data-route="new" id="newTab" class="iconbtn newbtn" data-tip="New session" aria-label="New session"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14"/> <path d="M12 5v14"/></svg><span class="lbl">New session</span></a>
40
40
  <button type="button" id="termBtn" class="iconbtn termbtn" data-term-toggle data-tip="Terminal (⌃`)" aria-label="Terminal" aria-expanded="false" aria-controls="termDock" aria-keyshortcuts="Control+`"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 19h8"/> <path d="m4 17 6-6-6-6"/></svg><span class="lbl">Terminal</span></button>
41
- <button type="button" class="health" id="health" title="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
42
- <a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="lbl">What’s new</span><span class="newdot" hidden></span></a>
43
- <a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="lbl">Settings</span><span class="topdot" hidden></span></a>
41
+ <button type="button" class="health" id="health" data-tip="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
42
+ <a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" data-tip="What’s new" aria-label="What’s new"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="lbl">What’s new</span><span class="newdot" hidden></span></a>
43
+ <a href="#/settings" data-route="settings" id="setTab" class="iconbtn" data-tip="Settings" aria-label="Settings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="lbl">Settings</span><span class="topdot" hidden></span></a>
44
+ <button type="button" class="iconbtn navtoggle" id="navToggle" aria-controls="navRail" aria-expanded="true" aria-label="Collapse the sidebar" aria-keyshortcuts="Meta+B Control+B [" data-tip="Collapse sidebar (⌘B)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="18" height="18" x="3" y="3" rx="2"/> <path d="M9 3v18"/> <path d="m16 15-3-3 3-3"/></svg><span class="lbl">Collapse</span></button>
44
45
  </div>
45
46
  </header>
47
+ <div class="navscrim" id="navScrim" hidden></div>
48
+ <div class="navdrawer" id="navDrawer" role="dialog" aria-modal="true" aria-label="Menu" hidden></div>
49
+ <div class="railtip" id="railTip" role="tooltip" hidden></div>
46
50
  <div class="moremenu" id="moreMenu" role="menu" aria-label="More" hidden>
47
51
  <a href="#/timeline" data-route="timeline" role="menuitem"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span>Timeline</span></a>
48
52
  <a href="#/digest" data-route="digest" role="menuitem"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span>Digest</span></a>
package/ui/live-view.js CHANGED
@@ -242,6 +242,8 @@ export function fullButtonHtml(id, { esc, icon }) {
242
242
  // name goes in as text (the caller fills .lf-title with textContent).
243
243
  export function fullBarHtml(id, { esc, icon, pills = '', stop = '', retitle = '', glyph = '' }) {
244
244
  return '<div class="live-fbar">' +
245
+ // 0.9.18 phones: the nav as a drawer (ui/navrail.js); from 700px the rail is beside full screen and this hides
246
+ '<button type="button" class="btn lf-menu" data-nav-drawer aria-label="Menu" aria-expanded="false" aria-controls="navDrawer" data-tip="Menu">' + icon('menu') + '</button>' +
245
247
  '<button type="button" class="btn live-fs-x" data-action="live-full-exit" data-id="' + esc(id) + '" aria-label="Back to the board" data-tip="Board">' + icon('chevron-left') + '</button>' + // 0.9.16 one layout: back is the board
246
248
  glyph + '<h2 class="lf-title"></h2>' + retitle + '<div class="lf-pills">' + pills + '</div>' + stop +
247
249
  // 0.9.15 the embedded terminal (ui/term.js): docked under full screen, which gets shorter
package/ui/navrail.js ADDED
@@ -0,0 +1,99 @@
1
+ // 0.9.18 The left nav on every screen (feedback, Oct 28: "it seems weird that I
2
+ // need to click on Board first to see the left hand nav").
3
+ //
4
+ // From 700px the rail stays beside every page, full screen and the walkthrough
5
+ // included. Two widths: expanded (84px, icon over label, the rail as it always
6
+ // was) and collapsed (56px, icons with a tip). The toggle at the rail's foot,
7
+ // ⌘B, or [ when you're not typing, switches them, remembered on this device. When the
8
+ // room is short (under 1100px, or a full-screen chat that would get narrower
9
+ // than NAV_CHAT_MIN beside the session panel) the rail collapses for that view
10
+ // only: your saved choice is untouched and comes back when there's room.
11
+ //
12
+ // Under 700px there's no rail: a menu button (and, in a Home Screen app or a
13
+ // non-Safari browser, a swipe in from the left edge) opens the nav as a drawer
14
+ // over the page. Safari's own back-swipe owns that edge in the browser, so there
15
+ // the button is the way in.
16
+ //
17
+ // Pure functions and a tiny store (the unit tests use a fake storage); app.js wires them.
18
+
19
+ export const NAV_KEY = 'lo-nav-v1' // '1' collapsed, '0' expanded (an explicit choice); none: expanded
20
+ export const RAIL_MIN = 700 // from here the rail; under it the phone's bar and the drawer
21
+ export const RAIL_ROOMY = 1100 // under this the rail is collapsed whatever you chose
22
+ export const RAIL_W_OPEN = 84, RAIL_W_SLIM = 56
23
+ export const NAV_CHAT_MIN = 560 // a full-screen chat keeps at least this beside an expanded rail
24
+ export const EDGE_PX = 20 // a drawer swipe starts this close to the left edge
25
+ export const SWIPE_MIN = 48 // and travels at least this far, mostly sideways
26
+
27
+ export function createNavStore(storage) {
28
+ const stored = () => { try { const v = storage ? storage.getItem(NAV_KEY) : null; return v === '1' || v === '0' ? v : null } catch { return null } }
29
+ return {
30
+ collapsed: () => stored() === '1',
31
+ explicit: () => stored() != null,
32
+ set(collapsed) { try { storage && storage.setItem(NAV_KEY, collapsed ? '1' : '0') } catch {} },
33
+ toggle() { const c = stored() !== '1'; this.set(c); return c },
34
+ }
35
+ }
36
+
37
+ // What the rail shows now. width: the window's CSS width; saved: your choice
38
+ // (true = collapsed); side: the full-screen session panel's width when it's open
39
+ // beside the chat (0 when there's none, or it's a rail / collapsed).
40
+ // → { rail, collapsed, auto } — auto: collapsed by the room, not by you.
41
+ export function navState({ width = 0, saved = false, side = 0 } = {}) {
42
+ const w = Number(width) || 0
43
+ if (w < RAIL_MIN) return { rail: false, collapsed: false, auto: false }
44
+ const tight = w < RAIL_ROOMY || (side > 0 && w - RAIL_W_OPEN - side < NAV_CHAT_MIN)
45
+ if (saved) return { rail: true, collapsed: true, auto: false }
46
+ return { rail: true, collapsed: tight, auto: tight }
47
+ }
48
+
49
+ // The toggle's words for the state shown.
50
+ export function toggleWords({ collapsed = false, auto = false } = {}, key = '⌘B') {
51
+ if (auto) return { label: 'Expand the sidebar', tip: 'Not enough room to expand here', icon: 'panel-left-open' }
52
+ return collapsed
53
+ ? { label: 'Expand the sidebar', tip: 'Expand sidebar (' + key + ')', icon: 'panel-left-open' }
54
+ : { label: 'Collapse the sidebar', tip: 'Collapse sidebar (' + key + ')', icon: 'panel-left-close' }
55
+ }
56
+
57
+ // ⌘B (Ctrl+B off a Mac) toggles the rail from anywhere, the composer included,
58
+ // but not inside the terminal (Ctrl+B is tmux's); [ does too when you're not typing.
59
+ // (⌘\ is the session panel's, ⌘⇧[ ] the tabs'.)
60
+ export function isNavKey(ev, { typing = false, term = false, mac = true } = {}) {
61
+ if (!ev || ev.altKey) return false
62
+ const mod = mac ? ev.metaKey && !ev.ctrlKey : ev.ctrlKey && !ev.metaKey
63
+ if (mod && !ev.shiftKey && (ev.key === 'b' || ev.key === 'B')) return !term
64
+ return ev.key === '[' && !ev.metaKey && !ev.ctrlKey && !typing
65
+ }
66
+ export const navKeyLabel = (mac = true) => (mac ? '⌘B' : 'Ctrl+B')
67
+
68
+ // Mobile Safari in the browser (not a Home Screen app): its back-swipe owns the left edge.
69
+ export function safariOwnsEdge(ua = '', standalone = false) {
70
+ const s = String(ua)
71
+ const ios = /iP(hone|ad|od)/.test(s) || (/Macintosh/.test(s) && /Mobile/.test(s))
72
+ return ios && !standalone && /Safari/.test(s) && !/CriOS|FxiOS|EdgiOS/.test(s)
73
+ }
74
+
75
+ // A touch that starts this close to the left edge may open the drawer.
76
+ export const edgeStart = (x, edge = EDGE_PX) => Number(x) >= 0 && Number(x) <= edge
77
+ // What a finished swipe means: 'open' (rightwards from the edge), 'close'
78
+ // (leftwards on the open drawer) or null (too short, or mostly up and down).
79
+ export function swipeIntent(dx, dy, open) {
80
+ const x = Number(dx) || 0, y = Number(dy) || 0
81
+ if (Math.abs(x) < SWIPE_MIN || Math.abs(x) < Math.abs(y) * 1.5) return null
82
+ if (!open && x > 0) return 'open'
83
+ if (open && x < 0) return 'close'
84
+ return null
85
+ }
86
+
87
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
88
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
89
+
90
+ // The drawer: the nav's destinations as one list. items: [{ href, route, label, svg, on, count }]
91
+ // (svg is the nav's own inline icon markup).
92
+ export function drawerHtml(items = [], { title = 'Leftoff' } = {}) {
93
+ const row = (it) => '<a class="nd-item' + (it.on ? ' on' : '') + '" href="' + esc(it.href) + '"' + (it.route ? ' data-route="' + esc(it.route) + '"' : '') + (it.on ? ' aria-current="page"' : '') + '>' +
94
+ (it.svg || '') + '<span class="nd-lbl">' + esc(it.label) + '</span>' + (it.on ? '<span class="nd-cur">Current</span>' : '') +
95
+ (it.count ? '<span class="nd-n" aria-label="' + esc(it.count + ' waiting') + '">' + esc(String(it.count)) + '</span>' : '') + '</a>'
96
+ return '<div class="nd-head"><img src="favicon.svg" class="logo" alt="" width="22" height="22"><span class="nd-title">' + esc(title) + '</span>' +
97
+ '<button type="button" class="nd-x" data-nd-close aria-label="Close the menu" data-tip="Close (Esc)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button></div>' +
98
+ '<nav class="nd-list" aria-label="Sections">' + items.map(row).join('') + '</nav>'
99
+ }