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.
@@ -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.
@@ -1,4 +1,13 @@
1
- # Product video v3 — what shipped since August
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
@@ -44,7 +44,7 @@ import {
44
44
  type PaneNode,
45
45
  type Side
46
46
  } from "./utils/paneTree";
47
- import { ViewsPanel, ViewsPull, hasUnseenViews, loadViewsSeen } from "./components/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
- {unseenViewSessions > 0
4163
- ? <span className="views-entry-count fresh" aria-label={`New views in ${unseenViewSessions} session${unseenViewSessions === 1 ? "" : "s"}`}>{unseenViewSessions} new</span>
4164
- : totalViews > 0 ? <span className="views-entry-count">{totalViews}</span> : null}
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 the newest result (the
11
- // scoped session's, or the fleet's), with ‹ › to step through the rest and
12
- // an index you can pull open beside or over the page. There is no separate
13
- // "list mode" to click through first — the list was a detour on the way to
14
- // the thing you came to read.
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. Opened from the ◧ Views pull on the
22
- // right edge, the topbar, a card's ◧ chip, or ⌘⇧V.
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 to read a report AND keep the index beside it; still leaves a
172
- // usable terminal on a 1440 display. The drag handle remembers a choice.
173
- const DEFAULT_DOCK_W = 620;
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(380, Math.min(Math.round(window.innerWidth * 0.7), w));
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) is a companion to the
203
- // reader, not a mode of its own. BESIDE the page where there is room, it
204
- // stays open unless you hide it (remembered). OVER the page — a narrow
205
- // dock — it is a sheet you pull when you want it and never on open, so
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 index OVER the page is the one layer to back out of; the
326
- // reader itself is the panel, so the next Escape closes it.
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, indexOpen, dockW]);
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
- // Where the index lives: beside the page when the panel is wide enough for
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" : "Everything published"}
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 ? (railOverlay ? " rail-over" : " rail-side") : "")}
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 && !railOverlay && list}
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: 240px; }
4515
- /* The index OVER the page (a narrow dock): a sheet under the nav bar. */
4516
- .views-index {
4517
- position: absolute; inset: 0; z-index: 2;
4518
- display: flex; flex-direction: column;
4519
- background: var(--panel);
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: none;
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, ViewsPull, hasUnseenViews, loadViewsSeen } from "../src/components/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(screen.getByText("Views end-to-end")).toBeTruthy());
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 sits beside the page; a plain click on a row turns the page.
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, the index rides OVER it on demand, and keys are focus-scoped", async () => {
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
- // A 620px dock has no room for the index beside the page: ☰ lays it
115
- // OVER the page, and Esc from the panel backs that one layer out.
116
- fireEvent.click(screen.getByRole("button", { name: "Show the index" }));
117
- expect(document.querySelector(".views-index .views-list")).toBeTruthy();
118
- expect(document.querySelector("iframe.views-frame")).toBeTruthy(); // the page stays under it
119
- fireEvent.keyDown(panel, { key: "Escape" });
120
- expect(document.querySelector(".views-index")).toBeNull();
121
- // The next Esc from the panel closes it.
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 widens to the fleet by itself", async () => {
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(document.querySelector("iframe.views-frame")).toBeTruthy());
129
- expect(screen.getByRole("tab", { name: "all" }).getAttribute("aria-selected")).toBe("true");
130
- expect(screen.getByText("1 / 3")).toBeTruthy();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.58",
3
+ "version": "0.11.60",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {