hop2 0.11.58 → 0.11.59
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 +7 -36
- package/hay/apps/web/src/styles.css +13 -33
- package/hay/apps/web/test/ViewsPanel.test.tsx +15 -21
- 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) && (
|
|
@@ -18,8 +18,8 @@ import { relativeTime } from "../utils/switcherModel";
|
|
|
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
|
//
|
|
@@ -200,15 +200,14 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
200
200
|
});
|
|
201
201
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
202
202
|
// The index (the list of everything published) is a companion to the
|
|
203
|
-
// reader, not a mode of its own
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// the
|
|
203
|
+
// reader, not a mode of its own: a sheet over the page in a narrow dock, a
|
|
204
|
+
// rail beside it where there is room — either way only when you ask.
|
|
205
|
+
// Hidden until asked for, everywhere: the page is what you opened Views
|
|
206
|
+
// to see. Beside the page it stays as you left it (remembered).
|
|
207
207
|
const [railOpen, setRailOpen] = useState<boolean>(() => {
|
|
208
|
-
try { return localStorage.getItem(RAIL_KEY)
|
|
208
|
+
try { return localStorage.getItem(RAIL_KEY) === "1"; } catch { return false; }
|
|
209
209
|
});
|
|
210
210
|
const [indexOpen, setIndexOpen] = useState(false);
|
|
211
|
-
const widened = useRef(false);
|
|
212
211
|
const now = Date.now();
|
|
213
212
|
|
|
214
213
|
// The scope arrives as whatever identifier the opener HAD — the URL's
|
|
@@ -281,15 +280,6 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
281
280
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
282
281
|
}, [items, flatRows]);
|
|
283
282
|
|
|
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
283
|
const at = preview ? flatRows.findIndex((r) => r.path === preview.path) : -1;
|
|
294
284
|
const step = (dir: 1 | -1) => {
|
|
295
285
|
if (!flatRows.length) return;
|
|
@@ -697,22 +687,3 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
697
687
|
</>
|
|
698
688
|
);
|
|
699
689
|
};
|
|
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
|
-
);
|
|
@@ -4670,38 +4670,6 @@ body.pane-dragging { user-select: none; }
|
|
|
4670
4670
|
width: 5px; height: 5px; border-radius: 3px;
|
|
4671
4671
|
background: var(--accent, #7c5cff);
|
|
4672
4672
|
}
|
|
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
4673
|
/* Labelled entry points beat a 16px glyph: the wall's header and the
|
|
4706
4674
|
topbar both say the word, and wear the count. */
|
|
4707
4675
|
.views-entry-count {
|
|
@@ -4716,12 +4684,24 @@ body.pane-dragging { user-select: none; }
|
|
|
4716
4684
|
an inline dot would shove the icon off-centre, so it rides the corner. */
|
|
4717
4685
|
.switcher-action { position: relative; }
|
|
4718
4686
|
.switcher-action .views-chip-dot { position: absolute; top: 5px; right: 5px; }
|
|
4687
|
+
/* A session's results are worth a real button on its card, not an 8px
|
|
4688
|
+
chip: the word, the count, a hit target a thumb can find. */
|
|
4719
4689
|
.switcher-chip.views {
|
|
4720
|
-
border:
|
|
4690
|
+
border: 1px solid var(--line);
|
|
4721
4691
|
cursor: pointer;
|
|
4722
4692
|
font-family: inherit;
|
|
4723
4693
|
line-height: 1.2;
|
|
4694
|
+
font-size: 10.5px;
|
|
4695
|
+
padding: 3px 8px;
|
|
4696
|
+
border-radius: 7px;
|
|
4697
|
+
color: var(--ink);
|
|
4698
|
+
background: var(--panel);
|
|
4699
|
+
white-space: nowrap;
|
|
4724
4700
|
}
|
|
4701
|
+
.switcher-chip.views:hover { border-color: var(--accent, #7c5cff); }
|
|
4702
|
+
/* Dense zooms have no room for the word beside the name — the name owns
|
|
4703
|
+
the row (same rule as the inline actions); the glyph and count stay. */
|
|
4704
|
+
.switcher-overlay.tile-dense .switcher-chip.views .views-word { display: none; }
|
|
4725
4705
|
.switcher-chip.views.fresh {
|
|
4726
4706
|
background: color-mix(in srgb, var(--accent, #7c5cff) 22%, transparent);
|
|
4727
4707
|
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
|
|
|
@@ -16,7 +16,10 @@ let store: Record<string, string> = {};
|
|
|
16
16
|
beforeEach(() => {
|
|
17
17
|
// jsdom's own localStorage is only partially implemented here, and the
|
|
18
18
|
// seen-markers are the whole point of these cases.
|
|
19
|
-
|
|
19
|
+
// The index rail is hidden until asked and the choice is remembered; most
|
|
20
|
+
// cases below read rows from it, so they start with it open, the way a
|
|
21
|
+
// reader who opened it once would. The reader-first case clears this.
|
|
22
|
+
store = { hop_views_rail: "1" };
|
|
20
23
|
vi.stubGlobal("localStorage", {
|
|
21
24
|
getItem: (key: string) => (key in store ? store[key] : null),
|
|
22
25
|
setItem: (key: string, value: string) => { store[key] = value; },
|
|
@@ -57,8 +60,9 @@ describe("ViewsPanel", () => {
|
|
|
57
60
|
|
|
58
61
|
it("opens STRAIGHT INTO the newest result; a row click switches the page; the row stays a real link", async () => {
|
|
59
62
|
const onClose = vi.fn();
|
|
63
|
+
delete store.hop_views_rail; // a first-time reader: nothing remembered
|
|
60
64
|
render(<ViewsPanel onClose={onClose} />);
|
|
61
|
-
await waitFor(() => expect(
|
|
65
|
+
await waitFor(() => expect(document.querySelector("iframe.views-frame")).toBeTruthy());
|
|
62
66
|
// jsdom's window is 1024 wide — the desk case, where the page exists.
|
|
63
67
|
// No click needed: the newest item is already the page.
|
|
64
68
|
let frame = document.querySelector("iframe.views-frame") as HTMLIFrameElement;
|
|
@@ -70,7 +74,10 @@ describe("ViewsPanel", () => {
|
|
|
70
74
|
// browser's PDF viewer, which is half of what the pane is for.
|
|
71
75
|
expect(frame.hasAttribute("sandbox")).toBe(false);
|
|
72
76
|
expect(screen.getByText("1 / 3")).toBeTruthy();
|
|
73
|
-
// The index
|
|
77
|
+
// The index is not shown until asked — the page is the point. ☰ opens
|
|
78
|
+
// it beside the page; a plain click on a row turns the page.
|
|
79
|
+
expect(document.querySelector(".views-list")).toBeNull();
|
|
80
|
+
fireEvent.click(screen.getByRole("button", { name: "Show the index" }));
|
|
74
81
|
const row = screen.getByText("views-test.html").closest("a") as HTMLAnchorElement;
|
|
75
82
|
expect(row.getAttribute("target")).toBe("_blank");
|
|
76
83
|
fireEvent.click(row);
|
|
@@ -123,24 +130,11 @@ describe("ViewsPanel", () => {
|
|
|
123
130
|
expect(onClose).toHaveBeenCalled();
|
|
124
131
|
});
|
|
125
132
|
|
|
126
|
-
it("a session that has published nothing
|
|
133
|
+
it("a session that has published nothing says so — it never widens by itself", async () => {
|
|
127
134
|
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();
|
|
135
|
+
await waitFor(() => expect(screen.getByText(/Nothing published yet by this session/)).toBeTruthy());
|
|
136
|
+
expect(screen.getByRole("tab", { name: "all" }).getAttribute("aria-selected")).toBe("false");
|
|
137
|
+
expect(document.querySelector("iframe.views-frame")).toBeNull();
|
|
144
138
|
});
|
|
145
139
|
|
|
146
140
|
it("scoped open can widen to the fleet without reopening", async () => {
|
package/package.json
CHANGED