diff --git a/BUILD-NOTES.md b/BUILD-NOTES.md index b36da68..9ae6443 100644 --- a/BUILD-NOTES.md +++ b/BUILD-NOTES.md @@ -807,3 +807,74 @@ three new important-style diagnostics. The three local note-margin overrides pre base/tablet/mobile caution spacing, including the existing inline Verify note margin. They are inventoried rather than suppressed or “fixed” by changing approved CSS. No global configuration, runtime, schema, billing, dependencies, sandbox or launch settings change. + +## Accepted final editorial templates — Astra integration + +Adopts the accepted Astra Daily Digest newspaper, Interview Notes quote spread, Case Study evidence +ledger and Launch Announcement dark/light poster. The immutable original batch is +`aa-template-revamp-1788686289312/batch-01-editorial/`; rejected inherited-Sol material is not adopted. +The first fifteen redesigned templates remain unchanged. This is a separate final integration PR, +not deployment authorization; all nineteen are only live after the later reviewed rollout. + +Daily and Interview are exact accepted source files. Case adds one first-line, file-local +`noRedundantRoles` compatibility directive. Launch adds one **element-local** `useSemanticElements` +directive immediately before its existing named CODE region. Nothing else in any template changes: +CSS, titles, body elements, roles, scopes, IDs, links, SVG, meters and command descendants are exact. +Canonical SHA-256: +- Daily: `500f1619164f01ec4aa19a6c1e00277696b50cf3a711463800b22f128a010129` +- Interview: `146ebab1a80f985196bf788ad5d7ab4fc564dec5c72ec0c4f7b9df154a2a9691` +- Case: `bda5f8984424f791f6707dc0565c198a6607fcd98f41d851eaed001e2151b007` +- Launch: `c50f7b44e6e2193126e085fa2b3d3b89266d5dfbb5760dd11633de51fd26fa19` + +Case's explicit inventory is one table, seven rows, four columnheaders and twenty-one cells. Four +native `th scope="col"` headers continue to associate by column with five complete value/meter rows; +the footer cell spans four columns. The exception is bounded by regression assertions, not permission +for future unrelated roles. [MDN's display accessibility note](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/display#tables) +warns that changing table display can affect accessibility in some browsers. Launch preserves its +one named CODE scrolling region and native Chromium keyboard behavior. No other rule suppression or +global configuration change is authorized. Eight paired sandbox PNGs/accessibility trees and native +relationships match the accepted candidates; Chromium evidence does not certify Safari/Firefox or +screen-reader navigation. + +Original content parity accounts for307 text nodes,303 retained after four already-approved decorative +quote-mark removals in Interview, and170 numeric occurrences. All substantive text and keyed story, +quote, ledger, feature and availability records remain; code/SVG/meter/link data are exact. No factual +correction is invented for Case's existing baseline-period wording. Integration adds no content delta. +Canonical thumbnails use the unchanged generator: Daily1600, Interview1440, Case1280, Launch1440. +Descriptions name the actual compositions; no other PNG or catalog contract changes. + +### Explicitly approved viewer runtime exception + +The required twelve-story Daily fixture exposed the existing12000px auto-height ceiling through the +real locally published artifact viewer, reporter and opaque sandbox. At390px it measures11456px +closed and12316px with the six-item archive open; at320px,13757px and14745px. Previously the outer frame +stopped at12000px (all content remained reachable through nested scrolling). The template is not +shortened or redesigned to conceal this runtime limit. + +Only `src/ui/client/viewer.js` changes: `FRAME_MAX_HEIGHT`12000→32768 and its explanatory comment, +retiring obsolete approximately3000px template-budget advice. Exact approved source SHA-256: +`43142918625f52d55ac08c0659dcc3eb05e286cf4637199636ac33e391bc1cd4`. This is a bounded operating choice +with headroom above the14745px fixture, **not** a universal maximum, future-proof promise or browser +resource-safety proof; the previous test's permissive40000 upper bound is not its justification. +Matched sender, message type, finite/positive checks, rounding,48px floor and nested-scroll fallback +remain unchanged. No reporter, CSP, origin, sandbox permissions, dependencies or external assets change. + +Regression tests use the actual built repository asset, never a substituted green candidate. The +Daily open/close test fails before at320/390 and passes afterward, with all twelve stories, six archive +entries, reachable footer and stable closed/open/closed sizes. The unit test now asserts32768 exactly. +Existing nine guard controls remain; added at/just-above boundary and real65536px synthetic content +prove clamping plus usable keyboard/nested scrolling. Ordinary80→160→24px content tests resizing and +the48px floor. A separate fresh two-origin cloud-mode proof checks byte-exact served viewer asset, +real published fixture source, both phone widths and settled message counts. No production fixtures. + +Lint:34 errors→0 via only the two comments.41warnings comprise40 exact prior warnings and one Case +mobile `.l-chg display:grid!important` override that preserves the selected grid against generic cell +`display:block`. It is inventoried, not rewritten merely to quiet warnings. New unit assertions cover +the two bounded exceptions; browser tests protect native associations, exact data-meter rows, Launch +keyboard scrolling and the real viewer. Existing tests/frameworks are extended, not replaced. + +[Final integrated review](https://anacreon.ai/downloads/aa-template-revamp-1788686289312/integrated-batch01/final/index.html) +separates fresh final checks, applied-asset viewer proof and final sandbox checks from reused, +unchanged-source16main/48boundary/no-JS/growth/contrast/fallback evidence. Private fixture databases, +authentication, logs and backup material are excluded. No analytics, customer/financial data, +launch, billing, schema, infrastructure or unrelated runtime behavior changes. diff --git a/public/assets/template-thumbs/case-study.png b/public/assets/template-thumbs/case-study.png index 5887a14..4cdfa4b 100644 Binary files a/public/assets/template-thumbs/case-study.png and b/public/assets/template-thumbs/case-study.png differ diff --git a/public/assets/template-thumbs/daily-digest.png b/public/assets/template-thumbs/daily-digest.png index 345ac96..8fb2b1e 100644 Binary files a/public/assets/template-thumbs/daily-digest.png and b/public/assets/template-thumbs/daily-digest.png differ diff --git a/public/assets/template-thumbs/interview-notes.png b/public/assets/template-thumbs/interview-notes.png index 19d7a82..a17ff4d 100644 Binary files a/public/assets/template-thumbs/interview-notes.png and b/public/assets/template-thumbs/interview-notes.png differ diff --git a/public/assets/template-thumbs/launch-announcement.png b/public/assets/template-thumbs/launch-announcement.png index 85f9b34..779558b 100644 Binary files a/public/assets/template-thumbs/launch-announcement.png and b/public/assets/template-thumbs/launch-announcement.png differ diff --git a/src/ui/client/viewer.js b/src/ui/client/viewer.js index 61c311b..b6955c0 100644 --- a/src/ui/client/viewer.js +++ b/src/ui/client/viewer.js @@ -43,18 +43,13 @@ const FRAME_MIN_HEIGHT = 48; * on ordinary product output is not a circuit breaker, it is a layout constraint wearing one's * clothes. * - * 12000 is chosen to sit far above real documents and far below anything that hurts: a 12000px - * iframe is an ordinary long page to lay out and paint, because painting is viewport-bound and - * scrolls are virtualised, while the runaway cases this guards against are orders of magnitude - * past it. Our own templates carry a tighter design budget of ~3000px, which is a rule about - * making good pages and is enforced by nobody but the person writing one. - * - * Above the cap the old behaviour is exactly what should happen, and it is now the rare case - * rather than the normal one: the frame is clamped to 12000px and the document scrolls inside it. - * Nothing is hidden and nothing is truncated — the reader scrolls the frame instead of the page for - * the remainder. That degradation is deliberate; the alternative is trusting an unbounded number. + * 32768 preserves auto-height for the approved long Daily fixture, including its archive + * (14745px at320px wide), while retaining a finite circuit breaker against runaway reports. + * This is not a template design budget: content may grow naturally, without font reductions, + * hidden stories or a prose-height target. Larger reports retain the existing nested-scroll + * fallback rather than asking the browser to lay out an unbounded number. */ -const FRAME_MAX_HEIGHT = 12_000; +const FRAME_MAX_HEIGHT = 32_768; let contentHash = boot.initialContent?.content_hash || null; let contentRequestInFlight = false; let stopped = false; diff --git a/templates/case-study.html b/templates/case-study.html index b630bd4..5c20481 100644 --- a/templates/case-study.html +++ b/templates/case-study.html @@ -1,156 +1,23 @@ + - - + + Northwind Freight cut invoice disputes by 71% in one quarter - -
+
-
- Case study · Freight & logistics + Case study · Freight & logistics +

Northwind Freight cut invoice disputes by 71% in one quarter

71%

@@ -165,39 +32,40 @@ body{padding:var(--s5) var(--s4) var(--s8);font-size:.9375rem;line-height:1.5}
-
-
“We never had a disputes problem. We had a problem where nobody could see the weight - ticket and the invoice at the same time.”
-
Priya Raman, VP Finance, Northwind Freight
-
- -
- +
+
- - + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + -
Before and after · Q2 2025 baseline vs Q2 2026
MetricBeforeAfterChange
MetricBeforeAfterChange
Invoices disputed19.4%5.6%−71%
Clerk hours spent on disputes854 / mo179 / mo−79%
Days sales outstanding61 days43 days−30%
Time to resolve one dispute22 min16 min−27%
Unrecovered accessorial write-offs$310K / yr$88K / yr−72%
Invoices disputed19.4%5.6%−71%
Clerk hours spent on disputes854 / mo179 / mo−79%
Days sales outstanding61 days43 days−30%
Time to resolve one dispute22 min16 min−27%
Unrecovered accessorial write-offs$310K / yr$88K / yr−72%
Bars show the after figure as a share of the before figure — +
Bars show the after figure as a share of the before figure — shorter is better. Every row is pulled from Northwind's TMS and billing ledger, not from our own telemetry.
-
+

Two branches still run a manual accessorial log and are excluded from the figures.

+ + +
+
“We never had a disputes problem. We had a problem where nobody could see the weight + ticket and the invoice at the same time.”
+
Priya Raman, VP Finance, Northwind Freight
+
@@ -228,8 +96,7 @@ body{padding:var(--s5) var(--s4) var(--s8);font-size:.9375rem;line-height:1.5}

The first fortnight was bad. The hold threshold shipped at $10, which held 31% of all invoices and made the queue worse than the disputes had been. Raising it to $25 on 23 March dropped holds to 7% and is the single change that made the numbers above possible.

-

Two branches still run a manual accessorial log and are excluded from the figures.

-
+
“The first month I kept refreshing the dispute queue because I assumed it was broken.”
@@ -254,6 +121,6 @@ body{padding:var(--s5) var(--s4) var(--s8);font-size:.9375rem;line-height:1.5} 2026; the comparison quarter is April–June 2026. Two of twenty-two branches are excluded. Published with permission. -
+
- + \ No newline at end of file diff --git a/templates/daily-digest.html b/templates/daily-digest.html index 0e178b7..61e8785 100644 --- a/templates/daily-digest.html +++ b/templates/daily-digest.html @@ -1,145 +1,14 @@ - - + + AI Engineering Daily — 3 September 2026 - - @@ -186,10 +55,11 @@ summary span{color:var(--subtle);font-weight:400;font-size:.8125rem}
-

Postgres 18 ends the case for a connection pooler

+

Postgres 18 ends the case for a connection pooler

Hexadecimal24 min06:40

-
+

Watch it if you own the database tier.

+

TL;DRBuilt-in connection multiplexing lands in 18. Under ~500 clients it removes the case for PgBouncer; above that the pooler wins on failover.

-

Watch it if you own the database tier.

@@ -206,10 +75,11 @@ summary span{color:var(--subtle);font-weight:400;font-size:.8125rem}
-

We replaced our vector database with one table

+

We replaced our vector database with one table

Latent Space38 min09:05

-
+

Watch it if your retrieval bill has a comma in it.

+

TL;DRA 40M-chunk corpus moved from a managed vector store into pgvector with HNSW. Recall held 0.94, p95 rose 11 ms, the bill fell $9,400/mo.

-

Watch it if your retrieval bill has a comma in it.

@@ -226,17 +95,17 @@ summary span{color:var(--subtle);font-weight:400;font-size:.8125rem}
-

The cost curve nobody budgets for

+

The cost curve nobody budgets for

Infra Weekly17 min11:20

-
+

Watch it if you sign off the cloud invoice.

+

TL;DREgress, not compute, turns a cheap architecture expensive: a $300-a-month cluster carrying $4,100 of cross-zone traffic on one misplaced replica.

-

Watch it if you sign off the cloud invoice.

@@ -245,17 +114,17 @@ summary span{color:var(--subtle);font-weight:400;font-size:.8125rem}
-

Shipping agents other teams actually adopt

+

Shipping agents other teams actually adopt

Build Notes52 min14:02

-
+

Watch it if you are rolling agents out.

+

TL;DRAdoption tracked with one thing — whether the agent produced an artifact a human could forward. Chat-only agents churned in two weeks.

-

Watch it if you are rolling agents out.

@@ -277,4 +146,4 @@ summary span{color:var(--subtle);font-weight:400;font-size:.8125rem} - + \ No newline at end of file diff --git a/templates/interview-notes.html b/templates/interview-notes.html index 410b527..b489522 100644 --- a/templates/interview-notes.html +++ b/templates/interview-notes.html @@ -1,117 +1,17 @@ - - + + Interview notes — Ana Petrović, Rovna Logistics - - -
+
Interview notes · 6 of 9 @@ -119,10 +19,9 @@ blockquote p{font-size:clamp(1.1875rem,2.3vw,1.5rem);font-weight:600;line-height

The output was never the problem. The schedule and provenance were.

-
- +

If it had told me it had skipped a week, I would have kept it.

-
Ana Petrović · 41:02 · on what would have changed her mind
+
Ana Petrović · 41:02 · on what would have changed her mind
@@ -137,7 +36,7 @@ blockquote p{font-size:clamp(1.1875rem,2.3vw,1.5rem);font-weight:600;line-height
-
+
What we came to learn

Why teams turn a scheduled agent off in month two. Ana ran a Monday metrics job for seven weeks, then disabled it.

@@ -145,41 +44,32 @@ blockquote p{font-size:clamp(1.1875rem,2.3vw,1.5rem);font-weight:600;line-height

Findings

-
-
08:12
-
-

The report was fine. The problem was that on Tuesday nobody could tell me whether it had actually run.

-
Ana Petrović, on the first three weeks
-
+
+
08:12

The report was fine. The problem was that on Tuesday nobody could tell me whether it had actually run.

+
Ana Petrović, on the first three weeks
+
What follows

Every churned team so far describes the same gap: the output was trusted, the schedule was not. Ana checked manually for three weeks, decided that was the job she had been automating, and stopped.

-
-
+ -
-
21:40
-
-

I forwarded the first one to my director and she asked me to just paste it into the doc. So I was doing it by hand anyway.

-
Ana Petrović, on sharing the output upward
-
+
+
21:40

I forwarded the first one to my director and she asked me to just paste it into the doc. So I was doing it by hand anyway.

+
Ana Petrović, on sharing the output upward
+
What follows

The artifact was not the unit of sharing in her organisation — the weekly doc was. A correct output that cannot travel the last ten metres fails where no output fails. Second interview with this shape (see 03).

-
-
+ -
-
35:05
-
-

We turned it off when the numbers moved and nobody had changed anything. I could not see what it had read.

-
Ana Petrović, on the week before she disabled it
-
+
+
35:05

We turned it off when the numbers moved and nobody had changed anything. I could not see what it had read.

+
Ana Petrović, on the week before she disabled it
+
What follows

Trust broke on provenance, not accuracy — the figure was right and she still could not tell which query produced it. Three unprompted mentions in six interviews, and no question about it in the guide.

-
-
+
@@ -201,6 +91,6 @@ blockquote p{font-size:clamp(1.1875rem,2.3vw,1.5rem);font-weight:600;line-height
Recorded with consent; transcript held 90 days, then deleted. Quotes are verbatim, lightly de-hesitated; timestamps refer to the recording. Interview 6 of 9 · synthesis 18 September.
-
+
- + \ No newline at end of file diff --git a/templates/launch-announcement.html b/templates/launch-announcement.html index fb5b9a0..738a616 100644 --- a/templates/launch-announcement.html +++ b/templates/launch-announcement.html @@ -1,362 +1,19 @@ - - + + Fieldnote 2.0 is available today - - + -
-
-

Available today · 4 September 2026

+
+

Available today · 4 September 2026

Fieldnote 2.0 works with no signal at all

Survey the whole site offline; it syncs itself when you get back to the truck. @@ -371,58 +28,76 @@

Upgrade
Free, all plans
Downtime
None — nothing to migrate
-
-
- - -
-
-

The headline change

+
+

The headline change

Offline is the default, not the fallback

-

- Every form, photo and signature is written to the device first and queued. The queue - drains by itself the moment there is a network, in the order the records were created. -

-

- Conflicts are boring now too: two people editing one record produces two versions, both - kept, merged in one tap. Nothing is silently overwritten. -

-
-
- +
+ A record is saved on the device, held in a queue while offline, then uploaded in order when the network returns. - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +

On deviceQueuedSynced

The round trip needs no connection at any point.
+
+

+ Every form, photo and signature is written to the device first and queued. The queue + drains by itself the moment there is a network, in the order the records were created. +

+

+ Conflicts are boring now too: two people editing one record produces two versions, both + kept, merged in one tap. Nothing is silently overwritten. +

+
+ + + + + +
+
+

From nine weeks of beta

+ +
- -
-

Also in 2.0

+
+

Also in 2.0

- -
-
-

From nine weeks of beta

-
    -
  • -

    241,800

    -

    records captured offline by 1,900 crews

    -
  • -
  • -

    18 sec

    -

    median time to drain a full day's queue

    -
  • -
  • -

    0

    -

    records lost to a failed sync

    -
  • -
-
-
- - -
+

Try it on your next site visit

  1. @@ -475,7 +128,8 @@
  2. Download your sites

    Settings → Offline, then pick the sites for the week. About 40MB each.

    - fieldnote sites pull --week current + + fieldnote sites pull --week current
  3. Turn the radio off and work

    @@ -487,7 +141,6 @@
-

Availability

@@ -519,4 +172,4 @@

- + \ No newline at end of file diff --git a/templates/manifest.ts b/templates/manifest.ts index a80b797..72c81ee 100644 --- a/templates/manifest.ts +++ b/templates/manifest.ts @@ -4,21 +4,23 @@ export const starterTemplateManifest = [ "slug": "daily-digest", "category": "meetings", "name": "Daily digest", - "description": "Many items condensed with an index rail: a jump index and today's themes on the left, per-item cards with a TL;DR on the right, and the long tail collapsed to one line each.", + "description": "A newspaper-style daily desk: a complete story index beside four open, ruled articles with relevance, summaries and evidence, followed by a native six-item archive.", "type": "html", "content_file": "daily-digest.html", "thumbnail": "/assets/template-thumbs/daily-digest.png", - "slots": [] + "slots": [], + "thumbnail_viewport": 1600 }, { "slug": "interview-notes", "category": "meetings", "name": "Interview notes", - "description": "One conversation, quotes-led. Who they are, what they said in a timestamped pull quote, and your reading kept visibly separate. For user research and hiring debriefs.", + "description": "A voice-and-interpretation spread: a broad serif quotation beside the participant identity, then paired verbatim evidence and analyst readings with timed quotes and owned next actions.", "type": "html", "content_file": "interview-notes.html", "thumbnail": "/assets/template-thumbs/interview-notes.png", - "slots": [] + "slots": [], + "thumbnail_viewport": 1440 }, { "slug": "meeting-recap", @@ -94,7 +96,7 @@ export const starterTemplateManifest = [ "slug": "case-study", "category": "research", "name": "Case Study", - "description": "A customer outcome told with the customer's own numbers: one display figure and what it is measured against, a before-and-after ledger with magnitude bars, the situation that caused it, an honest account of what the change cost, and quotes from the people who lived it.", + "description": "An evidence spread: the customer outcome opposite five before/after metric records, followed by serif customer voices, the operational story and an equally prominent honest-cost section.", "type": "html", "content_file": "case-study.html", "thumbnail": "/assets/template-thumbs/case-study.png", @@ -179,11 +181,12 @@ export const starterTemplateManifest = [ "slug": "launch-announcement", "category": "releases", "name": "Launch announcement", - "description": "The friendly end of the releases family: an inverted black masthead with the headline claim and the facts strip, one lead feature with a diagram, two supporting ones, beta numbers, three steps to try it, and a plain-spoken availability table.", + "description": "A dark launch poster pairs the headline and update links with an offline-flow diagram, then shifts to a light beta-evidence strip, open feature rows and a practical trial sequence.", "type": "html", "content_file": "launch-announcement.html", "thumbnail": "/assets/template-thumbs/launch-announcement.png", - "slots": [] + "slots": [], + "thumbnail_viewport": 1440 }, { "slug": "migration-guide", diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 338be0b..ecf63b3 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -1225,3 +1225,248 @@ test('a retired template slug redirects instead of 404ing', async ({ page }) => expect(new URL(page.url()).pathname).toBe('/templates/meeting-recap'); await expect(page.locator('.aa-templates__frame')).toBeVisible(); }); + +// Final editorial regression: publish synthetic content through the existing API seed and run the +// actual repository viewer asset + appended reporter. No asset interception or production fixture. +test('Daily long edition resizes through archive open AND close without feedback', async ({ + page, + request, +}, testInfo) => { + const width = + testInfo.project.name === 'chromium-375' + ? 320 + : testInfo.project.name === 'chromium-480' + ? 390 + : (testInfo.project.use.viewport?.width ?? 1440); + await page.setViewportSize({ width, height: 844 }); + const original = await readFile('templates/daily-digest.html', 'utf8'); + const content = await page.evaluate((html) => { + const document = new DOMParser().parseFromString(html, 'text/html'); + const get = (selector: string) => { + const element = document.querySelector(selector); + if (!element) throw new Error(`Missing fixture component ${selector}`); + return element; + }; + const articles = [...document.querySelectorAll('.item')]; + const links = [...document.querySelectorAll('.toc > li')]; + const words = + 'This synthetic quotation is a layout stress test only. It describes an extended conversation with repeated detail so the text must keep its natural reading order and its full attribution without clipping or collapsing. ' + .trim() + .split(/\s+/); + for (let index = 4; index < 12; index += 1) { + const article = articles[index % 4]?.cloneNode(true) as HTMLElement; + const link = links[index % 4]?.cloneNode(true) as HTMLElement; + if (!article || !link) throw new Error('Missing fixture seed'); + article.id = `i${index + 1}`; + const number = article.querySelector('.item__n'); + const linkNumber = link.querySelector('span'); + const anchor = link.querySelector('a'); + if (!number || !linkNumber || !anchor) throw new Error('Missing fixture relationship'); + number.textContent = linkNumber.textContent = String(index + 1).padStart(2, '0'); + anchor.setAttribute('href', `#i${index + 1}`); + get('.stream').append(article); + get('.toc').append(link); + } + for (const article of document.querySelectorAll('.item')) { + const caveat = document.createElement('li'); + caveat.textContent = `Synthetic longer caveat: ${Array.from({ length: 45 }, (_, i) => words[i % words.length]).join(' ')}.`; + article.querySelector('.pts')?.append(caveat); + } + const notice = document.createElement('p'); + notice.setAttribute( + 'style', + 'margin:0;padding:12px 18px;background:#fff3cd;color:#473c10;font:700 14px/1.5 system-ui,sans-serif' + ); + notice.textContent = 'SYNTHETIC FIXTURE — GROWTH — not sample content'; + document.body.prepend(notice); + get('.lead').textContent = + 'Twelve synthetic lead stories remain visible in full; the archive is unchanged.'; + return `\n${document.documentElement.outerHTML}`; + }, original); + const published = await publishArtifact(request, seed.apiKey, { + slug: `e2e-daily-long-${width}`, + type: 'html', + title: 'Synthetic Daily long edition', + content, + share: true, + }); + await page.goto(requiredShare(published).url); + const embed = page.locator('[data-aa-frame]'); + const frame = page.frameLocator('[data-aa-frame]'); + await expect(embed).toHaveAttribute('sandbox', 'allow-scripts'); + await expect(embed).toHaveAttribute('data-aa-frame-height', 'measured'); + await expect(frame.locator('.item')).toHaveCount(12); + await expect(frame.locator('.rest > li')).toHaveCount(6); + expect( + await frame + .locator('.item') + .evaluateAll((items) => + items.every((item) => item.getBoundingClientRect().height > 0 && !item.closest('details')) + ) + ).toBe(true); + const height = () => + frame.locator('html').evaluate((element) => Math.ceil(element.getBoundingClientRect().height)); + const inlineHeight = () => embed.evaluate((element) => Number.parseFloat(element.style.height)); + await expect.poll(inlineHeight).toBe(await height()); + const closed = await height(); + if (width === 320) expect(closed).toBeGreaterThan(13_000); + const summary = frame.locator('summary'); + await summary.focus(); + await page.keyboard.press('Enter'); + await expect(frame.locator('details')).toHaveAttribute('open', ''); + await expect.poll(height).toBeGreaterThan(closed); + const opened = await height(); + if (width <= 390) expect(opened).toBeGreaterThan(12_000); + await expect.poll(inlineHeight).toBe(opened); + await frame.locator('footer').scrollIntoViewIfNeeded(); + await expect(frame.locator('footer')).toBeInViewport(); + await summary.focus(); + await page.keyboard.press('Enter'); + await expect(frame.locator('details')).not.toHaveAttribute('open', ''); + await expect.poll(height).toBe(closed); + await expect.poll(inlineHeight).toBe(closed); + // Read several independent settled samples; the outer size must not create a resize feedback loop. + for (let sample = 0; sample < 3; sample += 1) { + await page.waitForTimeout(150); + expect(await height()).toBe(closed); + expect(await inlineHeight()).toBe(closed); + } + await frame.locator('footer').scrollIntoViewIfNeeded(); + await expect(frame.locator('footer')).toBeInViewport(); +}); + +test('viewer ceiling guards and keyboard fallback survive short and very tall documents', async ({ + page, + request, +}) => { + const content = + '
'; + const published = await publishArtifact(request, seed.apiKey, { + slug: 'e2e-editorial-height-guards', + type: 'html', + title: 'Synthetic height guards', + content, + share: true, + }); + await page.goto(requiredShare(published).url); + const embed = page.locator('[data-aa-frame]'); + const frame = page.frameLocator('[data-aa-frame]'); + await expect(embed).toHaveAttribute('sandbox', 'allow-scripts'); + const height = () => embed.evaluate((element) => Number.parseFloat(element.style.height)); + await expect.poll(height).toBe(80); + await frame.locator('main').evaluate((element) => { + element.style.height = '160px'; + }); + await expect.poll(height).toBe(160); + await frame.locator('main').evaluate((element) => { + element.style.height = '24px'; + }); + await expect.poll(height).toBe(48); + const send = async (value: number) => { + await frame.locator('html').evaluate((_element, value) => { + parent.postMessage({ type: 'aa:frame-height', height: value }, '*'); + }, value); + }; + await send(15_000); + await expect.poll(height).toBe(15_000); + for (const invalid of [Number.NaN, Number.POSITIVE_INFINITY, 0, -10]) { + await send(invalid); + await page.waitForTimeout(30); + expect(await height()).toBe(15_000); + } + await page.evaluate(() => window.postMessage({ type: 'aa:frame-height', height: 20_000 }, '*')); + await page.waitForTimeout(30); + expect(await height()).toBe(15_000); + await frame + .locator('html') + .evaluate(() => parent.postMessage({ type: 'wrong-type', height: 20_000 }, '*')); + await page.waitForTimeout(30); + expect(await height()).toBe(15_000); + for (const [requested, expected] of [ + [32_768, 32_768], + [32_769, 32_768], + [1e9, 32_768], + [1, 48], + [60, 60], + ]) { + if (requested === undefined || expected === undefined) throw new Error('Missing guard case'); + await send(requested); + await expect.poll(height).toBe(expected); + } + // Real content, not just a claimed number. The actual reporter reaches the ceiling at each edge. + for (const requested of [32_768, 32_769, 65_536]) { + await frame.locator('main').evaluate((element, value) => { + element.style.height = `${value}px`; + }, requested); + await expect.poll(height).toBe(32_768); + expect( + await frame + .locator('html') + .evaluate((element) => Math.ceil(element.getBoundingClientRect().height)) + ).toBe(requested); + } + // Publish the very tall fixture too, then navigate afresh: guard-message mutations are not its seed. + const tall = await publishArtifact(request, seed.apiKey, { + slug: 'e2e-editorial-tall-fallback', + type: 'html', + title: 'Synthetic tall fallback', + content: content.replace('height:80px', 'height:65536px'), + share: true, + }); + await page.goto(requiredShare(tall).url); + await expect.poll(height).toBe(32_768); + await frame.locator('body').click({ position: { x: 3, y: 3 } }); + await page.keyboard.press('Control+End'); + await expect.poll(() => frame.locator('html').evaluate(() => scrollY)).toBeGreaterThan(0); + // Finish End before focusing the footer; checking only >0 sees the start of its animation. + await expect + .poll(() => + frame + .locator('html') + .evaluate(() => document.documentElement.scrollHeight - innerHeight - scrollY) + ) + .toBe(0); + // End -> Tab is the actual keyboard journey to the footer; do not race it with a reverse Home. + await page.keyboard.press('Tab'); + await expect(frame.getByRole('button', { name: 'Synthetic footer' })).toBeFocused(); + await expect(frame.getByRole('button', { name: 'Synthetic footer' })).toBeInViewport(); +}); + +test('Launch long command remains keyboard-accessible in the actual published viewer', async ({ + page, + request, +}) => { + const source = await readFile('templates/launch-announcement.html', 'utf8'); + const content = source.replace( + 'fieldnote sites pull --week current', + 'SYNTHETIC_COMMAND_SEGMENT'.repeat(10) + ); + const published = await publishArtifact(request, seed.apiKey, { + slug: 'e2e-launch-long-command', + type: 'html', + title: 'Synthetic Launch command', + content, + share: true, + }); + await page.goto(requiredShare(published).url); + const frame = page.frameLocator('[data-aa-frame]'); + await expect(page.locator('[data-aa-frame]')).toHaveAttribute('sandbox', 'allow-scripts'); + const command = frame.getByRole('region', { name: 'Command', exact: true }); + await expect(command).toHaveCount(1); + expect(await command.evaluate((element) => element.tagName)).toBe('CODE'); + await frame.locator('body').click({ position: { x: 3, y: 3 } }); + await page.keyboard.press('Control+Home'); + let reached = false; + for (let attempt = 0; attempt < 20; attempt += 1) { + await page.keyboard.press('Tab'); + if (await command.evaluate((element) => element === document.activeElement)) { + reached = true; + break; + } + } + expect(reached).toBe(true); + await page.keyboard.press('ArrowRight'); + await expect.poll(() => command.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); + await frame.locator('footer').scrollIntoViewIfNeeded(); + await expect(frame.locator('footer')).toBeInViewport(); +}); diff --git a/tests/e2e/template-table-semantics.spec.ts b/tests/e2e/template-table-semantics.spec.ts index e518da4..3d3397d 100644 --- a/tests/e2e/template-table-semantics.spec.ts +++ b/tests/e2e/template-table-semantics.spec.ts @@ -146,3 +146,70 @@ for (const [slug, expectedRows, expectedCells, expectedReferences] of [ ).toBe(true); }); } + +test('Case Study keeps native column associations and complete value/meter records in its sandbox', async ({ + page, +}) => { + await page.goto(`${cloudOrigin}/templates/case-study`); + await expect(page.locator('iframe')).toHaveAttribute('sandbox', 'allow-scripts'); + await expect(page.locator('iframe')).toHaveAttribute( + 'src', + `${sandboxOrigin}/templates/case-study/frame` + ); + const frame = page.frameLocator('iframe'); + const table = frame.getByRole('table'); + await expect(table.getByRole('row')).toHaveCount(7); + await expect(table.getByRole('columnheader')).toHaveText(['Metric', 'Before', 'After', 'Change']); + await expect(table.locator('tfoot td')).toHaveAttribute('colspan', '4'); + await expect(table.getByRole('cell')).toHaveCount(21); + const associations = () => + table.evaluate((element) => { + if (!(element instanceof HTMLTableElement)) throw new Error('Missing native table'); + return [...element.querySelectorAll('tbody td')].map((cell) => { + if (!(cell instanceof HTMLTableCellElement)) throw new Error('Missing native cell'); + const header = element.tHead?.rows[0]?.cells[cell.cellIndex]; + return Boolean( + header && + header.tagName === 'TH' && + header.scope === 'col' && + header.textContent?.trim() && + cell.parentElement?.closest('table') === element + ); + }); + }); + expect(await associations()).toEqual(Array(20).fill(true)); + // A missing scope must break this same relationship check; restore only the disposable DOM. + const first = table.locator('thead th').first(); + await first.evaluate((element) => element.removeAttribute('scope')); + expect((await associations()).every(Boolean)).toBe(false); + await first.evaluate((element) => element.setAttribute('scope', 'col')); + expect((await associations()).every(Boolean)).toBe(true); + const rows = await table.locator('tbody tr').evaluateAll((elements) => + elements.map((row) => ({ + values: [...row.querySelectorAll('td')].map((cell) => cell.textContent?.trim()), + meter: row.querySelector('.meter i')?.style.width, + })) + ); + expect(rows).toEqual([ + { values: ['Invoices disputed', '19.4%', '5.6%', '−71%'], meter: '29%' }, + { values: ['Clerk hours spent on disputes', '854 / mo', '179 / mo', '−79%'], meter: '21%' }, + { values: ['Days sales outstanding', '61 days', '43 days', '−30%'], meter: '70%' }, + { values: ['Time to resolve one dispute', '22 min', '16 min', '−27%'], meter: '73%' }, + { + values: ['Unrecovered accessorial write-offs', '$310K / yr', '$88K / yr', '−72%'], + meter: '28%', + }, + ]); + for (const cell of await table.locator('tbody td').all()) { + const text = (await cell.textContent())?.trim() ?? ''; + await expect(cell).toHaveAccessibleName( + new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') + ); + } + const tree = await table.ariaSnapshot(); + expect(tree).toContain('columnheader'); + expect(tree).toContain('cell'); + expect(await frame.locator('html').evaluate((element) => element.scrollWidth <= innerWidth)).toBe( + true + ); +}); diff --git a/tests/unit/template-table-semantics.test.ts b/tests/unit/template-table-semantics.test.ts index 61ce0a9..e116801 100644 --- a/tests/unit/template-table-semantics.test.ts +++ b/tests/unit/template-table-semantics.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; const source = (slug: string) => readFileSync(new URL(`../../templates/${slug}.html`, import.meta.url), 'utf8'); -// These four responsive tables intentionally retain explicit roles when CSS changes their display. +// These five responsive tables intentionally retain explicit roles when CSS changes their display. // Bound the exception to the known table inventory, rather than silencing future unrelated roles. describe('responsive template table semantics', () => { it('uses four native scoped Postmortem row headers, not interactive roles on td', () => { @@ -24,6 +24,7 @@ describe('responsive template table semantics', () => { 1, ], ['postmortem', { table: 1, rowgroup: 2, row: 5, columnheader: 5, rowheader: 4, cell: 16 }, 1], + ['case-study', { table: 1, row: 7, columnheader: 4, cell: 21 }, 0], ['meeting-recap', { table: 1, rowgroup: 2, row: 7, columnheader: 4, cell: 24 }, 0], [ 'decision-brief', @@ -51,3 +52,15 @@ describe('responsive template table semantics', () => { }); } }); + +// Launch is not a table: only its one named scrolling code region gets this separate exception. +it('limits Launch to one element-local semantic compatibility comment', () => { + const html = source('launch-announcement'); + expect(html.match(/biome-ignore[^\n]*/g)).toEqual([ + 'biome-ignore lint/a11y/useSemanticElements: The accepted named code region keeps its exact code component and native keyboard scrolling. -->', + ]); + expect(html).toContain( + 'scrolling. -->\n fieldnote sites pull --week current' + ); + expect(html.match(/role="region"/g)).toHaveLength(1); +}); diff --git a/tests/unit/ui-frame-height.test.ts b/tests/unit/ui-frame-height.test.ts index 455634d..9c14dc4 100644 --- a/tests/unit/ui-frame-height.test.ts +++ b/tests/unit/ui-frame-height.test.ts @@ -107,19 +107,13 @@ describe('the frame-height handshake', () => { ); }); - it('caps the measurement high enough that real documents never meet it', () => { + it('uses the approved finite ceiling with headroom above the 14745px Daily fixture', () => { const ceiling = Number( /const FRAME_MAX_HEIGHT = ([\d_]+);/.exec(viewerJs)?.[1]?.replace(/_/g, '') ); - expect(ceiling, 'FRAME_MAX_HEIGHT not found').toBeGreaterThan(0); - - // The cap is a circuit breaker against a broken or hostile measurement, not a limit on how long - // a document may be. At 2400 it was doing the second job too: the shipped `report-html` - // measures 3344px at 1440 and 4972px at 390, and both were clamped into a nested scrollbar. A - // ceiling that fires on this product's own output is not protecting anyone. - expect(ceiling, 'the cap fires on ordinary product output').toBeGreaterThan(6000); - // And it still has to be a ceiling. An unbounded number is laid out in the reader's browser. - expect(ceiling).toBeLessThanOrEqual(40_000); + // An operating choice, not a universal document maximum or browser resource-safety proof. + // Above it, content stays reachable by nested scrolling rather than being removed. + expect(ceiling).toBe(32_768); }); it('still clamps rather than trusts, so an absurd measurement cannot run away', () => {