hop2 0.11.58 → 0.11.60
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/demo/capture/capture.mjs +71 -1
- package/demo/product-video-v3.md +11 -2
- package/hay/apps/web/src/App.tsx +16 -13
- package/hay/apps/web/src/components/SessionSwitcher.tsx +1 -1
- package/hay/apps/web/src/components/ViewsPanel.tsx +22 -66
- package/hay/apps/web/src/styles.css +19 -44
- package/hay/apps/web/test/ViewsPanel.test.tsx +18 -29
- package/package.json +1 -1
package/demo/capture/capture.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { getHopState, sleep } from "../hop-demo-lib.mjs";
|
|
|
34
34
|
import { chromium, devices } from "../../hay/node_modules/playwright/index.mjs";
|
|
35
35
|
import { NATIVE_CSS_INIT } from "./native-css.mjs";
|
|
36
36
|
import {
|
|
37
|
-
CHROME, FFMPEG, OUT, VID_TMP, ALLOWED, SCREEN_FORBIDDEN, TERMINALS_PATH, REPORT_HTML, REPO_ROOT, WIPE, loadTerminals
|
|
37
|
+
CHROME, FFMPEG, OUT, VID_TMP, ALLOWED, SCREEN_FORBIDDEN, TERMINALS_PATH, REPORT_HTML, REPO_ROOT, WIPE, castName, loadTerminals
|
|
38
38
|
} from "./capture-env.mjs";
|
|
39
39
|
|
|
40
40
|
const state = getHopState();
|
|
@@ -979,6 +979,76 @@ try {
|
|
|
979
979
|
await sleep(3200);
|
|
980
980
|
await saveVideo(page, "16-drop", trimFor(t0, marker));
|
|
981
981
|
|
|
982
|
+
} else if (clip === "08-math-hover") {
|
|
983
|
+
// LaTeX in the output renders on hover: the pointer rests on a formula,
|
|
984
|
+
// the KaTeX tooltip appears, a click pins it, the pointer moves on to a
|
|
985
|
+
// second formula. Full screen, on Lyra, from a wiped prompt.
|
|
986
|
+
//
|
|
987
|
+
// Size discipline: typing through the API makes the API client the size
|
|
988
|
+
// owner, at ITS size — the page would then follow that grid scaled, and
|
|
989
|
+
// xterm maps the mouse through unscaled cell metrics, so a hover misses.
|
|
990
|
+
// So the page claims first (a click), the API client is resized to the
|
|
991
|
+
// page's grid, and only then does the API type: the PTY never changes
|
|
992
|
+
// size again and the page renders 1:1.
|
|
993
|
+
const lyra = requireTerminalId(castName("Lyra"));
|
|
994
|
+
const key = terminals[castName("Lyra")]?.sessionName || castName("Lyra");
|
|
995
|
+
const authH = { Authorization: `Bearer ${state.sessionSecret}` };
|
|
996
|
+
const grid = async () => {
|
|
997
|
+
const sc = await (await fetch(`${state.localUrl}/api/sessions/screen?name=${encodeURIComponent(key)}`, { headers: authH })).json();
|
|
998
|
+
return sc;
|
|
999
|
+
};
|
|
1000
|
+
const { page, t0 } = await newRecordedPage(browser, {
|
|
1001
|
+
css: "try{localStorage.removeItem('hay_pane_tree');localStorage.setItem('hay_view_mode','fit');localStorage.setItem('hay_theme','dark');}catch(e){}"
|
|
1002
|
+
});
|
|
1003
|
+
await gotoSession(page, castName("Lyra"));
|
|
1004
|
+
await sleep(2200);
|
|
1005
|
+
await page.mouse.click(960, 700); // the page takes the size
|
|
1006
|
+
await sleep(1500);
|
|
1007
|
+
const g0 = await grid();
|
|
1008
|
+
await apiResize(lyra, g0.cols, g0.rows); // the API client agrees with it
|
|
1009
|
+
await sleep(600);
|
|
1010
|
+
await apiWrite(lyra, WIPE + "; clear\r");
|
|
1011
|
+
await sleep(1200);
|
|
1012
|
+
await assertDark(page, "08-math-hover");
|
|
1013
|
+
const marker = Date.now();
|
|
1014
|
+
await sleep(900);
|
|
1015
|
+
const line = "printf '%s\\n' 'Closed form: $x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$ and the series \\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6} converge.'";
|
|
1016
|
+
await typeSlow(lyra, line, 22);
|
|
1017
|
+
await sleep(300); await apiWrite(lyra, "\r");
|
|
1018
|
+
await sleep(1400);
|
|
1019
|
+
const strip = (t) => (t || "").replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1bc|\r/g, "");
|
|
1020
|
+
let sc = null, rows = [], row = -1, col = -1, col2 = -1;
|
|
1021
|
+
for (let attempt = 0; attempt < 16 && row < 0; attempt++) {
|
|
1022
|
+
sc = await grid();
|
|
1023
|
+
rows = strip(sc.data || "").split("\n");
|
|
1024
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1025
|
+
const c = rows[i].indexOf("\\frac");
|
|
1026
|
+
if (c >= 0 && !/printf/.test(rows[i])) { row = i; col = c + 2; col2 = rows[i].indexOf("\\sum") + 2; break; }
|
|
1027
|
+
}
|
|
1028
|
+
if (row < 0) await sleep(500);
|
|
1029
|
+
}
|
|
1030
|
+
if (row < 0) throw new Error("ABORT 08: formula not on screen — rows: " + JSON.stringify(rows.filter((r) => r.trim()).slice(0, 4)));
|
|
1031
|
+
const g = await page.evaluate(() => { const s = document.querySelector(".xterm-screen").getBoundingClientRect(); return { left: s.left, top: s.top, w: s.width, h: s.height }; });
|
|
1032
|
+
const cw = g.w / sc.cols, ch = g.h / sc.rows;
|
|
1033
|
+
const x1 = g.left + (col + 0.5) * cw, x2 = g.left + (col2 + 0.5) * cw, y = g.top + (row + 0.5) * ch;
|
|
1034
|
+
console.error(`08: grid ${sc.cols}x${sc.rows} screen ${g.w.toFixed(0)}x${g.h.toFixed(0)} → row ${row} at y ${y.toFixed(0)}, cols ${col}/${col2}`);
|
|
1035
|
+
await page.mouse.move(x1 - 120, y + 60, { steps: 10 });
|
|
1036
|
+
await sleep(500);
|
|
1037
|
+
await page.mouse.move(x1, y, { steps: 18 }); // the tooltip appears
|
|
1038
|
+
await sleep(900);
|
|
1039
|
+
console.error("08: tooltip visible:", await page.evaluate(() => { const t = document.querySelector(".math-tip"); return !!t && !t.hidden; }));
|
|
1040
|
+
await page.screenshot({ path: path.join(VID_TMP, "08-hover-check.png") });
|
|
1041
|
+
await sleep(1700);
|
|
1042
|
+
await page.mouse.down(); await page.mouse.up(); // pin it
|
|
1043
|
+
await sleep(1800);
|
|
1044
|
+
await page.keyboard.press("Escape");
|
|
1045
|
+
await sleep(400);
|
|
1046
|
+
await page.mouse.move(x2, y, { steps: 24 }); // the second formula
|
|
1047
|
+
await sleep(2800);
|
|
1048
|
+
await page.mouse.move(x2, y + 200, { steps: 12 });
|
|
1049
|
+
await sleep(1200);
|
|
1050
|
+
await saveVideo(page, "08-math-hover", trimFor(t0, marker));
|
|
1051
|
+
|
|
982
1052
|
} else if (clip === "debug-sheet") {
|
|
983
1053
|
// Diagnostic: open Aurora's card menu on the wall, keep a frame, list
|
|
984
1054
|
// every visible control. No recording kept.
|
package/demo/product-video-v3.md
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
# Product video v3 —
|
|
1
|
+
# Product video v3 — footage for the Remotion master
|
|
2
|
+
|
|
3
|
+
**The production video lives in `~/Code/hop-video`** (a Remotion project: the
|
|
4
|
+
July master `out/hop_master_v2*.mp4`, storyboard in `STORYBOARD.md`). This
|
|
5
|
+
directory is the *capture rig* that films the footage it uses. The
|
|
6
|
+
`assemble-video.mjs` cut described below is a rough stitch for review, not
|
|
7
|
+
the product video — the v3 master is rendered from Remotion
|
|
8
|
+
(`npm run render:v3 && npm run mix:v3` there) with the clips listed here
|
|
9
|
+
copied into `footage/live/`.
|
|
10
|
+
|
|
2
11
|
|
|
3
12
|
The v2 cut (August) opened on the wall and closed on the phone illustration.
|
|
4
13
|
Since then the product grew four stories worth filming — a three-choice
|
|
@@ -44,7 +53,7 @@ card appears on the wall while codex starts reading the transcript. The
|
|
|
44
53
|
illustration that follows explains what just happened (fork vs hand-off,
|
|
45
54
|
filed beside its source), the way `hero-overview.svg` explains the runtime.
|
|
46
55
|
|
|
47
|
-
## Producing it
|
|
56
|
+
## Producing it (the rough stitch — for review only)
|
|
48
57
|
|
|
49
58
|
```bash
|
|
50
59
|
export PATH=/opt/homebrew/bin:$PATH
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
type PaneNode,
|
|
45
45
|
type Side
|
|
46
46
|
} from "./utils/paneTree";
|
|
47
|
-
import { ViewsPanel,
|
|
47
|
+
import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
|
|
48
48
|
import type { ViewsSummary } from "./utils/switcherModel";
|
|
49
49
|
|
|
50
50
|
const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
|
|
@@ -4069,7 +4069,6 @@ const App = () => {
|
|
|
4069
4069
|
const seen = loadViewsSeen();
|
|
4070
4070
|
return sessions.filter((s) => hasUnseenViews(s.internalName || s.name, s.views?.latestAt, seen)).length;
|
|
4071
4071
|
}, [sessions, viewsOpen]);
|
|
4072
|
-
const totalViews = useMemo(() => sessions.reduce((n, s) => n + (s.views?.count || 0), 0), [sessions]);
|
|
4073
4072
|
|
|
4074
4073
|
const sessionStyle = isMobile
|
|
4075
4074
|
? ({ "--mobile-keyboard-height": `${keyboardVisible ? keyboardHeight : 0}px` } as CSSProperties)
|
|
@@ -4159,9 +4158,21 @@ const App = () => {
|
|
|
4159
4158
|
onClick={() => setViewsOpen({ session: session.room, dock: true })}
|
|
4160
4159
|
>
|
|
4161
4160
|
Views
|
|
4162
|
-
{
|
|
4163
|
-
|
|
4164
|
-
|
|
4161
|
+
{(() => {
|
|
4162
|
+
// This session's own results — the entry is per session, as
|
|
4163
|
+
// the panel it opens is. Fresh when it published since you
|
|
4164
|
+
// last looked; otherwise how many it has.
|
|
4165
|
+
// session.room is whatever the URL carried — the display
|
|
4166
|
+
// name on a directly-loaded page, the internal one after a
|
|
4167
|
+
// switch — so match either, like the panel does.
|
|
4168
|
+
const lc = session.room.toLowerCase();
|
|
4169
|
+
const mine = sessions.find((x) => (x.internalName || "").toLowerCase() === lc
|
|
4170
|
+
|| (x.name || "").toLowerCase() === lc || (x.displayName || "").toLowerCase() === lc);
|
|
4171
|
+
const n = mine?.views?.count || 0;
|
|
4172
|
+
const fresh = !!mine && n > 0 && hasUnseenViews(mine.internalName || mine.name, mine.views?.latestAt, loadViewsSeen());
|
|
4173
|
+
return fresh ? <span className="views-entry-count fresh" aria-label="New views">{n} new</span>
|
|
4174
|
+
: n > 0 ? <span className="views-entry-count">{n}</span> : null;
|
|
4175
|
+
})()}
|
|
4165
4176
|
</button>
|
|
4166
4177
|
<button
|
|
4167
4178
|
type="button"
|
|
@@ -4998,14 +5009,6 @@ const App = () => {
|
|
|
4998
5009
|
{toast && <div className="terminal-toast" role="status" aria-live="polite">{toast}</div>}
|
|
4999
5010
|
</main>
|
|
5000
5011
|
)}
|
|
5001
|
-
{/* The drawer pull on the right edge: the front door to Views on every
|
|
5002
|
-
desktop surface — the wall, the hub and a full-screen session — and
|
|
5003
|
-
the one place a new result announces itself in large type. Scoped to
|
|
5004
|
-
the session you are in; fleet-wide from the wall and the hub. */}
|
|
5005
|
-
{!isMobile && !viewsOpen && (!!session || isEmbeddedInHop()) && (
|
|
5006
|
-
<ViewsPull count={totalViews} fresh={unseenViewSessions}
|
|
5007
|
-
onOpen={() => setViewsOpen(switcherOpen || !session ? { dock: true } : { session: session.room, dock: true })} />
|
|
5008
|
-
)}
|
|
5009
5012
|
{/* Outside the mode ternary on purpose: Views opens from the hub, from a
|
|
5010
5013
|
session, and from over the switcher, and it must outlive a switch. */}
|
|
5011
5014
|
{viewsOpen && (
|
|
@@ -2954,7 +2954,7 @@ export const SessionSwitcher = ({
|
|
|
2954
2954
|
onClick={(e) => { e.stopPropagation(); onOpenViews(key); }}
|
|
2955
2955
|
onPointerDown={(e) => e.stopPropagation()}
|
|
2956
2956
|
>
|
|
2957
|
-
◧ {s.views.count}
|
|
2957
|
+
◧ <span className="views-word">Views · </span>{s.views.count}
|
|
2958
2958
|
</button>
|
|
2959
2959
|
)}
|
|
2960
2960
|
{waitingOnUser(preview) && (
|
|
@@ -7,19 +7,19 @@ import { relativeTime } from "../utils/switcherModel";
|
|
|
7
7
|
// this client was to catch the printed URL before it scrolled out of the
|
|
8
8
|
// viewport.
|
|
9
9
|
//
|
|
10
|
-
// ONE SHAPE: a reader. Opening Views lands you IN
|
|
11
|
-
// scoped session's, or the fleet's),
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// ONE SHAPE: a reader with its index beside it. Opening Views lands you IN
|
|
11
|
+
// the newest result (the scoped session's, or the fleet's), the list of
|
|
12
|
+
// everything published on the left, ‹ › to step through it. Both in one
|
|
13
|
+
// window, like a mail client — there is no separate "list mode" to click
|
|
14
|
+
// through first.
|
|
15
15
|
//
|
|
16
16
|
// Two placements of that one shape:
|
|
17
17
|
//
|
|
18
18
|
// - DOCK (the default on a desk-sized window): a right-side drawer with NO
|
|
19
19
|
// backdrop, so the wall or the terminal beside it stays visible, live and
|
|
20
20
|
// INTERACTIVE — you read the plot while the session that produced it keeps
|
|
21
|
-
// streaming, and you can still type.
|
|
22
|
-
//
|
|
21
|
+
// streaming, and you can still type. Per session: opened from the session's
|
|
22
|
+
// own Views button, a card's ◧ chip, or ⌘⇧V.
|
|
23
23
|
// - MODAL (windows too narrow for a drawer): the same reader, centred, with
|
|
24
24
|
// the index beside the page.
|
|
25
25
|
//
|
|
@@ -168,11 +168,11 @@ const previewSrc = (item: ViewItem) =>
|
|
|
168
168
|
: hrefFor(item);
|
|
169
169
|
|
|
170
170
|
const RAIL_KEY = "hop_views_rail";
|
|
171
|
-
// Wide enough
|
|
172
|
-
//
|
|
173
|
-
const DEFAULT_DOCK_W =
|
|
171
|
+
// Wide enough for the index beside a readable page; on a 1440 display that
|
|
172
|
+
// still leaves 680px of terminal. The drag handle remembers a choice.
|
|
173
|
+
const DEFAULT_DOCK_W = 760;
|
|
174
174
|
const clampDockW = (w: number) =>
|
|
175
|
-
Math.max(
|
|
175
|
+
Math.max(560, Math.min(Math.round(window.innerWidth * 0.75), w));
|
|
176
176
|
|
|
177
177
|
export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Props) => {
|
|
178
178
|
const [items, setItems] = useState<ViewItem[] | null>(null);
|
|
@@ -199,16 +199,13 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
199
199
|
return clampDockW(Number.isFinite(stored) && stored > 0 ? stored : DEFAULT_DOCK_W);
|
|
200
200
|
});
|
|
201
201
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
202
|
-
// The index (the list of everything published)
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// the drawer always lands on the result itself.
|
|
202
|
+
// The index (the list of everything published) sits BESIDE the page, in
|
|
203
|
+
// the same window, like a mail client: the list on the left, the result on
|
|
204
|
+
// the right, both visible at once. ☰ hides it for a wider page, and that
|
|
205
|
+
// choice is remembered.
|
|
207
206
|
const [railOpen, setRailOpen] = useState<boolean>(() => {
|
|
208
207
|
try { return localStorage.getItem(RAIL_KEY) !== "0"; } catch { return true; }
|
|
209
208
|
});
|
|
210
|
-
const [indexOpen, setIndexOpen] = useState(false);
|
|
211
|
-
const widened = useRef(false);
|
|
212
209
|
const now = Date.now();
|
|
213
210
|
|
|
214
211
|
// The scope arrives as whatever identifier the opener HAD — the URL's
|
|
@@ -281,15 +278,6 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
281
278
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
282
279
|
}, [items, flatRows]);
|
|
283
280
|
|
|
284
|
-
// A session that has published nothing yet is not a dead end: widen to the
|
|
285
|
-
// fleet once, automatically, and let the scope toggle say so. Once only —
|
|
286
|
-
// a reader who narrows back to the session meant it.
|
|
287
|
-
useEffect(() => {
|
|
288
|
-
if (!items || widened.current || !resolvedSession || showAll) return;
|
|
289
|
-
if (flatRows.length === 0 && items.length > 0) { widened.current = true; setShowAll(true); }
|
|
290
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
291
|
-
}, [items, flatRows, resolvedSession, showAll]);
|
|
292
|
-
|
|
293
281
|
const at = preview ? flatRows.findIndex((r) => r.path === preview.path) : -1;
|
|
294
282
|
const step = (dir: 1 | -1) => {
|
|
295
283
|
if (!flatRows.length) return;
|
|
@@ -322,10 +310,8 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
322
310
|
if (event.key === "Escape") {
|
|
323
311
|
event.preventDefault();
|
|
324
312
|
event.stopPropagation();
|
|
325
|
-
// The
|
|
326
|
-
|
|
327
|
-
if (railOverlay && indexOpen) setIndexOpen(false);
|
|
328
|
-
else onClose();
|
|
313
|
+
// The reader IS the panel: Escape closes it.
|
|
314
|
+
onClose();
|
|
329
315
|
return;
|
|
330
316
|
}
|
|
331
317
|
const dir = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1
|
|
@@ -339,7 +325,7 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
339
325
|
window.addEventListener("keydown", onKey, true);
|
|
340
326
|
return () => window.removeEventListener("keydown", onKey, true);
|
|
341
327
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
342
|
-
}, [onClose, preview, flatRows, docked
|
|
328
|
+
}, [onClose, preview, flatRows, docked]);
|
|
343
329
|
|
|
344
330
|
// Opening the panel IS the act of seeing what it shows.
|
|
345
331
|
useEffect(() => {
|
|
@@ -376,7 +362,6 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
376
362
|
if (!canPreview()) return; // narrow: the anchor's new tab is right
|
|
377
363
|
event.preventDefault();
|
|
378
364
|
setPreview(item);
|
|
379
|
-
setIndexOpen(false);
|
|
380
365
|
};
|
|
381
366
|
|
|
382
367
|
const copyLink = (item: ViewItem) => {
|
|
@@ -444,13 +429,8 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
444
429
|
el.addEventListener("pointerup", onUp);
|
|
445
430
|
};
|
|
446
431
|
|
|
447
|
-
|
|
448
|
-
// both, over it otherwise (a 620px dock with a 240px rail leaves 380px of
|
|
449
|
-
// page — enough for a plot, not for a report).
|
|
450
|
-
const railOverlay = docked ? dockW < 900 : false;
|
|
451
|
-
const railVisible = (railOverlay ? indexOpen : railOpen) && flatRows.length > 0;
|
|
432
|
+
const railVisible = railOpen && flatRows.length > 0;
|
|
452
433
|
const toggleRail = () => {
|
|
453
|
-
if (railOverlay) { setIndexOpen((v) => !v); return; }
|
|
454
434
|
setRailOpen((v) => {
|
|
455
435
|
try { localStorage.setItem(RAIL_KEY, v ? "0" : "1"); } catch { /* private mode */ }
|
|
456
436
|
return !v;
|
|
@@ -596,7 +576,7 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
596
576
|
<div className="views-nav">
|
|
597
577
|
<button type="button" className={"views-act views-rail-toggle" + (railVisible ? " on" : "")}
|
|
598
578
|
aria-label={railVisible ? "Hide the index" : "Show the index"} aria-pressed={railVisible}
|
|
599
|
-
title={railVisible ? "Hide the index" : "
|
|
579
|
+
title={railVisible ? "Hide the index (a wider page)" : "Show the index beside the page"}
|
|
600
580
|
onClick={toggleRail}>☰</button>
|
|
601
581
|
<button type="button" className="views-act" aria-label="Previous view" title="Previous (←)"
|
|
602
582
|
disabled={at <= 0} onClick={() => step(-1)}>‹</button>
|
|
@@ -648,7 +628,7 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
648
628
|
{!docked && <div className="views-backdrop" onClick={onClose} />}
|
|
649
629
|
<div className={"views-panel" + (docked ? " docked" : "")
|
|
650
630
|
+ (flatRows.length > 0 ? " reading" : "")
|
|
651
|
-
+ (railVisible ?
|
|
631
|
+
+ (railVisible ? " rail-side" : "")}
|
|
652
632
|
style={docked ? { width: dockW } : undefined}
|
|
653
633
|
role="dialog" aria-label="Published views"
|
|
654
634
|
ref={panelRef} tabIndex={-1}>
|
|
@@ -683,36 +663,12 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
683
663
|
</div>
|
|
684
664
|
{nav}
|
|
685
665
|
<div className="views-body">
|
|
686
|
-
{railVisible &&
|
|
666
|
+
{railVisible && list}
|
|
687
667
|
<div className="views-preview">
|
|
688
668
|
{empty || (canPreview() ? page : list)}
|
|
689
669
|
</div>
|
|
690
|
-
{railVisible && railOverlay && (
|
|
691
|
-
<div className="views-index" role="region" aria-label="Everything published">
|
|
692
|
-
{list}
|
|
693
|
-
</div>
|
|
694
|
-
)}
|
|
695
670
|
</div>
|
|
696
671
|
</div>
|
|
697
672
|
</>
|
|
698
673
|
);
|
|
699
674
|
};
|
|
700
|
-
|
|
701
|
-
/**
|
|
702
|
-
* The drawer pull: a tab on the right edge of the page that opens Views. It
|
|
703
|
-
* is the affordance a 32px icon in a toolbar never was — big enough to find
|
|
704
|
-
* without looking, and it wears the count so a new result is visible from
|
|
705
|
-
* across the room.
|
|
706
|
-
*/
|
|
707
|
-
export const ViewsPull = ({ count, fresh, onOpen }: { count: number; fresh: number; onOpen: () => void }) => (
|
|
708
|
-
<button type="button"
|
|
709
|
-
className={"views-pull" + (fresh > 0 ? " fresh" : "")}
|
|
710
|
-
aria-label={`Views: ${count} published${fresh > 0 ? `, new in ${fresh} session${fresh === 1 ? "" : "s"}` : ""}`}
|
|
711
|
-
title="Results agents published with hop view (⌘⇧V)"
|
|
712
|
-
onClick={onOpen}>
|
|
713
|
-
<span className="views-pull-glyph" aria-hidden="true">◧</span>
|
|
714
|
-
<span className="views-pull-label">Views</span>
|
|
715
|
-
{fresh > 0 ? <span className="views-pull-count fresh">{fresh} new</span>
|
|
716
|
-
: count > 0 ? <span className="views-pull-count">{count}</span> : null}
|
|
717
|
-
</button>
|
|
718
|
-
);
|
|
@@ -4511,17 +4511,12 @@ body.pane-dragging { user-select: none; }
|
|
|
4511
4511
|
padding-right: 10px;
|
|
4512
4512
|
margin-right: 12px;
|
|
4513
4513
|
}
|
|
4514
|
-
.views-panel.docked.rail-side .views-list { flex-basis:
|
|
4515
|
-
/*
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
border: 1px solid var(--line); border-radius: 10px;
|
|
4521
|
-
padding: 8px 10px;
|
|
4522
|
-
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
|
|
4523
|
-
}
|
|
4524
|
-
.views-index .views-list { flex: 1; }
|
|
4514
|
+
.views-panel.docked.rail-side .views-list { flex-basis: 232px; }
|
|
4515
|
+
/* In the rail the title owns the row: the per-row actions would crowd it
|
|
4516
|
+
to three letters, and the reader's bar carries the same actions for the
|
|
4517
|
+
page that is open. (Still in the DOM — a screen reader finds them.) */
|
|
4518
|
+
.views-panel.rail-side .views-row .views-acts { display: none; }
|
|
4519
|
+
.views-panel.rail-side .views-row.selected { background: color-mix(in srgb, var(--accent, #7c5cff) 16%, transparent); }
|
|
4525
4520
|
.views-preview { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
4526
4521
|
/* The reader's bar: index toggle · ‹ n/N › · title · actions. */
|
|
4527
4522
|
.views-nav {
|
|
@@ -4670,38 +4665,6 @@ body.pane-dragging { user-select: none; }
|
|
|
4670
4665
|
width: 5px; height: 5px; border-radius: 3px;
|
|
4671
4666
|
background: var(--accent, #7c5cff);
|
|
4672
4667
|
}
|
|
4673
|
-
/* The drawer pull: a tab on the right edge, mid-height, above the wall
|
|
4674
|
-
(600) and its sheets (620), below the Views panel (730) it opens. Big by
|
|
4675
|
-
design — this is the front door to Views, not a toolbar icon. */
|
|
4676
|
-
.views-pull {
|
|
4677
|
-
position: fixed;
|
|
4678
|
-
right: 0; top: 50%;
|
|
4679
|
-
transform: translateY(-50%);
|
|
4680
|
-
z-index: 700;
|
|
4681
|
-
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
|
4682
|
-
width: 44px; padding: 14px 0 12px;
|
|
4683
|
-
border: 1px solid var(--line); border-right: none;
|
|
4684
|
-
border-radius: 12px 0 0 12px;
|
|
4685
|
-
background: var(--panel);
|
|
4686
|
-
color: var(--ink-muted);
|
|
4687
|
-
box-shadow: -6px 0 22px rgba(0, 0, 0, 0.22);
|
|
4688
|
-
cursor: pointer;
|
|
4689
|
-
transition: width 0.12s ease, color 0.12s ease;
|
|
4690
|
-
}
|
|
4691
|
-
.views-pull:hover { color: var(--ink); border-color: var(--accent, #7c5cff); width: 48px; }
|
|
4692
|
-
.views-pull.fresh { color: var(--ink); border-color: var(--accent, #7c5cff); }
|
|
4693
|
-
.views-pull-glyph { font-size: 18px; line-height: 1; color: var(--accent, #7c5cff); }
|
|
4694
|
-
.views-pull-label {
|
|
4695
|
-
writing-mode: vertical-rl; transform: rotate(180deg);
|
|
4696
|
-
font-size: 12px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
|
|
4697
|
-
}
|
|
4698
|
-
.views-pull-count {
|
|
4699
|
-
font-family: var(--font-mono); font-size: 10px; font-weight: 700;
|
|
4700
|
-
padding: 2px 5px; border-radius: 6px;
|
|
4701
|
-
background: var(--bg-alt); color: var(--ink-muted);
|
|
4702
|
-
writing-mode: vertical-rl; transform: rotate(180deg);
|
|
4703
|
-
}
|
|
4704
|
-
.views-pull-count.fresh { background: var(--accent, #7c5cff); color: #fff; }
|
|
4705
4668
|
/* Labelled entry points beat a 16px glyph: the wall's header and the
|
|
4706
4669
|
topbar both say the word, and wear the count. */
|
|
4707
4670
|
.views-entry-count {
|
|
@@ -4716,12 +4679,24 @@ body.pane-dragging { user-select: none; }
|
|
|
4716
4679
|
an inline dot would shove the icon off-centre, so it rides the corner. */
|
|
4717
4680
|
.switcher-action { position: relative; }
|
|
4718
4681
|
.switcher-action .views-chip-dot { position: absolute; top: 5px; right: 5px; }
|
|
4682
|
+
/* A session's results are worth a real button on its card, not an 8px
|
|
4683
|
+
chip: the word, the count, a hit target a thumb can find. */
|
|
4719
4684
|
.switcher-chip.views {
|
|
4720
|
-
border:
|
|
4685
|
+
border: 1px solid var(--line);
|
|
4721
4686
|
cursor: pointer;
|
|
4722
4687
|
font-family: inherit;
|
|
4723
4688
|
line-height: 1.2;
|
|
4689
|
+
font-size: 10.5px;
|
|
4690
|
+
padding: 3px 8px;
|
|
4691
|
+
border-radius: 7px;
|
|
4692
|
+
color: var(--ink);
|
|
4693
|
+
background: var(--panel);
|
|
4694
|
+
white-space: nowrap;
|
|
4724
4695
|
}
|
|
4696
|
+
.switcher-chip.views:hover { border-color: var(--accent, #7c5cff); }
|
|
4697
|
+
/* Dense zooms have no room for the word beside the name — the name owns
|
|
4698
|
+
the row (same rule as the inline actions); the glyph and count stay. */
|
|
4699
|
+
.switcher-overlay.tile-dense .switcher-chip.views .views-word { display: none; }
|
|
4725
4700
|
.switcher-chip.views.fresh {
|
|
4726
4701
|
background: color-mix(in srgb, var(--accent, #7c5cff) 22%, transparent);
|
|
4727
4702
|
color: var(--accent, #7c5cff);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { ViewsPanel,
|
|
3
|
+
import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "../src/components/ViewsPanel";
|
|
4
4
|
import { SessionSwitcher } from "../src/components/SessionSwitcher";
|
|
5
5
|
import type { SwitcherSession } from "../src/utils/switcherModel";
|
|
6
6
|
|
|
@@ -58,7 +58,7 @@ describe("ViewsPanel", () => {
|
|
|
58
58
|
it("opens STRAIGHT INTO the newest result; a row click switches the page; the row stays a real link", async () => {
|
|
59
59
|
const onClose = vi.fn();
|
|
60
60
|
render(<ViewsPanel onClose={onClose} />);
|
|
61
|
-
await waitFor(() => expect(
|
|
61
|
+
await waitFor(() => expect(document.querySelector("iframe.views-frame")).toBeTruthy());
|
|
62
62
|
// jsdom's window is 1024 wide — the desk case, where the page exists.
|
|
63
63
|
// No click needed: the newest item is already the page.
|
|
64
64
|
let frame = document.querySelector("iframe.views-frame") as HTMLIFrameElement;
|
|
@@ -70,7 +70,9 @@ describe("ViewsPanel", () => {
|
|
|
70
70
|
// browser's PDF viewer, which is half of what the pane is for.
|
|
71
71
|
expect(frame.hasAttribute("sandbox")).toBe(false);
|
|
72
72
|
expect(screen.getByText("1 / 3")).toBeTruthy();
|
|
73
|
-
// The index
|
|
73
|
+
// The index is beside the page from the start — both in one window; a
|
|
74
|
+
// plain click on a row turns the page.
|
|
75
|
+
expect(document.querySelector(".views-panel.rail-side .views-list")).toBeTruthy();
|
|
74
76
|
const row = screen.getByText("views-test.html").closest("a") as HTMLAnchorElement;
|
|
75
77
|
expect(row.getAttribute("target")).toBe("_blank");
|
|
76
78
|
fireEvent.click(row);
|
|
@@ -88,7 +90,7 @@ describe("ViewsPanel", () => {
|
|
|
88
90
|
expect(onClose).toHaveBeenCalled();
|
|
89
91
|
});
|
|
90
92
|
|
|
91
|
-
it("docked: the page is open at once
|
|
93
|
+
it("docked: the page is open at once with the index beside it, and keys are focus-scoped", async () => {
|
|
92
94
|
// The dock only exists on wide windows; jsdom defaults to 1024.
|
|
93
95
|
vi.stubGlobal("innerWidth", 1280);
|
|
94
96
|
const onClose = vi.fn();
|
|
@@ -111,36 +113,23 @@ describe("ViewsPanel", () => {
|
|
|
111
113
|
fireEvent.keyDown(window, { key: "Escape" });
|
|
112
114
|
expect(onClose).not.toHaveBeenCalled();
|
|
113
115
|
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
expect(document.querySelector("
|
|
119
|
-
|
|
120
|
-
expect(document.querySelector(".views-
|
|
121
|
-
//
|
|
116
|
+
// The index sits beside the page in the dock too; ☰ hides it for a
|
|
117
|
+
// wider page and the choice is remembered.
|
|
118
|
+
expect(document.querySelector(".views-panel.rail-side .views-list")).toBeTruthy();
|
|
119
|
+
fireEvent.click(screen.getByRole("button", { name: "Hide the index" }));
|
|
120
|
+
expect(document.querySelector(".views-list")).toBeNull();
|
|
121
|
+
expect(store.hop_views_rail).toBe("0");
|
|
122
|
+
expect(document.querySelector("iframe.views-frame")).toBeTruthy();
|
|
123
|
+
// Esc from the panel closes it.
|
|
122
124
|
fireEvent.keyDown(panel, { key: "Escape" });
|
|
123
125
|
expect(onClose).toHaveBeenCalled();
|
|
124
126
|
});
|
|
125
127
|
|
|
126
|
-
it("a session that has published nothing
|
|
128
|
+
it("a session that has published nothing says so — it never widens by itself", async () => {
|
|
127
129
|
render(<ViewsPanel session="Nowhere" onClose={() => {}} />);
|
|
128
|
-
await waitFor(() => expect(
|
|
129
|
-
expect(screen.getByRole("tab", { name: "all" }).getAttribute("aria-selected")).toBe("
|
|
130
|
-
expect(
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("the drawer pull says the word and wears the count", () => {
|
|
134
|
-
const onOpen = vi.fn();
|
|
135
|
-
const { rerender } = render(<ViewsPull count={7} fresh={0} onOpen={onOpen} />);
|
|
136
|
-
const pull = screen.getByRole("button", { name: "Views: 7 published" });
|
|
137
|
-
expect(pull.textContent).toContain("Views");
|
|
138
|
-
expect(pull.textContent).toContain("7");
|
|
139
|
-
fireEvent.click(pull);
|
|
140
|
-
expect(onOpen).toHaveBeenCalled();
|
|
141
|
-
rerender(<ViewsPull count={7} fresh={2} onOpen={onOpen} />);
|
|
142
|
-
expect(screen.getByRole("button", { name: "Views: 7 published, new in 2 sessions" }).className).toContain("fresh");
|
|
143
|
-
expect(screen.getByText("2 new")).toBeTruthy();
|
|
130
|
+
await waitFor(() => expect(screen.getByText(/Nothing published yet by this session/)).toBeTruthy());
|
|
131
|
+
expect(screen.getByRole("tab", { name: "all" }).getAttribute("aria-selected")).toBe("false");
|
|
132
|
+
expect(document.querySelector("iframe.views-frame")).toBeNull();
|
|
144
133
|
});
|
|
145
134
|
|
|
146
135
|
it("scoped open can widen to the fleet without reopening", async () => {
|
package/package.json
CHANGED