@leftoff/cli 0.9.17-beta.4 → 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/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() }
@@ -968,7 +1171,11 @@ function wtFill(s) {
968
1171
  const sig = k + '|' + (data ? (data.fingerprint || data.status) + (data.cached ? 'c' : '') : '-') + '|' + JSON.stringify([ui.view, ui.filter, ui.loading, ui.error, ui.fellBack, ui.deck, [...wtUi.code], (data && data.scopes || []).map((x) => x.key + x.available).join()])
969
1172
  let node = wtUi.node
970
1173
  if (!node || wtUi.sig !== sig) {
971
- const keep = node && wtUi.sig.startsWith(k + '|') ? (node.querySelector('#wtScroll') || {}).scrollTop || 0 : 0
1174
+ // (the old node may be out of the page already, reading 0: what was recorded as you scrolled wins)
1175
+ const same = node && wtUi.sig.startsWith(k + '|')
1176
+ const was = (sel) => { const el = same && node.querySelector(sel); return el ? (wtScrolls.get(el) || [el.scrollTop])[0] || 0 : 0 }
1177
+ const keep = was('#wtScroll'), keepSide = was('.wt-side')
1178
+ wtScrolls.clear()
972
1179
  node = document.createElement('div')
973
1180
  node.className = 'wt-node'
974
1181
  const when = data && data.generatedAt ? clock(data.generatedAt) : ''
@@ -979,12 +1186,42 @@ function wtFill(s) {
979
1186
  wtStuck(document.querySelector('.wt-scroll')) // 0.9.18
980
1187
  wtDeckPaint(node, { first: true }) // 0.9.18 the slideshow: its progress, its dots in view
981
1188
  const sc = node.querySelector('#wtScroll'); if (sc && keep) sc.scrollTop = keep
1189
+ const sd = node.querySelector('.wt-side'); if (sd && keepSide) sd.scrollTop = keepSide
982
1190
  } else {
983
- if (node.parentNode !== mount) mount.replaceChildren(node)
1191
+ if (node.parentNode !== mount) {
1192
+ // 0.9.18 (Joel: "pause for a second and it jumps back to the top"): a live update redraws the
1193
+ // session page (paintSession sets $app.innerHTML), which takes this node out of the page;
1194
+ // putting it back resets every scroll box inside it to 0, so put back where you were, and focus
1195
+ mount.replaceChildren(node)
1196
+ wtRestoreView(node)
1197
+ }
984
1198
  wtMarks(s)
985
1199
  }
986
1200
  if (!data && !ui.loading && !ui.error) setTimeout(() => wtLoad(s.id), 0) // opened by a reload: read it now
987
1201
  }
1202
+ // 0.9.18 where each scroll box in the walkthrough was (the slide or long page, the file list, the
1203
+ // dots) and what had focus, kept as you go, since a node taken out of the page reads 0 and has no focus
1204
+ const wtScrolls = new Map() // element -> [top, left], for the node on screen (cleared when it's rebuilt)
1205
+ let wtFocus = null // what had focus in the walkthrough just before a redraw took it out (wtBeforePaint)
1206
+ document.addEventListener('scroll', (e) => { const t = e.target; if (t && t.nodeType === 1 && wtUi.node && t.isConnected && wtUi.node.contains(t)) wtScrolls.set(t, [t.scrollTop, t.scrollLeft]) }, { capture: true, passive: true })
1207
+ let wtQuietUntil = 0, wtRefocusing = false // the scroll and focus our own restore makes don't close a tip
1208
+ function wtBeforePaint() {
1209
+ const a = document.activeElement
1210
+ wtFocus = wtUi.node && a && a !== document.body && wtUi.node.contains(a) ? a : null
1211
+ }
1212
+ function wtRestoreView(node) {
1213
+ for (const [el, v] of wtScrolls) {
1214
+ if (!node.contains(el)) { wtScrolls.delete(el); continue }
1215
+ if (el.scrollTop !== v[0] || el.scrollLeft !== v[1]) wtQuietUntil = performance.now() + 150
1216
+ if (el.scrollTop !== v[0]) el.scrollTop = v[0]
1217
+ if (el.scrollLeft !== v[1]) el.scrollLeft = v[1]
1218
+ }
1219
+ // focus only goes back if it was in here when the redraw began and the redraw left it nowhere (never taken from elsewhere)
1220
+ const a = document.activeElement
1221
+ if (wtFocus && node.contains(wtFocus) && (!a || a === document.body)) { wtRefocusing = true; try { wtFocus.focus({ preventScroll: true }) } finally { wtRefocusing = false } }
1222
+ wtFocus = null
1223
+ wtStuck(node.querySelector('.wt-scroll'))
1224
+ }
988
1225
  // The reviewed marks and the count, in place (no redraw).
989
1226
  function wtMarks(s) {
990
1227
  const node = wtUi.node; if (!node || !s) return
@@ -1853,7 +2090,7 @@ function paintNeedsCount(n) {
1853
2090
  const c = document.getElementById('needsCount')
1854
2091
  if (c) { c.hidden = !n; c.textContent = badgeText(n) }
1855
2092
  const tab = document.getElementById('needsTab')
1856
- 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' }
1857
2094
  if (quickLookOpen() && paintNeedsCount.count !== n) loadQuickLook()
1858
2095
  paintNeedsCount.count = n
1859
2096
  document.title = (n ? '(' + n + ') ' : '') + 'Leftoff'
@@ -2065,6 +2302,7 @@ function paintSession(s, data) {
2065
2302
  // preview closed with Esc puts focus back on one, then a redraw replaced it)
2066
2303
  const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
2067
2304
  liveKeeper.before($app)
2305
+ wtBeforePaint() // 0.9.18 the walkthrough's focus, before this takes it out of the page
2068
2306
  $app.innerHTML = sessionPageHtml(s, data)
2069
2307
  liveAfterRender(s.id)
2070
2308
  fitAskDetail($app)
@@ -2345,7 +2583,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2345
2583
  side += answeredList(s)
2346
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 ⋯
2347
2585
  const label = fsPeekWords(s)
2348
- 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 })
2349
2587
  }
2350
2588
  // The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
2351
2589
  // "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
@@ -2571,26 +2809,27 @@ function exitFull(id) {
2571
2809
  const fsResize = { drag: null, raf: 0 }
2572
2810
  function applyFsWidth(w) {
2573
2811
  const de = document.documentElement
2574
- const n = clampSideWidth(w, window.innerWidth)
2812
+ const n = clampSideWidth(w, fsViewport())
2575
2813
  if (n === SIDE_W_DEFAULT && fsWidth.stored() == null) de.style.removeProperty('--fs-uw')
2576
2814
  else de.style.setProperty('--fs-uw', n + 'px')
2577
- 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()
2578
2817
  return n
2579
2818
  }
2580
- applyFsWidth(fsWidth.shown(window.innerWidth))
2581
- 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())) })
2582
2821
  document.addEventListener('pointerdown', (e) => {
2583
2822
  const h = e.target.closest && e.target.closest('.fs-resize')
2584
2823
  if (!h || e.button !== 0 || window.innerWidth < WIDE_MIN) return
2585
2824
  e.preventDefault()
2586
2825
  try { h.setPointerCapture(e.pointerId) } catch {}
2587
- 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 }
2588
2827
  document.documentElement.classList.add('fs-resizing')
2589
2828
  })
2590
2829
  document.addEventListener('pointermove', (e) => {
2591
2830
  const d = fsResize.drag
2592
2831
  if (!d || e.pointerId !== d.id) return
2593
- 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
2594
2833
  if (!fsResize.raf) fsResize.raf = requestAnimationFrame(() => { fsResize.raf = 0; if (fsResize.drag && fsResize.drag.now != null) applyFsWidth(fsResize.drag.now) })
2595
2834
  })
2596
2835
  const fsResizeEnd = (e) => {
@@ -2599,7 +2838,7 @@ const fsResizeEnd = (e) => {
2599
2838
  fsResize.drag = null
2600
2839
  document.documentElement.classList.remove('fs-resizing')
2601
2840
  try { d.h.releasePointerCapture(d.id) } catch {}
2602
- 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()))
2603
2842
  }
2604
2843
  document.addEventListener('pointerup', fsResizeEnd)
2605
2844
  document.addEventListener('pointercancel', fsResizeEnd)
@@ -2607,7 +2846,7 @@ document.addEventListener('dblclick', (e) => { if (e.target.closest && e.target.
2607
2846
  document.addEventListener('keydown', (e) => {
2608
2847
  const h = e.target.closest && e.target.closest('.fs-resize')
2609
2848
  if (!h || e.metaKey || e.ctrlKey || e.altKey) return
2610
- const cur = fsWidth.shown(window.innerWidth)
2849
+ const cur = fsWidth.shown(fsViewport())
2611
2850
  let next = null
2612
2851
  if (e.key === 'ArrowLeft') next = cur + SIDE_W_STEP
2613
2852
  else if (e.key === 'ArrowRight') next = cur - SIDE_W_STEP
@@ -2616,7 +2855,7 @@ document.addEventListener('keydown', (e) => {
2616
2855
  else if (e.key === 'Enter') { e.preventDefault(); fsWidth.reset(); applyFsWidth(SIDE_W_DEFAULT); return }
2617
2856
  if (next == null) return
2618
2857
  e.preventDefault()
2619
- applyFsWidth(fsWidth.set(Number.isFinite(next) ? next : sideMax(window.innerWidth), window.innerWidth))
2858
+ applyFsWidth(fsWidth.set(Number.isFinite(next) ? next : sideMax(fsViewport()), fsViewport()))
2620
2859
  })
2621
2860
  // 0.9.16 the session panel: open, collapsed to its rail (⌘\, remembered), or on a phone a sheet
2622
2861
  function fsSideClasses() {
@@ -2625,6 +2864,7 @@ function fsSideClasses() {
2625
2864
  de.classList.toggle('fs-on', !!side)
2626
2865
  de.classList.toggle('fs-collapsed', !!side && side.classList.contains('collapsed'))
2627
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
2628
2868
  }
2629
2869
  function toggleFsSide(force) {
2630
2870
  const side = document.getElementById('fsSide')
@@ -2695,6 +2935,7 @@ document.addEventListener('keydown', (ev) => {
2695
2935
  function setFullClass(on) {
2696
2936
  document.documentElement.classList.toggle('live-full', !!on)
2697
2937
  if (on) fitFullViewport()
2938
+ applyNav()
2698
2939
  }
2699
2940
  // The window as you see it: on a phone the keyboard takes the bottom of it,
2700
2941
  // and the composer stays just above the keyboard.
@@ -6602,10 +6843,10 @@ async function refreshHealth() {
6602
6843
  // the header shows this Mac's name; the rest is in the status popover (v0.5.9)
6603
6844
  const summary = h.sessionsOpen + ' open · ' + h.machine + ' · ' + h.summarizer
6604
6845
  el.querySelector('.label').textContent = h.machine
6605
- el.title = summary
6846
+ el.dataset.tip = summary
6606
6847
  el.setAttribute('aria-label', 'Status: ' + summary)
6607
6848
  const brand = document.getElementById('brand')
6608
- 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 || '?') : '')
6609
6850
  paintTopDot()
6610
6851
  if (statusOpen()) paintStatus()
6611
6852
  paintNeedsCount(needsCountOf(h))
@@ -6621,8 +6862,8 @@ async function refreshHealth() {
6621
6862
  if (updateBar) updateBar.check(null) // a failed check changes nothing
6622
6863
  el.className = 'health down'
6623
6864
  el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
6624
- el.title = el.querySelector('.label').textContent
6625
- el.setAttribute('aria-label', 'Status: ' + el.title)
6865
+ el.dataset.tip = el.querySelector('.label').textContent
6866
+ el.setAttribute('aria-label', 'Status: ' + el.dataset.tip)
6626
6867
  }
6627
6868
  }
6628
6869
 
@@ -6632,7 +6873,7 @@ async function refreshHealth() {
6632
6873
  // Settings as icons. Nothing wraps or is cut off from 900px up.
6633
6874
 
6634
6875
  const isMac = /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent)
6635
- { 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 /)' }
6636
6877
 
6637
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).
6638
6879
  function paintTopDot() {
@@ -6646,7 +6887,7 @@ function paintTopDot() {
6646
6887
  el.hidden = !all.length || (!warn && !(h.update && h.update.available))
6647
6888
  el.className = 'topdot' + (warn ? ' warn' : '')
6648
6889
  const why = all.flatMap((x) => x.why)
6649
- document.getElementById('setTab').title = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
6890
+ document.getElementById('setTab').dataset.tip = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
6650
6891
  paintMoreDots()
6651
6892
  }
6652
6893
 
@@ -7125,12 +7366,13 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
7125
7366
  tip.classList.add('on')
7126
7367
  }, delay)
7127
7368
  }
7128
- const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt .wt-exp [data-tip], #wt .wt-seg [data-tip], #wt .wt-fm [data-tip], #wt .wt-st-h [data-tip]') // 0.9.18: the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
7369
+ const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
7129
7370
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
7130
7371
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
7131
- document.addEventListener('focusin', (e) => { const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
7372
+ document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
7132
7373
  document.addEventListener('focusout', (e) => { if (tipped(e) === cur) hide() })
7133
- for (const ev of ['pointerdown', 'scroll', 'hashchange', 'resize']) window.addEventListener(ev, hide, true)
7374
+ for (const ev of ['pointerdown', 'hashchange', 'resize']) window.addEventListener(ev, hide, true)
7375
+ window.addEventListener('scroll', () => { if (performance.now() >= wtQuietUntil) hide() }, true) // 0.9.18 not for a redraw putting the walkthrough's scroll back
7134
7376
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) hide() })
7135
7377
  // 0.9.18 touch: a long press shows the tip (the tap it ends with doesn't follow the link)
7136
7378
  let lp = 0, lpd = false
@@ -7170,6 +7412,7 @@ document.addEventListener('click', (e) => { if (e.target.closest && $app.contain
7170
7412
  document.addEventListener('touchstart', (e) => {
7171
7413
  sw = null
7172
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
7173
7416
  const t = e.target
7174
7417
  // a table (even one that fits) or a code block is for reading and scrolling sideways, not for changing sessions
7175
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
@@ -7797,6 +8040,18 @@ document.addEventListener('click', async (ev) => {
7797
8040
  fmToggle((d && d.fingerprint) || '', btn.dataset.key, shut)
7798
8041
  return
7799
8042
  }
8043
+ if (action === 'wt-file') { // 0.9.18 the Files view's list: open that file's diff and bring it to the top of the diff pane
8044
+ ev.preventDefault()
8045
+ const node = wtUi.node, d = node && node.querySelector('#wt-f-' + CSS.escape(btn.dataset.i || ''))
8046
+ if (!d) return
8047
+ d.open = true
8048
+ for (const a of node.querySelectorAll('.wt-fl-i')) a.classList.toggle('cur', a.dataset.i === btn.dataset.i)
8049
+ const m = btn.closest('details.wt-fl-m'); if (m) m.open = false // a phone: the folded list closes again
8050
+ const sc = node.querySelector('#wtScroll')
8051
+ if (sc) sc.scrollTo({ top: Math.max(0, sc.scrollTop + d.getBoundingClientRect().top - sc.getBoundingClientRect().top - 4), behavior: reducedMotion() ? 'auto' : 'smooth' })
8052
+ const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
8053
+ return
8054
+ }
7800
8055
  if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
7801
8056
  if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
7802
8057
  if (action === 'wt-export-html') return wtExport('html')
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\"/>",
@@ -37,6 +40,12 @@ export const ICONS = {
37
40
  "maximize-2": "<path d=\"M15 3h6v6\"/> <path d=\"m21 3-7 7\"/> <path d=\"m3 21 7-7\"/> <path d=\"M9 21H3v-6\"/>",
38
41
  "minimize-2": "<path d=\"m14 10 7-7\"/> <path d=\"M20 10h-6V4\"/> <path d=\"m3 21 7-7\"/> <path d=\"M4 14h6v6\"/>",
39
42
  "rotate-ccw": "<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\"/>",
43
+ "square-plus": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M8 12h8\"/> <path d=\"M12 8v8\"/>",
44
+ "square-minus": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M8 12h8\"/>",
45
+ "square-dot": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <circle cx=\"12\" cy=\"12\" r=\"2.6\" fill=\"currentColor\" stroke=\"none\"/>",
46
+ "square-arrow-right": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M8 12h8\"/> <path d=\"m12 16 4-4-4-4\"/>",
47
+ "rows-3": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M21 9H3\"/> <path d=\"M21 15H3\"/>",
48
+ "gallery-horizontal": "<path d=\"M2 3v18\"/> <rect width=\"12\" height=\"18\" x=\"6\" y=\"3\" rx=\"2\"/> <path d=\"M22 3v18\"/>",
40
49
  "list-checks": "<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\"/>",
41
50
  "plus": "<path d=\"M5 12h14\"/> <path d=\"M12 5v14\"/>",
42
51
  "play": "<path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/>",
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