Public: anyone can read this; the crew can change it.

The studio says what just changed: a live swap announces itself #115

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/93-the-studio-says-what-just-changed-a-live → main 1 commit fork

  • Merged. scout/93-the-studio-says-what-just-changed-a-live was merged into main as 5e334ff.

Review

Sign in to review this change.
4 files changed +150 −2
src/web/live.ts +64 −2
@@ -10,7 +10,10 @@
1010 *
1111 * Announcements are a count, not the text: the polite live region says "2 new
1212 * comments" while the comments themselves land outside it, so a screen reader
13 * is told something arrived without re-reading the thread.
13 * is told something arrived without re-reading the thread. The app page's own
14 * swap is the one exception — there is no count to give, so the region says
15 * the same word and phrase the card itself just changed to ("live 6.1s after
16 * the push"), read off the swapped-in markup rather than invented twice.
1417 */
1518
1619 import { html, type Html } from "./html.ts";
@@ -131,6 +134,48 @@ export const liveClientJs = /* js */ `
131134 // flight — a burst of queued/started/finished for the same push collapses
132135 // to whatever the last one finds.
133136 var appBusy = false, appQueued = false;
137 function textOf(el) {
138 return el ? el.textContent.replace(/\s+/g, ' ').trim() : '';
139 }
140 // The head card's own word, whatever envPhrase drew into its badge.
141 function headWord(root) {
142 return textOf(root && root.querySelector('.studio-head .badge'));
143 }
144 // When the word is "live", the timing phrase envFacts already printed
145 // under it — never a second way of saying how fast.
146 function headPhrase(root) {
147 var spans = root ? root.querySelectorAll('.studio-head .env-facts span') : [];
148 for (var i = 0; i < spans.length; i++) {
149 var t = textOf(spans[i]);
150 if (t.indexOf('live ') === 0) return t;
151 }
152 return '';
153 }
154 // One preview row's own word, keyed by the "#N title" every row states
155 // first in its meta line, whichever else the title slot shows.
156 function rowWords(root) {
157 var out = {};
158 var items = root ? root.querySelectorAll('#app-previews .list > li') : [];
159 for (var i = 0; i < items.length; i++) {
160 var meta = textOf(items[i].querySelector('.row-meta'));
161 var label = meta.split(' · ')[0] || textOf(items[i].querySelector('.row-title'));
162 if (label) out[label] = textOf(items[i].querySelector('.row-trail .badge'));
163 }
164 return out;
165 }
166 // One sentence for whatever changed between the fragment on screen and the
167 // one just fetched, in the card's own words — never a second vocabulary.
168 // A fragment that reads the same as what's already there returns '', so a
169 // swap that changes nothing visible announces nothing.
170 function announceSwap(oldHead, oldPrev, newHead, newPrev) {
171 var was = headWord(oldHead), is = headWord(newHead);
172 if (is && is !== was) return is === 'live' ? (headPhrase(newHead) || is) : is;
173 var before = rowWords(oldPrev), after = rowWords(newPrev);
174 for (var label in after) {
175 if (after[label] !== before[label]) return label + ' — ' + after[label];
176 }
177 return '';
178 }
134179 function isDeploy(data) {
135180 return isApp && data && data.kind && data.kind.indexOf('run.') === 0 &&
136181 data.payload && data.payload.kind === 'deploy';
@@ -152,11 +197,28 @@ export const liveClientJs = /* js */ `
152197 if (!text) return;
153198 var holder = document.createElement('div');
154199 holder.innerHTML = text;
200 var oldHead = document.getElementById('app-head');
201 var oldPrev = document.getElementById('app-previews');
202 var newHead = holder.querySelector('#app-head');
203 var newPrev = holder.querySelector('#app-previews');
204 var line = announceSwap(oldHead, oldPrev, newHead, newPrev);
155205 ['app-head', 'app-previews'].forEach(function (id) {
156206 var mine = document.getElementById(id);
157207 var theirs = holder.querySelector('#' + id);
158 if (mine && theirs) mine.replaceWith(theirs);
208 if (!mine || !theirs) return;
209 var changed = mine.innerHTML !== theirs.innerHTML;
210 mine.replaceWith(theirs);
211 // A transition, not a resize: the card keeps its own height, and
212 // prefers-reduced-motion already collapses every animation's
213 // duration to nothing (style/base.ts), this one included.
214 if (changed) {
215 theirs.classList.add('studio-just-changed');
216 theirs.addEventListener('animationend', function () {
217 theirs.classList.remove('studio-just-changed');
218 });
219 }
159220 });
221 if (status && line) status.textContent = line.charAt(0).toUpperCase() + line.slice(1);
160222 })
161223 .catch(function () {})
162224 .then(function () {
src/web/studio/style.ts +8
@@ -47,6 +47,13 @@ const appHead = part(/* css */ `
4747 .studio-head form { display: inline-flex; }
4848 `);
4949
50 /** A card the live client just swapped in place (`live.ts`): a background
51 * flash, never a resize, so the page never shifts under a reader. */
52 const justChanged = part(/* css */ `
53 #app-head.studio-just-changed, #app-previews.studio-just-changed { animation: studio-flash 1.1s var(--ease); border-radius: var(--radius); }
54 @keyframes studio-flash { 0% { background-color: color-mix(in srgb, var(--accent) 14%, transparent); } 100% { background-color: transparent; } }
55 `);
56
5057 /** One of the five words, as a badge (`words.ts`). */
5158 const wordBadges = part(/* css */ `
5259 .badge[data-variant="accent"] { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
@@ -99,6 +106,7 @@ export const studioCss = `${[
99106 shell,
100107 tellBox,
101108 appHead,
109 justChanged,
102110 wordBadges,
103111 rows,
104112 asks,
test/live-pages.test.ts +20
@@ -108,6 +108,26 @@ describe("live browsing pages", () => {
108108 // the deploy run's own `run.finished` — the client must listen for it
109109 // directly, not only learn a preview is live once its tests are done.
110110 expect(html).toContain("app.serving");
111 // The polite region a swap must speak into — the one place a screen
112 // reader learns a preview just went live.
113 expect(html).toContain('id="live-status"');
114 });
115
116 test("the client script sets the status when the app fragment swaps, not only on the generic New-activity fallback", async () => {
117 const { liveClientJs } = await import("../src/web/live.ts");
118 // `onEvent` returns straight out of `refreshApp()` for a deploy/serving
119 // event (`live.ts`), so it never reaches the fallback branch's
120 // `status.textContent = 'New activity on this page'` — the swap itself
121 // must set the status, in the same sentence the swapped-in card shows.
122 const refreshApp = liveClientJs.slice(
123 liveClientJs.indexOf("function refreshApp"),
124 liveClientJs.indexOf("function onEvent"),
125 );
126 expect(refreshApp).toContain("status.textContent");
127 // A fragment identical to what's already on screen must not announce
128 // anything: the sentence is built from what changed, not fetched anew
129 // every time.
130 expect(refreshApp).toContain("announceSwap");
111131 });
112132
113133 test("the studio home wires up asks, apps, now and on-you as live regions, signed in; a signed-out visitor gets neither", async () => {
test/studio.test.ts +58
@@ -1968,6 +1968,64 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
19681968 expect(live).toContain(link);
19691969 });
19701970
1971 test("the fragment's markup is what the live client's status announcement reads: the head's word in a badge, a preview row's word beside its `#N title`", async () => {
1972 // `live.ts`'s `announceSwap` finds the head's word at `.studio-head
1973 // .badge` and, for a preview row, the pull's label at the start of
1974 // `.row-meta` beside its word at `.row-trail .badge`. If this markup
1975 // ever moves, the announcement goes silent without a test failing here.
1976 const text = await (await get("/alice/vault/app/state", owner)).text();
1977 expect(text).toContain("studio-head");
1978 expect(text).toMatch(/studio-head[\s\S]*?class="badge"[^>]*>not running</);
1979
1980 const alice = getUserByName(app.db, "alice")!;
1981 const branch = await commitToBranch(app, alice, repo, {
1982 branch: "feature/announce",
1983 base: repo.defaultBranch,
1984 message: "Add Y",
1985 files: [{ path: "Y.md", content: "y\n" }],
1986 });
1987 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1988 const opened = await openPull(app, alice, repo, {
1989 title: "Add Y",
1990 body: "",
1991 baseBranch: repo.defaultBranch,
1992 headBranch: "feature/announce",
1993 });
1994 if (!opened.ok) throw new Error(String(opened.error));
1995 const pull = opened.value;
1996 const env = previewEnv(pull.number);
1997 const deployRun = createRun(app.db, {
1998 repoId: repo.id,
1999 kind: "deploy",
2000 env,
2001 commitOid: pull.headOid,
2002 reason: "pull",
2003 });
2004 claimRun(app.db, deployRun.id);
2005 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
2006 putEnv(env, {
2007 live: {
2008 commitOid: pull.headOid,
2009 handle: { port: 4323 },
2010 runId: deployRun.id,
2011 since: new Date().toISOString(),
2012 manifest: { tty: null },
2013 },
2014 });
2015 const withPreview = await (
2016 await get("/alice/vault/app/state", owner)
2017 ).text();
2018 const label = `#${pull.number} Add Y`;
2019 expect(withPreview).toContain(`class="row-meta"`);
2020 expect(withPreview).toContain(`class="row-trail"`);
2021 expect(withPreview).toContain(label);
2022 expect(
2023 new RegExp(`${label}[\\s\\S]*?class="row-trail"[\\s\\S]*?>live<`).test(
2024 withPreview,
2025 ),
2026 ).toBe(true);
2027 });
2028
19712029 test("the head measures from the push, not from the permit it waited for: a deploy queued 5s reads 6.0s after the push and names the wait", async () => {
19722030 const createdAt = new Date(Date.now() - 20_000).toISOString();
19732031 const deployStartedAt = new Date(Date.parse(createdAt) + 5_000).toISOString();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs