Public: anyone can read this; the crew can change it.
The studio says what just changed: a live swap announces itself #115
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/93-the-studio-says-what-just-changed-a-live → main 1 commit fork
4 files
changed +150
−2
src/web/live.ts +64 −2
| @@ -10,7 +10,10 @@ | ||
| 10 | 10 | * |
| 11 | 11 | * Announcements are a count, not the text: the polite live region says "2 new |
| 12 | 12 | * 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. | |
| 14 | 17 | */ |
| 15 | 18 | |
| 16 | 19 | import { html, type Html } from "./html.ts"; |
| @@ -131,6 +134,48 @@ export const liveClientJs = /* js */ ` | ||
| 131 | 134 | // flight — a burst of queued/started/finished for the same push collapses |
| 132 | 135 | // to whatever the last one finds. |
| 133 | 136 | 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 | } | |
| 134 | 179 | function isDeploy(data) { |
| 135 | 180 | return isApp && data && data.kind && data.kind.indexOf('run.') === 0 && |
| 136 | 181 | data.payload && data.payload.kind === 'deploy'; |
| @@ -152,11 +197,28 @@ export const liveClientJs = /* js */ ` | ||
| 152 | 197 | if (!text) return; |
| 153 | 198 | var holder = document.createElement('div'); |
| 154 | 199 | 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); | |
| 155 | 205 | ['app-head', 'app-previews'].forEach(function (id) { |
| 156 | 206 | var mine = document.getElementById(id); |
| 157 | 207 | 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 | } | |
| 159 | 220 | }); |
| 221 | if (status && line) status.textContent = line.charAt(0).toUpperCase() + line.slice(1); | |
| 160 | 222 | }) |
| 161 | 223 | .catch(function () {}) |
| 162 | 224 | .then(function () { |
src/web/studio/style.ts +8
| @@ -47,6 +47,13 @@ const appHead = part(/* css */ ` | ||
| 47 | 47 | .studio-head form { display: inline-flex; } |
| 48 | 48 | `); |
| 49 | 49 | |
| 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 | ||
| 50 | 57 | /** One of the five words, as a badge (`words.ts`). */ |
| 51 | 58 | const wordBadges = part(/* css */ ` |
| 52 | 59 | .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 = `${[ | ||
| 99 | 106 | shell, |
| 100 | 107 | tellBox, |
| 101 | 108 | appHead, |
| 109 | justChanged, | |
| 102 | 110 | wordBadges, |
| 103 | 111 | rows, |
| 104 | 112 | asks, |
test/live-pages.test.ts +20
| @@ -108,6 +108,26 @@ describe("live browsing pages", () => { | ||
| 108 | 108 | // the deploy run's own `run.finished` — the client must listen for it |
| 109 | 109 | // directly, not only learn a preview is live once its tests are done. |
| 110 | 110 | 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"); | |
| 111 | 131 | }); |
| 112 | 132 | |
| 113 | 133 | 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", () => { | ||
| 1968 | 1968 | expect(live).toContain(link); |
| 1969 | 1969 | }); |
| 1970 | 1970 | |
| 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 | ||
| 1971 | 2029 | 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 () => { |
| 1972 | 2030 | const createdAt = new Date(Date.now() - 20_000).toISOString(); |
| 1973 | 2031 | const deployStartedAt = new Date(Date.parse(createdAt) + 5_000).toISOString(); |
new above
offline