@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 +1 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +77 -9
- package/ui/app.js +224 -17
- package/ui/icons.js +3 -0
- package/ui/index.html +16 -12
- package/ui/live-view.js +2 -0
- package/ui/navrail.js +99 -0
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.
|
|
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"
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
2870
|
-
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
2616
|
-
window.addEventListener('resize', () => { if (!fsResize.drag) applyFsWidth(fsWidth.shown(
|
|
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(
|
|
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),
|
|
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,
|
|
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(
|
|
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(
|
|
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.
|
|
6846
|
+
el.dataset.tip = summary
|
|
6641
6847
|
el.setAttribute('aria-label', 'Status: ' + summary)
|
|
6642
6848
|
const brand = document.getElementById('brand')
|
|
6643
|
-
if (brand) brand.
|
|
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.
|
|
6660
|
-
el.setAttribute('aria-label', 'Status: ' + el.
|
|
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.
|
|
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').
|
|
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"
|
|
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"
|
|
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"
|
|
42
|
-
<a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn"
|
|
43
|
-
<a href="#/settings" data-route="settings" id="setTab" class="iconbtn"
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
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
|
+
}
|