Compare commits

..
10 Commits
Author SHA1 Message Date
thandClaude Opus 4.6 64be9257f2 fix(tests): chat thread-settings test is idempotent
The row the test writes survives the backup/restore cycle (plugin
tables aren't in the export), so on the second run the GET returned
1 and the "starts at 0" assertion blew up. Explicitly reset the
flag at the start, then flip, then reset again — each run lands
the DB in the same state it started.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:37:35 +02:00
thandClaude Opus 4.6 0022f64c50 chat: merge caret + title into one back trigger
Caret and "chat" label were two hit targets with two hover states and
tooltips. Collapse them into a single <button id="chat-header-nav">
with one tooltip ("back to conversations") — the caret becomes a
visibility-only hint inside the button, shown when there's somewhere
to go back to, hidden on the list itself. The .navigable class
controls whether the button reads as interactive, so clicking the
label while already on the list is a cursor-default no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:33:22 +02:00
thandClaude Opus 4.6 d2f7b98e6c chat: clicking the title goes back to the conversation list
Same path as the back-caret — a bigger, easier target than the
icon. No-op when already on the list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:30:14 +02:00
thandClaude Opus 4.6 8020ddc787 tests: purge editor drafts after a run
Tests that opened the card modal left hyprcrd-editor-draft:* entries
in localStorage. The pre-run purge cleared them before the baseline
backup, but anything the tests wrote during the run stayed behind —
the user's next card open then fired "unsaved draft from 0 min ago,
restore?" for a non-edit they didn't make. Repeat the purge after
restore so the runner closes with a clean slate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:28:29 +02:00
thandClaude Opus 4.6 87ce038b19 new-bucket dialog: Enter in name field submits
Typing a name and hitting Enter did nothing — users had to move to
the Create button. Wire Enter on the name input to click the submit
path so the common flow is keyboard-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:23:57 +02:00
thandClaude Opus 4.6 ccba2932c2 calendar: include Bis: in the appointment-create template
CalDavSync already reads **Bis:** out of the body on push (and writes
it on pull), but the in-app template used when the user clicks a
calendar day to create an appointment didn't offer the field —
forcing the user to type the marker from scratch for multi-day or
ranged events. Drop it in next to **Zeit:** so the round-trip is
symmetric.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:23:13 +02:00
thandClaude Opus 4.6 f2a33a9164 bucket-bar: hide scrollbar, soft edge fades, wheel-to-scroll
The horizontal scrollbar on the bucket row was ugly and
inconsistent with the rest of the app's scrollbar-less panels. Hide
it, redirect vertical wheel over the bar to horizontal scroll, and
surface overflow with soft bg-coloured gradients on whichever side
has more content off-screen: right only while anchored at the
start, both in the middle, left only at the end.

Fade overlays live on #bucket-row (parent) as pseudo-elements with
pointer-events:none, rather than mask-image on the bar itself — a
mask would also clip super-bucket popovers that are intentionally
rendered inside #bucket-bar for the collab MutationObserver.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:20:27 +02:00
thandClaude Opus 4.6 41272bd9ce board: spinner during first load instead of "no cards found"
An empty board on first load blurting "no cards found" was alarming
on a board the user knew had cards. Track a cardsLoaded flag that
flips on the first /api/cards (meta or full) response — or on a
cached-cards hydration — and show a centred spinner placeholder
while it's still false. The "no cards found" copy only shows once
we've actually heard back and the set is genuinely empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:16:45 +02:00
thandClaude Opus 4.6 d31e186bd9 docs: document app-only compose + fix dev.sh for relay profile
dev.sh still has to opt into the relay profile now that it's no
longer in the default set; otherwise `./dev.sh` would print "relay:
http://localhost:8081" but skip starting the container. README gets
a short compose section in Quick Start explaining the app-only
default and how to add --profile relay for a local relay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:12:06 +02:00
thandClaude Opus 4.6 0ac0e34012 docker: make relay opt-in via compose profile
Running a local relay stopped being the common case — most of the
time we're developing against a real one. Move the relay service
behind a `relay` profile so plain `docker compose up` only brings up
the app. Use `docker compose --profile relay up` when a local relay
is wanted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:10:18 +02:00
17 changed files with 215 additions and 25 deletions

No files matched your search

+18
View File
@@ -23,6 +23,22 @@ Open `http://localhost:8080` and set your password on first visit.
The `-v hyprcrd-data:/var/www/html/data` flag persists the database across container restarts.
### Via docker compose
If you prefer compose, the bundled `docker-compose.yml` starts the app alone by default:
```bash
docker compose up -d # just the app on :8088
```
To also bring up a local relay (for multi-tenant messaging / collab / chat), opt in via the `relay` profile:
```bash
docker compose --profile relay up -d # app on :8088 + relay on :8081
```
Most installs talk to a remote relay, so the app-only default keeps the local setup minimal.
## Manual Installation
### 1. Clone
@@ -418,6 +434,8 @@ access via `.htaccess`).
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding. From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
Under the hood this runs `docker compose --profile relay up --build`. The relay service lives behind the `relay` compose profile, so a plain `docker compose up` brings up only the app — handy when you're developing against a remote relay.
### Setup workflow
1. Open the relay URL **from a desktop browser** — the setup wizard
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.5');
define('APP_VERSION', '1.9.12');
// Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+6 -2
View File
@@ -1,9 +1,11 @@
#!/bin/bash
# Start hyprcrd + relay for local development
# Start hyprcrd + relay for local development. The relay service is
# behind a compose profile since it's opt-in — this script explicitly
# opts in so you get both containers.
cd "$(dirname "$0")"
docker compose up -d --build
docker compose --profile relay up -d --build
echo ""
echo " hyprcrd: http://localhost:8088"
@@ -11,3 +13,5 @@ echo " relay: http://localhost:8081"
echo ""
echo " relay from hyprcrd container: http://relay:80"
echo ""
echo " (for app-only, drop the relay profile: docker compose up -d)"
echo ""
+4
View File
@@ -9,8 +9,12 @@ services:
- ./public:/var/www/html/public
- ./config.php:/var/www/html/config.php
# Relay is opt-in via the `relay` profile. Plain `docker compose up`
# starts just the app; use `docker compose --profile relay up` when
# you want to develop against a local relay instead of a remote one.
relay:
build: ./relay
profiles: ["relay"]
ports:
- "8081:80"
volumes:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "1.9.5",
"version": "1.9.12",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+44
View File
@@ -780,6 +780,50 @@ body.detail-image-fs-open { overflow: hidden; }
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
}
/* Bucket bar: no visible scrollbar, soft edge fades when the row
overflows so the user knows there's more off-screen. Fade is
implemented via absolutely-positioned overlay pseudo-elements on
#bucket-row (parent) rather than mask-image on the bar itself,
because mask-image would also clip super-bucket popovers rendered
inside the bar. Overlays use pointer-events:none so clicks pass
through to the tabs underneath. */
#bucket-bar {
scrollbar-width: none;
-ms-overflow-style: none;
scroll-behavior: smooth;
}
#bucket-bar::-webkit-scrollbar { display: none; }
#bucket-row::before,
#bucket-row::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 2;
}
#bucket-row::before {
left: 24px; /* matches .px-6 = 24px on #bucket-row */
background: linear-gradient(to right, var(--t-bg) 0%, transparent 100%);
}
#bucket-row::after {
/* Stop at the right edge of #bucket-bar (before the toolbar
toggles). The toolbar is flex-0, bar is flex-1 — the easiest
way is to inset the overlay by the total width of the toolbar
via a right offset that the JS writes. Defaults to 24px (the
.px-6 pad) and the JS overrides with the real value. */
right: var(--bucket-row-right-inset, 24px);
background: linear-gradient(to left, var(--t-bg) 0%, transparent 100%);
}
#bucket-row.fade-left::before,
#bucket-row.fade-both::before { opacity: 1; }
#bucket-row.fade-right::after,
#bucket-row.fade-both::after { opacity: 1; }
/* Unread dot on the fixed `all` bucket-tab — appears when new cards
arrive while the user is viewing a different bucket. Cleared the
moment the user navigates back to all (renderBoard drains the
+8 -2
View File
@@ -45,6 +45,7 @@ window.openFindBar = openFindBar;
window._reloadAll = async () => {
await loadBuckets();
state.cards = await api('api/cards') || [];
state.cardsLoaded = true;
renderBoard();
refreshCalendar();
// Let plugins / modules react to a full card reload (detail view checks
@@ -86,10 +87,14 @@ function setCachedCards(cards) {
}
async function loadCards() {
// Phase 0: Show cached cards instantly (from previous session)
// Phase 0: Show cached cards instantly (from previous session).
// A hit here is enough to flip cardsLoaded — fresh meta will
// overwrite state.cards a moment later, but the spinner→content
// transition already happened.
const cached = getCachedCards();
if (cached.length) {
state.cards = cached.map(c => ({ ...c, body: null }));
state.cardsLoaded = true;
updateSortUI();
renderBoard();
}
@@ -97,6 +102,7 @@ async function loadCards() {
// Phase 1: Load metadata → update board with fresh titles/tags/dates
const meta = await api('api/cards/meta');
if (!meta) return;
state.cardsLoaded = true;
state.cards = meta.map(c => ({ ...c, body: null }));
setCachedCards(meta);
updateSortUI();
@@ -158,7 +164,7 @@ importFile?.addEventListener('change', async e => {
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
const data = JSON.parse(text);
if (data.error) throw new Error(data.error);
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
await loadBuckets(); state.cards = await api('api/cards'); state.cardsLoaded = true; renderBoard();
} catch (err) {
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
}
+9
View File
@@ -587,9 +587,18 @@ export function renderBoard() {
board.style.display = 'flex';
board.style.alignItems = 'center';
board.style.justifyContent = 'center';
// Distinguish "genuinely empty" from "haven't heard back yet".
// Blurting "no cards found" during the initial load is scary
// on boards the user knows aren't empty.
if (!state.cardsLoaded) {
board.innerHTML = `<div class="flex flex-col items-center text-center">
<i class="ph ph-circle-notch text-4xl text-t-dim mb-4 block card-spinner"></i>
<p class="font-pixel text-sm text-t-dim">loading…</p></div>`;
} else {
board.innerHTML = `<div class="flex flex-col items-center text-center">
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
}
const fc = $('footer-count');
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
return;
+37
View File
@@ -186,6 +186,43 @@ const inpBucket = $('inp-bucket');
const searchFilter = $('search-bucket-filter');
const tasksFilter = $('tasks-bucket-filter');
// ── Overflow handling for the bucket bar ─────────────────────────
// Scrollbar is hidden (CSS). Mouse-wheel over the bar translates to
// horizontal scroll so long bucket rows stay usable without a bar.
// Edge fades kick in dynamically based on scroll position so the
// user sees there's more off-screen — both sides at once in the
// middle, one side at either extreme. Fade overlays live on the
// parent #bucket-row (see CSS) so they don't clip super-bucket
// popovers that render inside #bucket-bar itself.
const barRow = $('bucket-row');
function updateBucketBarFades() {
if (!bar || !barRow) return;
const canLeft = bar.scrollLeft > 2;
const canRight = bar.scrollLeft + bar.clientWidth < bar.scrollWidth - 2;
barRow.classList.toggle('fade-left', canLeft && !canRight);
barRow.classList.toggle('fade-right', !canLeft && canRight);
barRow.classList.toggle('fade-both', canLeft && canRight);
// Right-side fade overlay is anchored to the parent's right edge;
// the actual bar ends before the toolbar toggles. Compute the
// distance so the gradient lines up with the bar's right edge.
const rowRect = barRow.getBoundingClientRect();
const barRect = bar.getBoundingClientRect();
const rightInset = Math.max(0, rowRect.right - barRect.right);
barRow.style.setProperty('--bucket-row-right-inset', rightInset + 'px');
}
if (bar) {
bar.addEventListener('wheel', (e) => {
if (e.deltaY === 0) return;
if (bar.scrollWidth <= bar.clientWidth) return;
e.preventDefault();
bar.scrollLeft += e.deltaY;
}, { passive: false });
bar.addEventListener('scroll', updateBucketBarFades, { passive: true });
window.addEventListener('resize', updateBucketBarFades);
new MutationObserver(updateBucketBarFades).observe(bar, { childList: true, subtree: false });
updateBucketBarFades();
}
/** Switch the active bucket without going through the click path —
* used by the number-key hotkeys (and any other code that wants to
* activate a bucket programmatically without triggering the super-
+1 -1
View File
@@ -493,7 +493,7 @@ function openDayPopup(ds, cellEl) {
const cfg = calSlug ? getCalConfig(calSlug) : null;
const col = cfg?.color || '#fb923c';
const calTag = calSlug ? ` #${calSlug}` : '';
const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`;
const body = `**Zeit:** \n**Bis:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`;
window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null });
} else if (type === 'birthday') {
const body = `#geburtstag`;
+5
View File
@@ -6,6 +6,11 @@ export const state = {
// Data
cards: [],
buckets: [],
// True once we've got a real response from api/cards (or the
// cached-cards hydration). Until then the board shows a spinner
// instead of the "no cards found" placeholder — an empty board on
// first load is an honest "still fetching" state, not a real empty.
cardsLoaded: false,
// Filters & view — activeBucket is persisted across reloads via
// localStorage. Numeric ids are stringified; the special filters
+17
View File
@@ -29,6 +29,23 @@
flex-shrink: 0;
}
/* Combined caret + title trigger. Navigable (cursor + accent hover)
only when there's somewhere to go back to — toggled by JS via the
`.navigable` class. On the list it reads like a static label. */
.chat-header-nav {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
padding: 0;
color: var(--t-bright);
cursor: default;
transition: color 0.12s;
}
.chat-header-nav.navigable { cursor: pointer; color: var(--t-muted); }
.chat-header-nav.navigable:hover { color: var(--t-accent); }
.chat-view {
display: flex;
flex-direction: column;
+8 -2
View File
@@ -3,8 +3,14 @@
<div id="chat-flyout" class="hidden">
<div id="chat-flyout-header">
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright">chat</span>
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
<!-- Caret + "chat" label are a single trigger with one tooltip.
The caret shows only when there's somewhere to go back to
(thread / pick / room-info); on the list itself the click
is a no-op and the button styles itself as inert. -->
<button id="chat-header-nav" type="button" class="chat-header-nav" data-tip="back to conversations">
<i id="chat-header-back-caret" class="ph ph-caret-left text-sm hidden"></i>
<span id="chat-flyout-title" class="font-pixel text-[10px]">chat</span>
</button>
<span class="flex-1"></span>
<button id="chat-thread-disposable" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="disposable: messages drop after delivery"><i class="ph ph-fire text-sm"></i></button>
<button id="chat-thread-info" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="room info"><i class="ph ph-info text-sm"></i></button>
+19 -5
View File
@@ -179,11 +179,18 @@ function hideAllViews() {
.forEach(id => $(id)?.classList.add('hidden'));
}
// Toggle the caret + make the "chat" trigger look/feel clickable only
// when there's actually somewhere to go back to.
function setNavState(canBack) {
$('chat-header-back-caret')?.classList.toggle('hidden', !canBack);
$('chat-header-nav')?.classList.toggle('navigable', canBack);
}
function showListView() {
hideAllViews();
$('chat-list-view').classList.remove('hidden');
$('chat-flyout-title').textContent = 'chat';
$('chat-header-back')?.classList.add('hidden');
setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden');
currentThread = null;
@@ -193,7 +200,7 @@ function showThreadView(thread) {
hideAllViews();
$('chat-thread-view').classList.remove('hidden');
$('chat-flyout-title').textContent = 'chat';
$('chat-header-back')?.classList.remove('hidden');
setNavState(true);
currentThread = thread;
// Room-only: info icon. 1:1-only: disposable icon.
$('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room');
@@ -205,7 +212,7 @@ function showThreadView(thread) {
function showPickView() {
hideAllViews();
$('chat-pick-view').classList.remove('hidden');
$('chat-header-back')?.classList.add('hidden');
setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden');
}
@@ -213,7 +220,7 @@ function showPickView() {
function showRoomInfoView() {
hideAllViews();
$('chat-room-info-view').classList.remove('hidden');
$('chat-header-back')?.classList.add('hidden');
setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden');
}
@@ -749,7 +756,14 @@ function init() {
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
$('chat-new-btn').addEventListener('click', openPickView);
$('chat-empty-start')?.addEventListener('click', openPickView);
$('chat-header-back').addEventListener('click', () => { showListView(); loadConversations(); });
// Caret + "chat" label are one button with one tooltip. Click is a
// no-op when the caret is hidden (already on the list) — the CSS
// .navigable class tracks whether the button looks clickable.
$('chat-header-nav')?.addEventListener('click', () => {
if (!$('chat-header-nav')?.classList.contains('navigable')) return;
showListView();
loadConversations();
});
$('chat-pick-back').addEventListener('click', showListView);
$('chat-thread-form').addEventListener('submit', sendMessage);
$('chat-thread-info')?.addEventListener('click', openRoomInfo);
@@ -392,6 +392,18 @@ async function createBucketDialog() {
}
});
// Enter in the name field = Create (users expect the submit path
// without reaching for the mouse). Stop propagation so nothing
// upstream grabs it. No-op if name is empty — the submit button's
// own handler validates.
overlay.querySelector('#__cb_name').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
overlay.querySelector('.collab-btn-submit').click();
}
});
setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50);
});
}
+15 -11
View File
@@ -30,20 +30,20 @@ describe('Chat: flyout', () => {
expect(f.classList.contains('hidden')).toBe(true);
});
it('header-back visibility matches active view', async () => {
it('header back-caret visibility matches active view', async () => {
await ensureFlyoutClosed();
click('#chat-footer-btn');
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
// Wait for loadConversations + initial view pick to settle.
await sleep(150);
const back = $('#chat-header-back');
const caret = $('#chat-header-back-caret');
const threadShown = !$('#chat-thread-view').classList.contains('hidden');
const listShown = !$('#chat-list-view').classList.contains('hidden');
// Exactly one of thread / list should be active here (pick view
// is only reached via the + button).
expect(threadShown || listShown).toBe(true);
if (threadShown) expect(back.classList.contains('hidden')).toBe(false);
if (listShown) expect(back.classList.contains('hidden')).toBe(true);
if (threadShown) expect(caret.classList.contains('hidden')).toBe(false);
if (listShown) expect(caret.classList.contains('hidden')).toBe(true);
await ensureFlyoutClosed();
});
@@ -57,9 +57,9 @@ describe('Chat: flyout', () => {
expect($('#chat-pick-view').classList.contains('hidden')).toBe(false);
expect($('#chat-list-view').classList.contains('hidden')).toBe(true);
expect($('#chat-thread-view').classList.contains('hidden')).toBe(true);
// Header back is only for thread view — not needed when pick has
// Back caret is only for thread view — not needed when pick has
// its own dedicated back button.
expect($('#chat-header-back').classList.contains('hidden')).toBe(true);
expect($('#chat-header-back-caret').classList.contains('hidden')).toBe(true);
click('#chat-pick-back');
await sleep(50);
expect($('#chat-pick-view').classList.contains('hidden')).toBe(true);
@@ -120,18 +120,22 @@ describe('Chat: API', () => {
it('/api/chat/thread-settings round-trips disposable flag', async () => {
const peer = '__test_disposable_flip__';
const initial = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
.then(r => r.json());
expect(initial.disposable).toBe(0);
const set = await fetch('api/chat/thread-settings', {
const setFlag = (v) => fetch('api/chat/thread-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ peer, disposable: true }),
body: JSON.stringify({ peer, disposable: v }),
}).then(r => r.json());
// Reset from any leftover state (previous runs leave the row
// behind — restore only rolls cards/buckets, not plugin tables).
const reset = await setFlag(false);
expect(reset.disposable).toBe(0);
const set = await setFlag(true);
expect(set.disposable).toBe(1);
const after = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
.then(r => r.json());
expect(after.disposable).toBe(1);
// Leave a clean slate for the next run.
await setFlag(false);
});
});
+10
View File
@@ -587,6 +587,16 @@ async function run() {
await sleep(200);
} catch {}
// 6a. Purge any editor-draft entries the tests wrote while poking
// the modal. Leaving them around means the next card open pops
// "unsaved draft from 0 min ago — restore?" to the user, which
// is noise since the tests weren't real edits of their cards.
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith('hyprcrd-editor-draft')) localStorage.removeItem(k);
}
} catch {}
// 6b. Put the view back the way the user left it (super-view, sort,
// filters, …) so launching the test runner doesn't silently wipe
// their working context.