diff --git a/.gitignore b/.gitignore index 3f8e40d..3462444 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ public/assets/ui-foundation-*.js public/assets/viewer-*.js public/assets/viewer-*.css public/assets/dashboard-*.js +public/assets/marketing-video-*.js public/assets/manifest.json # Runtime state / secrets diff --git a/BUILD-NOTES.md b/BUILD-NOTES.md index bfd8735..f88f906 100644 --- a/BUILD-NOTES.md +++ b/BUILD-NOTES.md @@ -890,3 +890,20 @@ Library navigation uses native links, styled as tabs, with `library=mine|builtin Scoped integration red controls fail on the previous source for missing detail groups, missing library selection and visible card descriptions. Existing assertions move to the explicit personal destination / new query-preserving preview URLs. Browser regressions exercise the existing seven responsive projects, real sandboxed owner preview and JavaScript-disabled library navigation. The style guide documents these two targeted patterns using current primitives; no global redesign, dependency, schema, provider or launch-flag change is included. Homepage activation is separately authorized after this UI rollout is verified: existing `AA_COMING_SOON=false` selects the already-built HomePage. This PR does not merge/deploy or change that runtime flag. Existing production deployment and rollback safeguards remain required. + + +## Homepage launch video — deliberately activated, current marketing system + +The approved scope is one centered 4:3, maximum780px film after the existing hero/setup and before Examples. `MarketingVideo` is registered in the style guide and uses current primitives/tokens. Existing hero/CTAs/setup, icons, admin/detail/red analytics, public gallery and all20 canonical template/source/thumbnail options remain unchanged. This is not adoption of the rejected broad admin proposal or the unapproved icon alternatives. + +The progressive Watch link becomes a native button only when its small page-specific script runs. The server emits no movie/source/track src; scroll, hover and focus are passive. Explicit activation pins one phone/desktop rendition by actual CSS width (≤480px phone, not DPR); native controls, focus, playsinline, retry/open-file and no-JavaScript navigation remain usable. No autoplay, loop, HLS, external player or service. Responsive eager posters reserve geometry and avoid a lazy LCP candidate; high-DPR phones may select the34,330-byte still without loading both posters. + +Four exact frozen media deliveries total7,014,717bytes; movies are46s/60fps (phone2,843,251bytes, desktop4,122,608bytes), posters14,528/34,330bytes. No master, alternate encodes or production review pages enter the repository. One840-byte authored WebVTT visual alternative follows the same factual sequence as the native transcript; it does not invent speech/sound captions. See [homepage-video.md](docs/homepage-video.md) for full hashes, provenance and reproduction options. + +Existing static serving already supports real byte ranges. Only successful content-hashed MP4/WebP/VTT paths gain immutable caching, MP4 HEAD/full responses advertise ranges, and VTT receives its required MIME instead of the adapter's octet-stream fallback. Actual TCP full/range/suffix/HEAD readback and native track parsing protect this behavior. No Nginx, environment, launch flag, CSP, sandbox, provider, database or dependency change is required. + +Scoped tests include genuine before-fail component/cache controls; no passive movie load; one rendition; native keyboard timeline/focus; aborted-network and rejected-play retries; progressive no-JS; complete media hashes/faststart and actual five-cue parsing. Final local check/typecheck/build and full test suites are required, with41 pre-existing warnings and existing skips explicitly distinguished. Responsive main1440/768/390/320 and actual width-policy/gutter boundaries, eight matched existing-page screenshot pairs and actual network-emulated phone playback supplement the suite. + +The five-run cold-phone lab uses real loopback HTTP at configured1.6Mbps/150ms latency, not request substitution. Performance is lab evidence, not a physical-phone/deployed-CDN guarantee. Reserved player activation geometry/CLS is separate from initial font swaps in the unchanged page shell. Only Chromium is verified; no perceptual audio audition or Safari/Firefox certification is claimed. Parent must review the exact PR before the existing guarded deployment; homepage remains live and untouched until that separately authorized code rollout. + +Pre-review local receipts:1,153 unit/integration tests and338 E2E tests pass, with12 existing skips in each suite and41 exact baseline warning locations/rules. Thirteen clean responsive/boundary cells show zero passive movie requests, one poster/one selected movie and zero player-activation CLS. Five cold-phone full-length trials measured click→first presented frame705.2/697.8/692.4/696.2/696.3ms (median696.3ms, worst705.2ms), no post-first-frame stalls; the first run dropped8 of2,760 frames on the shared lab host, others0. These timings precede only a server-side VTT MIME correction; movie/player/CSS/markup bytes remained equal. Final actual HTTP/native-cue tests verify that correction separately. Eight hero/header/legal/catalog before/after PNG pairs are byte-identical at1440/390 with the same synthetic visible base URL. A separate desktop full-duration/native-volume/mute/fullscreen-entry/exit check passed. No claim of a live deployment is made here. diff --git a/docs/homepage-video.md b/docs/homepage-video.md new file mode 100644 index 0000000..caf7b8a --- /dev/null +++ b/docs/homepage-video.md @@ -0,0 +1,57 @@ +# Homepage launch film + +## Scope and activation contract + +`MarketingVideo` is registered in the existing style guide and inserted after the cloud homepage's hero/setup, before Examples. It uses current tokens, a centered maximum 780px frame and a reserved 4:3 ratio. No hero/CTA/icon, admin, canonical template, authentication, billing, CSP, launch-flag, Nginx or deployment-script change accompanies it. + +The server emits **no video/source/track src**. The default Watch action is an ordinary link to the optimized phone MP4; the small page-specific script replaces that link with a native button. Idle, scroll, hover and focus never attach movie URLs. Explicit activation selects one rendition using the actual frame CSS width: at most 480px gets phone, wider gets desktop. The choice remains pinned across resize/fullscreen/retry. High DPR intentionally does not force a heavier phone movie; this is a transfer-quality tradeoff, not a pixel-for-pixel retina promise. + +The image is a responsive picture, not a second video poster request. Its `sizes` reflect existing 16/24px gutters, and high-DPR phones may choose the 960px still (34,330 bytes, still below the 40KB phone target). Only one still is requested. It is eager because actual opening viewport LCP probes identified it as an LCP candidate. Width/height and aspect ratio reserve geometry. + +Native controls, playsinline, unmuted audio after activation, focus transfer, pause/seek/fullscreen/volume and a visible retry/open-file path are retained. A rejected play promise or failed request exposes a focused retry; no automatic second movie downloads. A pending 15-second deadline offers retry, rather than promising that every connection will load. The action/status area reserves space without changing the action label. No autoplay, loop, player library, HLS, prefetch or third-party request is introduced. + +## Exact media provenance + +Selected master: `agent-artifacts-v5-new-master.mp4`, SHA-256 `306f87df1bec707d4027d395db10f7ec4ee6421d6aa3f17ca641df90ad1983bb`, 1440×1080, 60fps, 46 seconds. The original master/compact/audio and production render directories are not included in this repository change. + +Frozen media bundle: `74b35dc950f75030c6722e929f97cd3997a879340885f19a2cf9968373260e7e`. The first four files below are exact media-worker delivery bytes. The fifth is an authored, 840-byte timed visual alternative derived from that delivery's factual visual transcript; it is not a re-encoded movie, narration, or invented sound captions. + +| File in `public/assets/media/` | Bytes | Full SHA-256 | +|---|---:|---| +| `agent-artifacts-demo-desktop.eccb5261ce8a945e.mp4` | 4,122,608 | `eccb5261ce8a945ef779ecb2575241ddd9f7d615cddce7cb99a2c6451df14cd1` | +| `agent-artifacts-demo-phone.debfd582ad844195.mp4` | 2,843,251 | `debfd582ad844195d4af3a7cb8321f563edf16c23afc457588785066f0f75075` | +| `agent-artifacts-demo-poster-480.83c10656873f7de8.webp` | 14,528 | `83c10656873f7de8c2f3b52609f8c23d51c7c63557a0b4b144f30db297d2be88` | +| `agent-artifacts-demo-poster-960.13ccf9f3467fcdd8.webp` | 34,330 | `13ccf9f3467fcdd8177563613b8771e854ccc70852208bf5ff0e1691979979bb` | +| `agent-artifacts-demo-transcript.89c7595fab39d9ee.vtt` | 840 | `89c7595fab39d9ee48d8f8171e25510fd73f7af236a51b4760f0cc288bc5934f` | + +Both movies are H.264 High / yuv420p, 60fps, 46s, 2,760 frames, with a single AAC stereo 48kHz encode of the selected lossless new mix. Desktop: 960×720, direct-master CRF22/slow, maximum keyframe gap 2s. Phone: 720×540, exact original compact picture-packet copy, maximum keyframe gap approximately 4.167s. Both are faststart (`moov` before `mdat`). The 30fps desktop trial saved only 4.6%; retaining finer motion was preferred. The phone picture-copy was smaller than a fresh 30fps encode, without an extra picture generation. + +The 6.9-second posters retain the complete “From a reply. To a real page.” frame. No new crop, image generation or film encoding was performed by the product integrator. + +### Reproduction recipe (archival inputs, not part of build) + +The media worker used FFmpeg; the commands below preserve its selected options. Supply the selected original files as `master.mp4`, `compact.mp4` and `new-mix.wav` in a disposable working directory. Do not replace originals. Exact delivery hashes, rather than cross-version encoder determinism, are the adoption gate. + +```sh +ffmpeg -i new-mix.wav -map 0:a:0 -c:a aac -b:a 128k -ar 48000 -ac 2 -t 46 -movflags +faststart audio128.m4a +ffmpeg -i master.mp4 -i audio128.m4a -map 0:v:0 -map 1:a:0 -vf 'scale=960:720:flags=lanczos:in_range=tv:out_range=tv,fps=60,setsar=1' -c:v libx264 -preset slow -crf 22 -threads 4 -pix_fmt yuv420p -profile:v high -g 120 -color_range tv -colorspace smpte170m -color_primaries bt709 -color_trc bt709 -c:a copy -t 46 -movflags +faststart desktop.mp4 +ffmpeg -i compact.mp4 -i audio128.m4a -map 0:v:0 -map 1:a:0 -c copy -t 46 -movflags +faststart phone.mp4 +ffmpeg -ss 6.9 -i master.mp4 -frames:v 1 -vf 'scale=480:360:flags=lanczos' -c:v libwebp -lossless 0 -quality 86 -compression_level 6 poster480.webp +ffmpeg -ss 6.9 -i master.mp4 -frames:v 1 -vf 'scale=960:720:flags=lanczos' -c:v libwebp -lossless 0 -quality 86 -compression_level 6 poster960.webp +``` + +The optional native “Visual transcript (English)” track and the always-available native details describe five visual sequences. They explicitly do not transcribe speech. The source production notes describe music/SFX, not narration. Objective audio decoding/alignment/no-clipping checks were supplied, but **no perceptual audio audition is claimed**. + +## Static HTTP contract + +Files follow the existing same-origin `/assets/` convention and are included by the existing package/Docker copy. No new static service or Nginx alias is needed: the existing static adapter provides real byte-range streams. Successful content-hashed MP4/WebP/VTT responses alone get one-year immutable caching. MP4 full/HEAD responses advertise byte ranges; partial responses retain exact 206 Content-Range/length. VTT receives `text/vtt; charset=utf-8` (the adapter's default octet-stream is not adequate for a native track). Missing/416 responses and unhashed files do not receive immutable caching. Homepage cache policy and CSP/sandbox policies remain unchanged. + +Only selected delivery files are committed. Generated CSS/JS filenames and manifest remain the existing build's responsibility. A content change must create a new media hash/path; never overwrite an immutable URL. + +## Regression and rollout boundaries + +Scoped unit/integration/browser tests cover passive absence, progressive links, one rendition, failure/policy rejection with retry, native focus/keyboard timeline, exact hashes/faststart, WebVTT parsing, and HTTP range/cache/MIME positive and negative cases. Full existing suites remain required. + +Local throughput checks use actual loopback HTTP with browser network emulation, not request fulfillment. They are reproducible lab measurements, not deployed-CDN or physical-device promises. Chromium is the verified engine; Safari/iOS/Firefox and perceptual audio listening remain unverified. Reserved-frame activation CLS is measured separately from initial font swaps in the unchanged page shell; do not claim global page CLS is zero. + +Deployment is a separately reviewed operation using the existing guarded production script. Before enabling page references, verify all five media files and hashes exist in the checked-out static root; the existing checkout-before-build-before-reload order provides this ordering. Verify public full hashes, both real ranges, WebVTT MIME, built script/CSS identity, and anonymous click-to-play behavior afterward. Keep the existing live homepage flag unchanged. Rollback is an auditable scoped revert plus the same guarded deploy, not an environment flip or routine database restore. Preserve old content-hashed files while previously served references can remain cached. diff --git a/public/assets/media/agent-artifacts-demo-desktop.eccb5261ce8a945e.mp4 b/public/assets/media/agent-artifacts-demo-desktop.eccb5261ce8a945e.mp4 new file mode 100644 index 0000000..9914361 Binary files /dev/null and b/public/assets/media/agent-artifacts-demo-desktop.eccb5261ce8a945e.mp4 differ diff --git a/public/assets/media/agent-artifacts-demo-phone.debfd582ad844195.mp4 b/public/assets/media/agent-artifacts-demo-phone.debfd582ad844195.mp4 new file mode 100644 index 0000000..31c13ad Binary files /dev/null and b/public/assets/media/agent-artifacts-demo-phone.debfd582ad844195.mp4 differ diff --git a/public/assets/media/agent-artifacts-demo-poster-480.83c10656873f7de8.webp b/public/assets/media/agent-artifacts-demo-poster-480.83c10656873f7de8.webp new file mode 100644 index 0000000..1ea4b97 Binary files /dev/null and b/public/assets/media/agent-artifacts-demo-poster-480.83c10656873f7de8.webp differ diff --git a/public/assets/media/agent-artifacts-demo-poster-960.13ccf9f3467fcdd8.webp b/public/assets/media/agent-artifacts-demo-poster-960.13ccf9f3467fcdd8.webp new file mode 100644 index 0000000..f72efce Binary files /dev/null and b/public/assets/media/agent-artifacts-demo-poster-960.13ccf9f3467fcdd8.webp differ diff --git a/public/assets/media/agent-artifacts-demo-transcript.89c7595fab39d9ee.vtt b/public/assets/media/agent-artifacts-demo-transcript.89c7595fab39d9ee.vtt new file mode 100644 index 0000000..42373a6 --- /dev/null +++ b/public/assets/media/agent-artifacts-demo-transcript.89c7595fab39d9ee.vtt @@ -0,0 +1,23 @@ +WEBVTT + +NOTE Descriptive visual transcript; no spoken dialogue or unverified sound effects are transcribed. + +00:00:00.000 --> 00:00:08.000 +An agent creates a private weekly growth report. +The reply opens as a formatted page with a chart and a recommended next step. + +00:00:08.000 --> 00:00:17.000 +The owner creates and shares a link, then adds password protection. +A recipient enters the password to open the report. + +00:00:17.000 --> 00:00:28.000 +The person asks the agent for fresh numbers in the same conversation. +The report updates at the same link. Earlier versions remain available. + +00:00:28.000 --> 00:00:35.000 +Reusable templates, saving a customized report as a template, +and Markdown and HTML presentation are shown. + +00:00:35.000 --> 00:00:46.000 +Several agents connect to one artifact link. +“Publish your agent’s work.” diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs index 579b628..b23384e 100644 --- a/scripts/build-assets.mjs +++ b/scripts/build-assets.mjs @@ -30,6 +30,11 @@ const ASSETS = [ { key: 'ui-foundation.js', source: 'src/ui/client/ui-foundation.js', name: 'ui-foundation' }, { key: 'viewer.js', source: 'src/ui/client/viewer.js', name: 'viewer' }, { key: 'dashboard.js', source: 'src/ui/client/dashboard.js', name: 'dashboard' }, + { + key: 'marketing-video.js', + source: 'src/ui/client/marketing-video.js', + name: 'marketing-video', + }, { key: 'viewer.css', source: 'src/ui/assets/viewer.css', name: 'viewer' }, ]; diff --git a/src/app.ts b/src/app.ts index 0598816..6ab51e4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,7 +19,7 @@ import { createWebRoute } from './routes/web.js'; import type { AnalyticsRecorder } from './services/analytics.js'; import { SESSION_COOKIE_NAME } from './services/sessions.js'; import { analyticsTag, configureAnalytics } from './ui/analytics.js'; -import { isHashedAssetPath } from './ui/assets.js'; +import { isHashedAssetPath, isHashedMediaPath } from './ui/assets.js'; import { ErrorPage } from './ui/pages/error-page.js'; interface AppVariables { @@ -118,6 +118,19 @@ export function createApp({ // and a year of immutable caching on those would strand the old copy in every CDN and browser. app.use('/assets/*', async (context, next) => { await next(); + // serveStatic already supplies real byte-range streams. Cache successful hashed media ranges + // too, and advertise range support on full/HEAD responses; no homepage or CSP policy change. + const media = isHashedMediaPath(context.req.path); + if (media && [200, 206, 304].includes(context.res.status)) { + context.header('Cache-Control', 'public, max-age=31536000, immutable'); + if (context.req.path.endsWith('.mp4')) { + context.header('Accept-Ranges', 'bytes'); + } + if (context.req.path.endsWith('.vtt')) { + // The static adapter otherwise falls back to octet-stream; tracks require WebVTT MIME. + context.header('Content-Type', 'text/vtt; charset=utf-8'); + } + } if (isHashedAssetPath(context.req.path) && [200, 304].includes(context.res.status)) { context.header('Cache-Control', 'public, max-age=31536000, immutable'); } diff --git a/src/ui/assets.ts b/src/ui/assets.ts index d724091..019deb0 100644 --- a/src/ui/assets.ts +++ b/src/ui/assets.ts @@ -17,7 +17,13 @@ const BUILD_COMMAND = 'pnpm run build:assets'; * Every hashed asset a page may ask for. The names match the keys `scripts/build-assets.mjs` * writes, and the union is what stops a page naming an asset the build does not produce. */ -export type AssetKey = 'app.css' | 'ui-foundation.js' | 'viewer.js' | 'dashboard.js' | 'viewer.css'; +export type AssetKey = + | 'app.css' + | 'ui-foundation.js' + | 'viewer.js' + | 'dashboard.js' + | 'viewer.css' + | 'marketing-video.js'; /** Mirrors the static root `src/app.ts` serves `/assets/*` from. Both resolve from the install. */ const servedPublicRoot = (): string => appPath('public'); @@ -42,6 +48,11 @@ export function isHashedAssetPath(path: string): boolean { return HASHED_ASSET_PATTERN.test(path); } +/** Selected, content-addressed marketing media only; never a blanket /assets cache policy. */ +export function isHashedMediaPath(path: string): boolean { + return /^\/assets\/media\/[a-z0-9-]+\.[a-f0-9]{16}\.(?:mp4|webp|vtt)$/.test(path); +} + export interface AssetResolverOptions { /** Directories that may hold `manifest.json`, in priority order. */ assetRoots: readonly string[]; diff --git a/src/ui/assets/app.css b/src/ui/assets/app.css index 8d90648..396e4a7 100644 --- a/src/ui/assets/app.css +++ b/src/ui/assets/app.css @@ -1861,6 +1861,79 @@ line-height: 1; } + /* The film owns its own reserved geometry. No change to hero, page shell or existing sections. */ + .aa-marketing-video { + width: min(100%, var(--width-aa-panel)); + margin: var(--spacing-aa-12) auto 0; + } + + .aa-marketing-video__frame { + position: relative; + aspect-ratio: 4 / 3; + overflow: hidden; + border-radius: var(--radius-aa-lg); + background: var(--color-aa-surface-raised); + box-shadow: var(--shadow-aa-card); + } + + .aa-marketing-video__frame picture, + .aa-marketing-video__frame img, + .aa-marketing-video__frame video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + } + + .aa-marketing-video__frame picture { + z-index: 1; + pointer-events: none; + } + + .aa-marketing-video__frame video:focus-visible { + outline: 3px solid var(--color-aa-accent); + outline-offset: -3px; + } + + .aa-marketing-video__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-aa-3) var(--spacing-aa-4); + margin-top: var(--spacing-aa-4); + } + + .aa-marketing-video__status { + min-height: 3em; + margin: var(--spacing-aa-2) 0; + color: var(--color-aa-muted); + font-size: var(--text-aa-sm); + line-height: 1.5; + } + + .aa-marketing-video__transcript summary { + width: fit-content; + cursor: pointer; + color: var(--color-aa-muted); + } + + .aa-marketing-video__transcript-body { + display: grid; + gap: var(--spacing-aa-3); + margin-top: var(--spacing-aa-4); + line-height: 1.6; + } + + .aa-marketing-video__transcript-body ol { + padding-left: var(--spacing-aa-6); + list-style: decimal; + } + + .aa-marketing-video__transcript-body :where(li + li) { + margin-top: var(--spacing-aa-2); + } + .aa-marketing-section { display: grid; justify-items: center; diff --git a/src/ui/client/marketing-video.js b/src/ui/client/marketing-video.js new file mode 100644 index 0000000..f37f044 --- /dev/null +++ b/src/ui/client/marketing-video.js @@ -0,0 +1,117 @@ +// Loaded only by the homepage and the style-guide specimen. No discovery, prefetch, autoplay, +// resize listener or third-party player. Only the explicit button handler may attach media URLs. +function bindMarketingVideo(root) { + const fallback = root.querySelector('[data-aa-video-play]'); + const video = root.querySelector('video'); + const poster = root.querySelector('[data-aa-video-poster]'); + const file = root.querySelector('[data-aa-video-file]'); + const status = root.querySelector('.aa-marketing-video__status'); + const frame = root.querySelector('.aa-marketing-video__frame'); + if ( + !(fallback instanceof HTMLAnchorElement) || + !(video instanceof HTMLVideoElement) || + !(poster instanceof HTMLElement) || + !(file instanceof HTMLAnchorElement) || + !(status instanceof HTMLElement) || + !(frame instanceof HTMLElement) + ) { + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = fallback.className; + button.id = fallback.id; + button.textContent = fallback.textContent; + button.setAttribute('data-aa-video-play', 'true'); + button.setAttribute('aria-controls', video.id); + fallback.replaceWith(button); + + let selected = ''; + let attempt = 0; + let deadline; + const clearDeadline = () => window.clearTimeout(deadline); + const failed = () => { + clearDeadline(); + button.disabled = false; + status.textContent = 'Playback failed. Press Watch again or open the file.'; + const returnFocus = document.activeElement === video; + poster.hidden = false; + video.hidden = true; + if (returnFocus) { + button.focus({ preventScroll: true }); + } + }; + + video.addEventListener('playing', () => { + clearDeadline(); + poster.hidden = true; + video.hidden = false; + button.disabled = false; + status.textContent = 'Playing. Use the native video controls.'; + }); + video.addEventListener('pause', () => { + if (!video.ended && !video.error) { + status.textContent = 'Paused. Use Play to continue.'; + } + }); + video.addEventListener('ended', () => { + status.textContent = 'Demo finished. You can watch it again.'; + }); + video.addEventListener('error', failed); + + button.addEventListener('click', async () => { + if (!video.paused && !video.ended && video.readyState >= 3) { + video.focus({ preventScroll: true }); + return; + } + const thisAttempt = ++attempt; + clearDeadline(); + button.disabled = true; + status.textContent = 'Loading… You can also open the video file.'; + video.hidden = false; + video.focus({ preventScroll: true }); + + if (!selected) { + const width = frame.getBoundingClientRect().width; + // A phone-sized box always keeps the 720px rendition, even at DPR 3. CSS width is the + // deliberate byte-budget policy, not a promise of pixel-for-pixel retina video. Wider boxes + // get the 960px film. Pin the choice: resize/fullscreen never fetches a second movie. + const needsDesktop = width > 480; + selected = needsDesktop ? root.dataset.aaVideoDesktop : root.dataset.aaVideoPhone; + video.src = selected; + file.href = selected; + const track = video.querySelector('[data-aa-video-captions]'); + if (track instanceof HTMLTrackElement) { + track.src = track.dataset.aaVideoCaptions; + } + video.load(); + } else if (video.error || video.readyState === 0) { + // Retrying is another explicit user action, never a fallback download of the other file. + video.load(); + } + if (video.ended) { + video.currentTime = 0; + } + + deadline = window.setTimeout(() => { + button.disabled = false; + status.textContent = 'Still loading. Press Watch again or open the file.'; + }, 15_000); + try { + await video.play(); + if (thisAttempt === attempt) { + clearDeadline(); + button.disabled = false; + } + } catch { + if (thisAttempt === attempt) { + failed(); + } + } + }); +} + +for (const root of document.querySelectorAll('[data-aa-marketing-video]')) { + bindMarketingVideo(root); +} diff --git a/src/ui/components/marketing-video.tsx b/src/ui/components/marketing-video.tsx new file mode 100644 index 0000000..8545daf --- /dev/null +++ b/src/ui/components/marketing-video.tsx @@ -0,0 +1,115 @@ +import { LAUNCH_VIDEO } from '../marketing-video-media.js'; +import { Button } from './primitives.js'; + +/** + * A first-party, deliberately activated film. The initial action is a working file link; the + * page-specific script replaces it with a native button of the same size. Without that script + * there is no dead play button and the browser still makes no movie request until navigation. + * + * No media src (including the caption track) is emitted before activation. The responsive picture + * owns the still; putting a poster on the video as well would risk downloading it twice. + * The still is an LCP candidate at the supported opening viewports, so it must not be lazy. + */ +export function MarketingVideo({ id = 'launch-demo' }: { id?: string }) { + return ( +
+
+ + + From a reply. To a real page. + + +
+
+
+ + + Open video file + +
+

+ 46 seconds. Simulated product workflow. +

+
+ Read the demo transcript +
+

This film illustrates a workflow with fictional example content.

+
    +
  1. + 0–8s — Create and open. In an agent conversation, a person asks for + a weekly growth report. The agent creates an artifact that is initially private. It + opens as a formatted report with a chart and a recommended next step. +
  2. +
  3. + 8–17s — Share and protect. In the owner's dashboard, a share link + is created, copied and sent in a team conversation. Password protection is added. A + recipient enters the password to open the report. +
  4. +
  5. + 17–28s — Update and retain. A teammate asks for fresh numbers. The + person returns to the same agent conversation and requests an update. The revised + report opens at the same link. The owner's version menu retains the earlier report. +
  6. +
  7. + 28–35s — Reuse and choose a format. The demo shows reusable + templates, saving a customized report as a template, and switching between Markdown + and full HTML presentation. +
  8. +
  9. + 35–46s — Connect and publish. Several agents connect to one + artifact link. The closing message is “Publish your agent’s work.” +
  10. +
+

+ This describes the visual sequence, not spoken dialogue. The optional visual + transcript in the video controls follows the same sequence. +

+
+
+
+
+ ); +} diff --git a/src/ui/marketing-video-media.ts b/src/ui/marketing-video-media.ts new file mode 100644 index 0000000..3161650 --- /dev/null +++ b/src/ui/marketing-video-media.ts @@ -0,0 +1,8 @@ +/** Content-addressed film delivery. Provenance and rebuild instructions: docs/homepage-video.md. */ +export const LAUNCH_VIDEO = { + phone: '/assets/media/agent-artifacts-demo-phone.debfd582ad844195.mp4', + desktop: '/assets/media/agent-artifacts-demo-desktop.eccb5261ce8a945e.mp4', + posterPhone: '/assets/media/agent-artifacts-demo-poster-480.83c10656873f7de8.webp', + posterDesktop: '/assets/media/agent-artifacts-demo-poster-960.13ccf9f3467fcdd8.webp', + captions: '/assets/media/agent-artifacts-demo-transcript.89c7595fab39d9ee.vtt', +} as const; diff --git a/src/ui/pages/home.tsx b/src/ui/pages/home.tsx index e8b57de..44e877a 100644 --- a/src/ui/pages/home.tsx +++ b/src/ui/pages/home.tsx @@ -18,6 +18,7 @@ import { MarketingWaitlist, MarketingWorksWith, } from '../components/marketing.js'; +import { MarketingVideo } from '../components/marketing-video.js'; import { Button } from '../components/primitives.js'; import { LegalFooterLinks } from './legal.js'; @@ -290,7 +291,7 @@ export function HomePage({ const pricingCtaLabel = authenticated ? HOME_AUTHENTICATED_CTA_LABEL : HOME_CTA_LABEL; return ( - + {/* The header carries one action per state below 560px. The authenticated state already did (Dashboard) and renders cleanly on one row; the anonymous state carried three, which is what @@ -382,6 +383,8 @@ export function HomePage({ + +
{HOME_EXAMPLES.map((example) => ( diff --git a/src/ui/pages/style-guide.tsx b/src/ui/pages/style-guide.tsx index e728c91..6389420 100644 --- a/src/ui/pages/style-guide.tsx +++ b/src/ui/pages/style-guide.tsx @@ -12,6 +12,7 @@ import { MarketingWaitlist, MarketingWorksWith, } from '../components/marketing.js'; +import { MarketingVideo } from '../components/marketing-video.js'; import { Avatar, Badge, @@ -215,6 +216,7 @@ export function StyleGuidePage() { return (
+ new URL(url).pathname.endsWith('.mp4'); +// Document coordinates: native focus scrolling is not a layout shift. +const geometry = (frame: Locator) => + frame.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x + scrollX, y: rect.y + scrollY, width: rect.width, height: rect.height }; + }); + +test('homepage film stays inert through scroll, hover and focus, then plays one native rendition', async ({ + page, +}) => { + const requests: string[] = []; + page.on('request', (request) => { + if (movie(request.url())) requests.push(new URL(request.url()).pathname); + }); + await page.goto(base); + const root = page.locator('[data-aa-marketing-video]'); + const frame = root.locator('.aa-marketing-video__frame'); + const video = root.locator('video'); + const play = root.getByRole('button', { name: 'Watch the 46-second demo' }); + await expect(play).toBeVisible(); + await frame.scrollIntoViewIfNeeded(); + await root.hover(); + await play.focus(); + await page.waitForTimeout(300); + expect(requests).toEqual([]); + await expect(video).not.toHaveAttribute('src'); + await expect(video.locator('track')).not.toHaveAttribute('src'); + const before = await geometry(frame); + const expected = await root.evaluate((element) => { + const box = element.querySelector('.aa-marketing-video__frame'); + if (!box) throw new Error('Missing reserved movie frame'); + const width = box.getBoundingClientRect().width; + return width > 480 + ? element.getAttribute('data-aa-video-desktop') + : element.getAttribute('data-aa-video-phone'); + }); + await play.press('Enter'); + await expect(video).toBeFocused(); + await expect + .poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime)) + .toBeGreaterThan(0); + expect([...new Set(requests)]).toEqual([expected]); + expect(await geometry(frame)).toEqual(before); + expect( + await video.evaluate((element: HTMLVideoElement) => ({ + controls: element.controls, + inline: element.playsInline, + muted: element.muted, + autoplay: element.autoplay, + loop: element.loop, + duration: element.duration, + })) + ).toEqual({ + controls: true, + inline: true, + muted: false, + autoplay: false, + loop: false, + duration: 46, + }); + + // The optional visual alternative must really parse, not merely satisfy a markup lint rule. + await video.evaluate((element: HTMLVideoElement) => { + const track = element.textTracks[0]; + if (!track) throw new Error('Missing native visual transcript'); + track.mode = 'showing'; + }); + await expect + .poll(() => video.locator('track').evaluate((element: HTMLTrackElement) => element.readyState)) + .toBe(2); + expect( + await video.evaluate((element: HTMLVideoElement) => element.textTracks[0]?.cues?.length) + ).toBe(5); + await video.evaluate((element: HTMLVideoElement) => { + const track = element.textTracks[0]; + if (!track) throw new Error('Missing native visual transcript'); + track.mode = 'disabled'; + }); + + // Native keyboard controls, followed by a real range-backed seek near the end (no media mocks). + await video.press('Space'); + await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.paused)).toBe(true); + // Native controls live in Chromium's closed shadow tree; reach the actual timeline by Tab, + // not by assuming arrow keys on the outer VIDEO seek (or a fixed five-second increment). + const pausedAt = await video.evaluate((element: HTMLVideoElement) => element.currentTime); + const cdp = await page.context().newCDPSession(page); + await video.focus(); + let timeline = false; + for (let step = 0; step < 9; step += 1) { + const tree = await cdp.send('Accessibility.getFullAXTree'); + timeline = tree.nodes.some( + (node) => + node.role?.value === 'slider' && + node.name?.value === 'video time scrubber' && + node.properties?.some( + (property) => property.name === 'focused' && property.value.value === true + ) + ); + if (timeline) break; + await page.keyboard.press('Tab'); + } + expect(timeline).toBe(true); + await page.keyboard.press('ArrowRight'); + await expect + .poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime)) + .toBeGreaterThan(pausedAt); + await cdp.detach(); + await video.evaluate((element: HTMLVideoElement) => { + element.currentTime = 45.4; + }); + await video.press('Space'); + await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.ended)).toBe(true); + await expect(root.locator('.aa-marketing-video__status')).toContainText('Demo finished'); + await page.setViewportSize({ width: 1000, height: 800 }); + await root.getByRole('button', { name: 'Watch the 46-second demo' }).click(); + await expect + .poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime)) + .toBeLessThan(10); + expect([...new Set(requests)]).toEqual([expected]); + await root.getByText('Read the demo transcript', { exact: true }).click(); + await expect( + root.getByText('This film illustrates a workflow with fictional example content.') + ).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +}); + +test('a failed movie request has a visible focused retry and never switches renditions', async ({ + page, +}) => { + // Deliberate negative control only. Successful playback in the preceding test uses real HTTP. + await page.route('**/*.mp4', (route) => route.abort('failed')); + await page.goto(base); + const root = page.locator('[data-aa-marketing-video]'); + const frame = root.locator('.aa-marketing-video__frame'); + const initialBox = await geometry(frame); + await root.getByRole('button', { name: 'Watch the 46-second demo' }).click(); + await expect(root.locator('.aa-marketing-video__status')).toContainText('Playback failed'); + const selected = await root.locator('video').getAttribute('src'); + if (!selected) throw new Error('Activation did not select a movie'); + await expect(root.getByRole('button', { name: 'Watch the 46-second demo' })).toBeFocused(); + await expect(root.locator('[data-aa-video-poster]')).toBeVisible(); + expect(await geometry(frame)).toEqual(initialBox); + await expect(root.getByRole('link', { name: 'Open video file' })).toHaveAttribute( + 'href', + selected + ); + await page.unroute('**/*.mp4'); + await root.getByRole('button', { name: 'Watch the 46-second demo' }).click(); + await expect + .poll(() => root.locator('video').evaluate((element: HTMLVideoElement) => element.currentTime)) + .toBeGreaterThan(0); + await expect(root.locator('video')).toHaveAttribute('src', selected); +}); + +test('no JavaScript keeps a real file-navigation fallback and a native transcript, not a dead button', async ({ + browser, +}, testInfo) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: testInfo.project.use.viewport ?? { width: 1440, height: 1000 }, + }); + try { + const page = await context.newPage(); + const requests: string[] = []; + page.on('request', (request) => { + if (movie(request.url())) requests.push(request.url()); + }); + await page.goto(base); + const root = page.locator('[data-aa-marketing-video]'); + const link = root.getByRole('link', { name: 'Watch the 46-second demo' }); + await link.scrollIntoViewIfNeeded(); + await link.hover(); + await link.focus(); + await page.waitForTimeout(200); + expect(requests).toEqual([]); + await expect(root.getByRole('button')).toHaveCount(0); + await root.getByText('Read the demo transcript', { exact: true }).click(); + await expect(root.locator('details')).toHaveAttribute('open'); + const source = await root.getAttribute('data-aa-video-phone'); + if (!source) throw new Error('Missing progressive fallback source'); + await expect(link).toHaveAttribute('href', source); + const response = page.waitForResponse((response) => movie(response.url())); + const bounds = await link.boundingBox(); + if (!bounds) throw new Error('Fallback link is not visible'); + // Raw browser input avoids waiting for a media-document navigation to finish streaming. + await page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + expect([200, 206]).toContain((await response).status()); + expect([...new Set(requests.map((url) => new URL(url).pathname))]).toEqual([source]); + } finally { + await context.close(); + } +}); + +test('a rejected play promise exposes the same usable retry rather than a silent or automatic fallback', async ({ + page, +}) => { + // Simulate one browser policy rejection, not a different server or invented movie response. + await page.addInitScript(() => { + const original = HTMLMediaElement.prototype.play; + let first = true; + HTMLMediaElement.prototype.play = function () { + if (first) { + first = false; + return Promise.reject(new DOMException('Test-only policy rejection', 'NotAllowedError')); + } + return original.call(this); + }; + }); + await page.goto(base); + const root = page.locator('[data-aa-marketing-video]'); + const button = root.getByRole('button', { name: 'Watch the 46-second demo' }); + await button.click(); + await expect(root.locator('.aa-marketing-video__status')).toContainText('Playback failed'); + await expect(button).toBeEnabled(); + await expect(button).toBeFocused(); + expect(await root.locator('video').evaluate((element: HTMLVideoElement) => element.paused)).toBe( + true + ); + await button.press('Enter'); + await expect + .poll(() => root.locator('video').evaluate((element: HTMLVideoElement) => element.currentTime)) + .toBeGreaterThan(0); +}); diff --git a/tests/integration/asset-caching.test.ts b/tests/integration/asset-caching.test.ts index f1e810d..37a4b08 100644 --- a/tests/integration/asset-caching.test.ts +++ b/tests/integration/asset-caching.test.ts @@ -1,11 +1,19 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { createHash, randomBytes } from 'node:crypto'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import pino from 'pino'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createApp } from '../../src/app.js'; import { loadConfig } from '../../src/config.js'; -import { type AssetKey, assetHref, isHashedAssetPath } from '../../src/ui/assets.js'; +import { appPath } from '../../src/lib/runtime-paths.js'; +import { + type AssetKey, + assetHref, + isHashedAssetPath, + isHashedMediaPath, +} from '../../src/ui/assets.js'; +import { LAUNCH_VIDEO } from '../../src/ui/marketing-video-media.js'; /** * Caching a file forever is only honest when its URL changes with its contents. That became true @@ -47,16 +55,20 @@ function hashedPath(key: AssetKey): string { } describe('hashed assets', () => { - it.each(['app.css', 'ui-foundation.js', 'viewer.js', 'viewer.css', 'dashboard.js'])( - 'lets a browser keep %s forever', - async (key) => { - const path = hashedPath(key); - const response = await testApp().request(`${ORIGIN}${path}`); + it.each([ + 'app.css', + 'ui-foundation.js', + 'viewer.js', + 'viewer.css', + 'dashboard.js', + 'marketing-video.js', + ])('lets a browser keep %s forever', async (key) => { + const path = hashedPath(key); + const response = await testApp().request(`${ORIGIN}${path}`); - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toBe(IMMUTABLE); - } - ); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe(IMMUTABLE); + }); }); describe('everything else under /assets', () => { @@ -118,3 +130,76 @@ describe('the shape that qualifies', () => { } }); }); + +describe('content-addressed marketing media', () => { + // HTTP fixture only, not an encoded movie. Real rendition decoding and seeking belong to the + // browser suite. Unique bytes keep parallel tests from touching one another's temporary asset. + const bytes = randomBytes(4096); + const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16); + const path = `/assets/media/range-fixture.${hash}.mp4`; + const disk = appPath(`public${path}`); + beforeAll(() => { + mkdirSync(appPath('public/assets/media'), { recursive: true }); + writeFileSync(disk, bytes, { flag: 'wx' }); + }); + afterAll(() => rmSync(disk)); + + it('serves a real exact 206 range with length, type and immutable cache headers', async () => { + const response = await testApp().request(`${ORIGIN}${path}`, { + headers: { Range: 'bytes=0-1023' }, + }); + expect(response.status).toBe(206); + expect(response.headers.get('content-range')).toBe('bytes 0-1023/4096'); + expect(response.headers.get('content-length')).toBe('1024'); + expect(response.headers.get('content-type')).toBe('video/mp4'); + expect(response.headers.get('accept-ranges')).toBe('bytes'); + expect(response.headers.get('cache-control')).toBe(IMMUTABLE); + expect(Buffer.from(await response.arrayBuffer())).toEqual(bytes.subarray(0, 1024)); + }); + + it('advertises ranges on HEAD and full GET without caching 416 errors', async () => { + for (const method of ['HEAD', 'GET']) { + const response = await testApp().request(`${ORIGIN}${path}`, { method }); + expect(response.status).toBe(200); + expect(response.headers.get('content-length')).toBe('4096'); + expect(response.headers.get('accept-ranges')).toBe('bytes'); + expect(response.headers.get('cache-control')).toBe(IMMUTABLE); + const actual = Buffer.from(await response.arrayBuffer()); + expect(actual).toEqual(method === 'HEAD' ? Buffer.alloc(0) : bytes); + } + const response = await testApp().request(`${ORIGIN}${path}`, { + headers: { Range: 'bytes=4096-8191' }, + }); + expect(response.status).toBe(416); + expect(response.headers.get('content-range')).toBe('bytes */4096'); + expect(response.headers.get('cache-control') ?? '').not.toContain('immutable'); + }); + + it('serves the timed visual transcript as WebVTT, not an octet-stream fallback', async () => { + const response = await testApp().request(`${ORIGIN}${LAUNCH_VIDEO.captions}`); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/vtt; charset=utf-8'); + expect(response.headers.get('cache-control')).toBe(IMMUTABLE); + expect(await response.text()).toMatch(/^WEBVTT/); + }); + + it('keeps media caching isolated from canonical thumbnails, unhashed files and misses', async () => { + for (const extension of ['mp4', 'webp', 'vtt']) { + expect(isHashedMediaPath(`/assets/media/launch-phone.${hash}.${extension}`)).toBe(true); + } + for (const path of [ + '/assets/media/launch.mp4', + '/assets/media/launch-pending.mp4', + '/assets/media/launch-0000000000000.mp4', + '/assets/template-thumbs/report.png', + '/assets/launch-000000000000.mp4', + '/assets/media/launch-000000000000.js', + '/', + ]) { + expect(isHashedMediaPath(path), path).toBe(false); + } + const response = await testApp().request(`${ORIGIN}/assets/media/absent.0000000000000000.mp4`); + expect(response.status).toBe(404); + expect(response.headers.get('cache-control') ?? '').not.toContain('immutable'); + }); +}); diff --git a/tests/unit/client-assets-build.test.ts b/tests/unit/client-assets-build.test.ts index 72f7402..73fe82f 100644 --- a/tests/unit/client-assets-build.test.ts +++ b/tests/unit/client-assets-build.test.ts @@ -29,6 +29,7 @@ const SOURCES = { 'ui-foundation.js': 'src/ui/client/ui-foundation.js', 'viewer.js': 'src/ui/client/viewer.js', 'dashboard.js': 'src/ui/client/dashboard.js', + 'marketing-video.js': 'src/ui/client/marketing-video.js', 'viewer.css': 'src/ui/assets/viewer.css', } as const; diff --git a/tests/unit/home-page.test.ts b/tests/unit/home-page.test.ts index b448f45..ad87486 100644 --- a/tests/unit/home-page.test.ts +++ b/tests/unit/home-page.test.ts @@ -308,3 +308,23 @@ describe('cloud marketing homepage', () => { }); }); }); + +describe('explicitly activated marketing demo', () => { + it('places one reserved demo after setup and before examples, with a real no-JS link', () => { + const html = renderToString(HomePage({ baseUrl: 'https://example.test' })); + const start = html.indexOf('data-aa-marketing-video'); + expect(start).toBeGreaterThan(html.indexOf('Set up with your agent')); + expect(start).toBeLessThan(html.indexOf('id="home-examples-title"')); + const player = html.slice(start, html.indexOf('', start)); + expect(player).toContain('Watch the 46-second demo'); + expect(player).toContain('Read the demo transcript'); + expect(player).toContain('preload="none"'); + expect(player).toContain('loading="eager"'); + expect(player).toContain('480w, /assets/media/'); + expect(player).toContain('playsinline'); + expect(player).not.toMatch(/<(?:video|source|track)[^>]*\ssrc=/); + expect(player).not.toMatch(/\s(?:autoplay|loop)(?:=|\s|>)/); + expect(player).toMatch(/]+href="\/assets\/media\/[^" ]+\.mp4"/); + expect(renderToString(StyleGuidePage())).toContain('data-aa-marketing-video'); + }); +}); diff --git a/tests/unit/marketing-video-media.test.ts b/tests/unit/marketing-video-media.test.ts new file mode 100644 index 0000000..fd9bf89 --- /dev/null +++ b/tests/unit/marketing-video-media.test.ts @@ -0,0 +1,57 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { isHashedMediaPath } from '../../src/ui/assets.js'; +import { LAUNCH_VIDEO } from '../../src/ui/marketing-video-media.js'; + +// Approved encodes/posters are adopted, not regenerated by the application build. These full +// hashes bind the four delivered assets; updating the film requires an explicit media review. +const approved = { + desktop: 'eccb5261ce8a945ef779ecb2575241ddd9f7d615cddce7cb99a2c6451df14cd1', + phone: 'debfd582ad844195d4af3a7cb8321f563edf16c23afc457588785066f0f75075', + posterPhone: '83c10656873f7de8c2f3b52609f8c23d51c7c63557a0b4b144f30db297d2be88', + posterDesktop: '13ccf9f3467fcdd8177563613b8771e854ccc70852208bf5ff0e1691979979bb', +}; + +describe('the shipped homepage film', () => { + it.each(Object.entries(LAUNCH_VIDEO))( + 'ships %s under its own actual content hash', + (key, url) => { + const bytes = readFileSync(`public${url}`); + const digest = createHash('sha256').update(bytes).digest('hex'); + expect(isHashedMediaPath(url)).toBe(true); + expect(url).toContain(`.${digest.slice(0, 16)}.`); + if (key in approved) expect(digest).toBe(approved[key as keyof typeof approved]); + } + ); + + it.each(['phone', 'desktop'] as const)( + 'keeps %s faststart, with the metadata ahead of movie data', + (key) => { + const bytes = readFileSync(`public${LAUNCH_VIDEO[key]}`); + const boxes: string[] = []; + let offset = 0; + while (offset < bytes.length) { + const size = bytes.readUInt32BE(offset); + expect(size).toBeGreaterThanOrEqual(8); + boxes.push(bytes.toString('ascii', offset + 4, offset + 8)); + offset += size; + } + expect(offset).toBe(bytes.length); + expect(boxes).toContain('moov'); + expect(boxes.indexOf('moov')).toBeLessThan(boxes.indexOf('mdat')); + } + ); + + it('keeps the optional timed visual alternative factual and available without replacing the film', () => { + const text = readFileSync(`public${LAUNCH_VIDEO.captions}`, 'utf8'); + expect(text.startsWith('WEBVTT\n')).toBe(true); + expect(text).toContain('Descriptive visual transcript'); + expect(text.match(/ --> /g)).toHaveLength(5); + expect(text).toContain('00:00:00.000 --> 00:00:08.000'); + expect(text).toContain('00:00:35.000 --> 00:00:46.000'); + expect(text).toContain('Earlier versions remain available'); + expect(text).toContain('“Publish your agent’s work.”'); + expect(text).not.toMatch(/\[music\]|\[.*sound.*\]/i); + }); +}); diff --git a/tests/unit/ui-assets.test.ts b/tests/unit/ui-assets.test.ts index 080bd98..8c2748f 100644 --- a/tests/unit/ui-assets.test.ts +++ b/tests/unit/ui-assets.test.ts @@ -164,6 +164,7 @@ describe('the real build script and the resolver', () => { 'viewer.js', 'dashboard.js', 'viewer.css', + 'marketing-video.js', ]; it('agree on every key the pages can ask for', () => {