B3 UX
The three thesis capabilities and the network states, each proven by a named
Playwright spec against the fixture site. Every spec starts by calling
POST http://localhost:9999/__mock/reset and POST /__mock/up in a
beforeEach.
Shared helper to create first: test/e2e/helpers.js exporting
resetMock(request), setMockDown(request, down), mockState(request),
openFooter(page) (clicks [data-disclosure-toggle] inside #history),
selectText(page, blockId, text) (uses page.evaluate to build a Range
over the first occurrence of text inside [data-redline-block="<id>"],
add it to the selection, and dispatch mouseup on the element), and
connectGitLab(page) (clicks the connect link and waits for navigation back
to baseURL).
T3.0 Shared client store
- Wave: 3
- Depends on: T2.4
- Size: M
- Design: state-machine.md (client state machine)
- Touches:
client.js,index.d.ts,index.js,test/client-store.test.js(new) - Why: three components each fetch the same payload and each invent their own error handling. One store owns the machine; components render it.
Steps
client.js: exportcreateRedlineStore({ serviceUrl, siteId, documentPath, fetch })returning an object with gettersstatus,payload,error,updatedAt;load()(dedupes an in-flight load, applies the transitions, never starts a new load within 5 seconds of the previous start unlessload({ force: true }));subscribe(listener)(called immediately with the current snapshot and on every change; returns an unsubscribe function);connectUrl(returnTo);message()returning the string for the current status viaREDLINE_MESSAGES.- Export
getRedlineStore(options)memoised onglobalThis.__redlineStoresby${serviceUrl}|${siteId}|${documentPath}. - The store requests
/v1/sites/<siteId>/documents/state?path=<documentPath>withcredentials: 'include',Accept: application/json, no custom headers (keeps the request CORS-simple).
Acceptance
test/client-store.test.js with a fake fetch:
store moves static to probing to livestore derives connect-required from the payloadstore classifies a network failure as offline-network and keeps the previous payloadstore classifies gitlab-unreachable as offline-gitlabstore dedupes concurrent loadsstore throttles reloads to five seconds unless forcedstore notifies subscribers on every transition
npm test 2>&1 | grep -c "^✔ store" # prints 7
T3.2 Remove browser dialogs
- Wave: 3
- Depends on: none (listed in wave 3 to batch with component work)
- Size: S
- Touches:
GitComments.astro,test/components.test.js(new) - Why:
window.promptandwindow.confirmblock the page and cannot be driven reliably by automation. Replies and deletes become inline controls.
Steps
- Reply: each thread gets a hidden
<form data-reply-form>with a textarea and a submit button; the Reply button toggles it. Submitting posts the reply and hides the form. - Delete: the Delete button becomes a two-step control. First click sets
data-confirm="true"and changes its text toConfirm delete; second click deletes; any other click in the thread resets it. - No
window.prompt,window.confirmoralert(anywhere in the three components.
Acceptance
test/components.test.js:
components use no blocking browser dialogs(reads the three.astrosources; asserts none containswindow.prompt,window.confirm,confirm(,alert()components compile(reusescripts/check-components.jslogic)
npm test 2>&1 | grep -c "^✔ components" # prints 2
T3.1 Reachability states in components
- Wave: 4
- Depends on: T3.0, T0.3, T0.5, T3.2
- Size: L
- Design: state-machine.md, network.md
- Touches:
GitHistoryFooter.astro,GitTrackChanges.astro,GitComments.astro,test/e2e/reachability.spec.js(new),test/e2e/static-fallback.spec.js(new),test/e2e/helpers.js(new) - Why: the reader must be told which edge failed and what they can do.
Steps
- Every component’s
<script>importsgetRedlineStoreandREDLINE_MESSAGESfrom./client.js(relative; the file sits beside the component in the published package) and subscribes to the store built from itsdata-service-url,data-site-id,data-document-path. - Each component sets
data-redline-state="<status>"on its root element on every snapshot, and rendersstore.message()in its status element: footer[data-history-status] > span:last-child, track changes[data-track-status], comments[data-comment-status]. - The footer calls
store.load()when opened and on its poll interval; the other two call it on mount. The footer’s existingrenderLiveStateruns onliveandconnect-required; on any failure the build-time markup is left as it is (it already is). - The comments component shows
[data-gitlab-connect]only inconnect-required, withhref = store.connectUrl(window.location.href). - Remove each component’s own
fetchcode.
Acceptance
test/e2e/reachability.spec.js:
shows the private-network message when the service is unreachable(page.route('http://localhost:8787/**', r => r.abort('connectionrefused')); open footer; expect#historyto havedata-redline-state="offline-network"and to containREDLINE_MESSAGES['offline-network']; same for[data-git-comments])shows the gitlab-unavailable message when gitlab is down(setMockDown(request, true); reload; expectoffline-gitlabstate and message on all three roots)shows the connect prompt when comments need a gitlab session(default; expect[data-git-comments]stateconnect-required, link visible withhrefstartinghttp://localhost:8787/v1/auth/gitlab/start?returnTo=)recovers to live when gitlab comes back(down → reload → up → click[data-track-refresh]→ expectlive)
test/e2e/static-fallback.spec.js:
renders build-time history without the service(abort service routes; expect the footer summary[data-summary-meta]to match/\d+ revisions?/and the history tab to list at least one commit row before and after opening)
npx playwright test test/e2e/reachability.spec.js test/e2e/static-fallback.spec.js 2>&1 | tail -3 # "5 passed"
npm run test:e2e 2>&1 | tail -3 # "6 passed" (health + 5)
T3.3 OAuth connect journey
- Wave: 5
- Depends on: T3.1, T0.4
- Size: S
- Touches:
test/e2e/oauth.spec.js(new),test/e2e/helpers.js - Why: proves the identity edge end to end in a browser: site → service → GitLab authorize → service callback → site, with a session cookie that the next request carries.
Steps
connectGitLab(page): click[data-gitlab-connect],await page.waitForURL(/localhost:4321/).- Spec.
Acceptance
test/e2e/oauth.spec.js:
connects gitlab through oauth and shows the comment form(connect; expect[data-git-comments]statelive,[data-comment-form]visible, andGET http://localhost:8787/v1/auth/gitlab/statusviapage.request(shares cookies) to return{ connected: true, user: { username: 'test' } })logout clears the session(page.request.post('http://localhost:8787/v1/auth/gitlab/logout'); reload; expectconnect-required)
npx playwright test test/e2e/oauth.spec.js 2>&1 | tail -3 # "2 passed"
npm run test:e2e 2>&1 | tail -3 # all passed; the total depends on which wave-5 tasks are done
T3.5 Inline redline
- Wave: 5
- Depends on: T3.0, T2.2, T2.6, T0.3
- Size: L
- Design: state-machine.md, fixture-data.md
- Touches:
GitRedline.astro(new),client.js(applyInlineRedline,clearInlineRedline),index.js,index.d.ts,package.json(exports,files),test/fixtures/site/src/pages/index.astro(add the component),test/e2e/redline.spec.js(new),README.md(section “Inline redline”) - Why: capability 1 of the thesis.
Steps
client.js: exportapplyInlineRedline(root, layer, { diffWords })andclearInlineRedline(root):replace: find[data-redline-block="<blockId>"]insideroot; store its original child nodes in aWeakMap; setdata-redline-change="replace"; replace its children with the segments ofdiffWords(stripMarker(before.content), stripMarker(after.content))rendered as text nodes,<del>and<ins>.stripMarkerremoves a leading#{1,6}for headings and a leading list marker for list items; code blocks render as one<del>and one<ins>of the whole content.delete: wrap the existing children in a<del>; setdata-redline-change="delete".insert: create the element type matching the block (h1…h6by heading level,li,pre, elsep) with an<ins>containing the content, attributesdata-redline-block="<blockId>",data-redline-change="insert",data-redline-inserted="true"; insert it after[data-redline-block="<afterBlockId>"]or as the first child ofrootwhenafterBlockIdis null.- Each decorated element also gets
data-redline-layer="<layer.iid>"and dispatchesredline:changeasdecorateDocumentChangesdoes today. clearInlineRedlinerestores originals, removes inserted elements and the three attributes.
GitRedline.astro(propsserviceUrl,siteId,documentPath,contentSelectordefault[data-redline-document],titledefaultProposed changes): a toolbar listing one<button type="button" data-redline-layer-toggle="<iid>" aria-pressed="false">!<iid> · <title></button>perpayload.changeLayers, a status line bound to the store, anddata-redline-state. Pressing a button applies that layer inline and clears any other; pressing it again clears. Only one layer at a time.- Fixture page: add
<GitRedline …/>directly above the article.
Acceptance
test/e2e/redline.spec.js:
renders the proposal as inline tracked changes(press the!7toggle;[data-redline-block="title:paragraph:paragraph"]hasdata-redline-change="replace", itsdelhas textRevised, itsinshas textProposed, and its full text isRevisedProposed paragraph.)clearing the layer restores the original text(press again; attribute gone; textRevised paragraph.)only one layer is inline at a time(with one layer in the fixture this asserts the toggle’saria-pressedflips and no element hasdata-redline-insertedafter clearing)
npx playwright test test/e2e/redline.spec.js 2>&1 | tail -3 # "3 passed"
npm run test:e2e 2>&1 | tail -3 # all passed
T3.6 Changed since
- Wave: 5
- Depends on: T3.0, T2.2, T2.3, T0.3
- Size: M
- Design: fixture-data.md (blocks changed since)
- Touches:
GitChangedSince.astro(new),client.js(markChangedSince),index.js,index.d.ts,package.json,test/fixtures/site/src/pages/index.astro,test/e2e/changed-since.spec.js(new),test/client-state.test.js(unit formarkChangedSince),README.md(section “Changed since”) - Why: capability 2 of the thesis.
Steps
client.js: exportchangedBlocksSince(history, sha)returning the set of block ids with anyblockHistoryentry whose commit appears beforeshainhistory.commits(newest first);sha === headgives an empty set; an unknown sha gives every block with history. ExportmarkChangedSince(root, blockIds)that setsdata-redline-changed-since="true"on matching[data-redline-block]elements and clears it elsewhere; returns the count marked.GitChangedSince.astro(same props as GitRedline plusstorageKeydefaultredline:last-seen): a<select data-since>with one option per commit (<shortSha> · <date> · <title>, valuesha), an optionLast visit(valuelast-visit, present only whenlocalStorage[storageKey:<siteId>:<documentPath>]holds a sha that is in the list), and a<p data-since-status>. On change compute and mark, and set the status to<N> blocks changed since <shortSha>orNo blocks changed since <shortSha>. Onlive, if a last-visit sha exists select it automatically; then store the current head sha as the last-visit value (wrap storage access intry/catch).- Fixture page: add
<GitChangedSince …/>beside GitRedline.
Acceptance
Unit (test/client-state.test.js):
changed blocks since a revision follow the fixture counts(since c1 → 3, c2 → 2, c3 → 0 using the expectedblockHistory)
test/e2e/changed-since.spec.js:
marks blocks changed since a chosen revision(selectaaaa…; expect 3 elements withdata-redline-changed-since="true"and status3 blocks changed since aaaaaaaa; selectcccc…; expect 0 andNo blocks changed since cccccccc)remembers the last visit(first load seeds storage; set storage to c2 viapage.evaluate; reload; expect theLast visitoption selected and 2 marked)
npx playwright test test/e2e/changed-since.spec.js 2>&1 | tail -3 # "2 passed"
npm run test:e2e 2>&1 | tail -3 # all passed
T3.4 Anchored comments journey
- Wave: 6
- Depends on: T3.3, T3.2, T2.2, T3.1
- Size: M
- Touches:
GitComments.astro,test/e2e/comments.spec.js(new) - Why: capability 3 of the thesis, end to end: select, comment, attributed, anchored, reply, resolve, delete, all through GitLab.
- Interim store allowed: if GitLab’s discussion model makes anchored comments unreliable (for example the anchor marker in the note body is mangled, or issue search is too slow to bind a comment to its document), the executor may add a small interim store in the service (SQLite file or JSON under a mounted volume) that holds the anchor and the GitLab note id, with GitLab still holding the body and author. A task
T3.7 propagate interim comment recordsmust then be added to B3 andSTATUS.mdwith its own acceptance test proving every interim record reaches GitLab. The Playwright assertions on the mock state stay as written.
Steps
GitComments.astro: remove the fallback that inventsdom:<tag>:<n>ids. Blocks withoutdata-redline-blockproduce a document-level anchor ({ selectedText }only).- Highlighting must set
data-redline-comment="<discussionId>"on the anchored block in addition to the CSS highlight, so tests can assert it. - Spec.
Acceptance
test/e2e/comments.spec.js (each test connects GitLab first):
creates an anchored comment attributed to the gitlab user(selectText(page, 'title:paragraph:paragraph', 'Revised'); expect[data-selection-label]to containCommenting on: “Revised”; fill the textarea withNeeds a source.; submit; expect one.agc__threadcontainingTest UserandNeeds a source.; expect[data-redline-block="title:paragraph:paragraph"]to havedata-redline-comment;mockState(request)shows one issue titled[Document Review] src/content/doc.mdand one discussion whose first note body contains<!-- redline-anchor:and decodes to an anchor withblockId: 'title:paragraph:paragraph',startOffset: 0,endOffset: 7)replies resolves and deletes without dialogs(replyAdded.→ thread shows two notes; Resolve → thread has classagc__thread--resolved; Delete, Confirm delete → no threads; mock state has no discussions)a document-level comment works without a selection(clear selection; submitGeneral note.; thread appears; no element hasdata-redline-comment)
npx playwright test test/e2e/comments.spec.js 2>&1 | tail -3 # "3 passed"
npm run test:e2e 2>&1 | tail -3 # "16 passed"