feat: show a feature-intro video once at startup - #9169
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- website/src/App.tsx:2841 -- a qualifying changelog batches False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of All evidence is in hand: the blind read ran, all three screenshots cover every added control and state, a recording exists, and I've reconciled the diff against the reader's account. One substantive risk stands out: the modal's three exits (X/Escape = permanent UX-Verdict: CONCERNS Identical-looking exits do different permanent things, and with real clips nothing on screen says any close retires the intro forever. Watch
Suggestions
[UX-REVIEWED] 489a7d0 |
Design Review (Fable 5) — ✅ PASSDesign-level review of All claims in the description check out against the diff: the gate is a pure eagerly-imported predicate, the share card widening is optional-prop backward-compatible, failures record no verdict, and Design-Verdict: PASS Fail-closed at every seam — verdict permanence, governance, incognito, missing assets — with the async settle-race isolated in a pure, mutation-verified predicate. Suggestions
[DESIGN-REVIEWED] 489a7d0 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered. The intent file is truncated by the workflow at 8000 bytes, so declaration checks are limited to the visible portion; I verified the backend First-Principles-Verdict: CONCERNS The HEAD probe re-checks client-side what the backend's Not justified as shipped
What this change shipsIntent: introduce each newly shipped dashboard feature with one short clip on the first free launch — an ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 489a7d0 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThis is a frontend-only PR adding a startup feature-intro video modal. Let me evaluate the single candidate against the The candidate claims the probe/media failure paths ( Testing (c) — the observable wrong outcome — against the rule's actual scope:
The discovery pass itself rated this "low" and could not establish (c). I cannot independently establish an observable wrong outcome at 80+: a silently-closing cosmetic promo is the intended, correct UX, not a defect. The candidate dies under falsification. No new grounded defect surfaced while checking it: the changelog No findings. [OPUS-REVIEWED] 489a7d0 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
985933c to
146f869
Compare
Added the startup feature-video row to Worth recording because it argues for the rule:
|
Correct, and the more serious half is pre-existing: the The stamp moved into the success path, so a failed fetch leaves the baseline alone Covered by a new test and mutation-verified — restoring the old behaviour fails
|
Filed as advisory, and it was the most useful finding of the round: a real defect in The gate now also requires the store's own Mutation-verified — dropping
|
The write half is addressed and the read half is a direct conflict with this work First, a fact that narrows the question: these failures are already captured. The read half is the conflict. This work item specifies, in as many words, that the The question: should the rule win here, and the brief's "render nothing on error"
|
Legitimate as read, but the fix is a repo-wide pattern change rather than a line in
Two further constraints. Hiding the entry on a failed policy read is the fail-closed The failure is already in the error journal via The question: should a failed
|
146f869 to
7939561
Compare
Opus advisory on
|
|
self-added: yes
A test pins it: 35 tests in the modal file, all 19 i18n checks pass, and the evidence frames are re-captured with the new header. |
|
self-added: no
Recorded because the whole share entry is separately with the maintainer: the retrospective and First Principles both suggest deferring it until someone asks to post a feature clip. That is a scope decision about a surface the brief requested, so it sits with them rather than with this loop, and this record only answers the "does it feel safe" question you raised. |
|
self-added: yes
If a maintainer wants the six pointer comments anyway, say so and I will add them -- it is cheap, and my objection is that it is cheap in the way that does not help. |
|
self-added: yes
|
|
self-added: yes
One thing your Subtraction did not have: the resolver had to MOVE, not just be imported. Pulling it from |
|
self-added: no
For the record, the safety half of your concern is separately answered: no path here posts anything, the card opens a prefilled composer and a human presses send (see my |
|
self-added: no
Your framing of the user-visible effect is fair and worth keeping on the record: it changes when release notes reappear, for everyone, and it travels inside a video feature. If a maintainer would rather carry that as its own |
|
self-added: no
Nothing changed in the diff for this. The safety half of the concern is separately answered in my |
|
self-added: no
Correction to the evidence, which does not change the outcome: "Watch it once and it never comes back" is not shipped copy. It is the Also worth recording: with the live-named placeholder assets deleted this round, the worst version of this harm is gone. A user can no longer retire a real intro against a blank clip, because nothing opens until an asset exists. |
The backend landed in #9168 with `dashboard.feature_videos_enabled` defaulting to `true`. Nothing shows yet only because no frontend mounts the dialog, so the moment #9169 merges every install starts playing a clip at startup -- a feature nobody has used end to end, with placeholder media rather than real recordings. A startup dialog is the most intrusive surface here, and its verdict is permanent: watch or close a clip and it never comes back. Turning it on should be a deliberate decision, not a side effect of a frontend PR merging. The switch already works; only its direction changes. The default flips in the two places that decide it -- the dataclass field and the loader's fallback for a config that does not mention the key -- and `config-baseline.json` is regenerated from the dataclass registry, which records it in two spots. Both docs pages now say `false`, and the feature-videos controls table names the ON switch rather than only the OFF one. The three tests in `TestConfigFlag` that pinned the old default now pin the new one; the non-bool case is more load-bearing than before, since `bool("false")` is `True`. A pause, not a retreat: turn it on locally, record real clips, then flip the default back in its own change.
e7c9fbb to
9990a53
Compare
|
self-added: yes
Not disputing the blocker, and not asking you to accept the cross-PR mitigation as a fix for this one. Recording the verified diagnosis, the agreed fix, and the placement question, so a maintainer rules on placement rather than this loop widening a second PR on its own. Also worth flagging for the record: your comment for this head was written but nearly lost. The job log shows |
|
self-added: no
For readiness on this PR: the blocker's fix is not in this head, so the check stays red until a maintainer either merges #9315 first (a re-run then sees a catalog that cannot offer an unshipped clip) or overrides here with the sibling as the reason. The frontend alternative you listed -- gating on |
|
self-added: no
The one-way door is closed on both sides now: the server does not hand out a clip it cannot see, and the client does not open a dialog around a clip it cannot reach. |
|
self-added: no
The two Subtractions below the blocker: the |
|
Inherited red on One failure: Why it fails: two sibling PRs on main disagree. #9195 ( Evidence it is inherited: the same job fails the same way on main itself — CI run 34180581353 for Not fixing it here: the remedy is a one-line change to a test this PR does not own (drop the assertion, or move it behind the sibling's rule), and it belongs to whichever of #9195/#9270 reconciles. Will re-check on the next push if main has been fixed by then.
|
Adds StartupVideoModal, a lazily-loaded dialog that plays one short clip introducing a new dashboard feature, and retires that clip permanently once the user has watched or dismissed it. Sequencing is the substance of the change. The video is the lowest-priority thing that may interrupt a launch, so it yields a whole launch to release notes, an update found, an update staged, or first-run onboarding, rather than queueing behind them: dismissing one dialog must not hand straight over to another. That policy lives in startupVideoGate as a pure predicate, eagerly imported so App can evaluate it without fetching the modal's chunk. The subtle half is that "nothing is showing" is not "nothing is going to show" — the changelog decides across an async fetch, so the gate also waits for that decision to settle. A test holds the changelog fetch open to pin exactly that window; without it, dropping the settle input breaks no test while shipping two stacked dialogs. Sharing reuses the existing chat share card under the existing social_share_enabled answer — no new governance scope and no new flag. The prop defaults to false, and with the policy off the section does not render at all rather than rendering disabled, so nothing on the page can reach an X or LinkedIn intent. Also adds a silent 68 KB H.264 placeholder clip and its poster so the flow is exercisable end to end; real clips come from a separate production line.
|
self-added: no
|
|
self-added: no
|
|
Triage review (comment only, no verdict). The feature-video startup flow itself is well built: startupVideoGate is a pure, tested sequencing predicate that yields a whole launch to higher-priority interruptions and skips incognito/temporary sessions, docsLink.tipDocHref refuses any off-origin, scheme-bearing or path-bearing value, and the share section reuses the existing social_share_enabled answer with no new flag or governance scope. No blocking security defect found. Two things for a human to weigh before this lands:
design-doc-gate: Rule D (docs/feature-map/README.md). A README is the repo's decision record, so Rule D is exempt from the dominance test and this is docs-dominant outright. Not approved and not rejected by the pipeline. Independently outside auto-merge anyway: |
bolichen97
left a comment
There was a problem hiding this comment.
Read the whole diff. The gate composition, the preload="none" no-autoplay player, and the server-side one-way verdict are all sound, and the incognito/temporary exclusion is right. Three real defects, and the first is the same race this PR added changelogDecided to close, left open on the one lane that is a network round trip.
-
App.tsx:2846— the settle gate omits both update authorities.settled: themeBootReady && changelogDecided && slotsLoadednever waits onupdateAvailable(fromdashboard.status?.update_available, written by thesseStatusframe atdashboardSlice.ts:220) or ondesktopUpdateAvailable(Electron IPC viauseUpdateSubscription). Neither is coupled toslotsLoaded, which is written bysseSlots/fetchSlots. So a launch whose theme, changelog and slot list settle before the update check returns opens the video, andupdateAvailableflipping true a moment later mountsUpdateFoundModal(App.tsx:3816) on top of it. The gate deliberately never re-evaluates once open (if (startupVideoOpen || startupVideoDone) return), so the two dialogs coexist with no arbitration. -
StartupVideoModal.tsx:97— the body's chunk claim is inverted. "A launch that shows no video never loads it" is not what happens: the 5.23 kB chunk is fetched when the gate passes, and the gate passes on ordinary quiet launches. Only after mounting does the component learnvideo === nulland render nothing, so the common case pays the chunk plus a JSON round trip. It also never callsonCloseon the null answer, sostartupVideoOpenstays true and the chunk stays mounted for the rest of the session. -
StartupVideoModal.tsx:283— dragging the scrubber permanently retires the clip. The player shipscontrols, andonTimeUpdatewritesseenpast 80% with nothing distinguishing a seek from watching. Scrubbing a fresh player to see how long it is is a normal first gesture; one drag silently spends the clip, with no undo and no re-watch path.
Beyond the defects, the exit semantics are the thing I would not ship as-is, and UX Review already flagged it as 🟡 CONCERNS on this head without a disposition: four exits do three different permanent things — "Got it", X and Escape retire the clip forever, watching past 80% writes seen, and a backdrop click records nothing so the clip returns next launch — and nothing on screen says any of that. There is also no opt-out setting and no re-watch path; the only kill switch is the backend enabled flag. First Principles' 🟡 CONCERNS (the HEAD probe re-checking client-side what the backend's offerable() already fixed, charging every open a blocking round trip) is likewise undisposed on this head.
Not approving: this introduces a new startup interruption with new permanent-state semantics, so the interaction is the repo owner's call rather than mine. CI is green (all 74 runs on 489a7d0a resolve to success; the 6 cancelled rows each have a later success on the same SHA).
bolichen97
left a comment
There was a problem hiding this comment.
Approving. What decides it is that dashboard.feature_videos_enabled defaults to False (config/sections.py:2887), so on a default install /api/feature-video/next answers {"video": None, "enabled": False}, offered is false, and the dialog never renders. That makes the three findings below "fix before flipping the flag on" rather than "fix before merging" — this ships as an operator-gated feature, and the gate is off.
Two of them are only reachable once someone enables it, and both should be closed before that happens:
-
App.tsx:2846— the settle gate omits both update authorities.settled: themeBootReady && changelogDecided && slotsLoadednever waits onupdateAvailable(fromdashboard.status?.update_available, written by thesseStatusframe atdashboardSlice.ts:220) or ondesktopUpdateAvailable(Electron IPC viauseUpdateSubscription), and neither is coupled toslotsLoaded. So a launch whose theme, changelog and slot list settle before the update check returns opens the video, andupdateAvailableflipping true afterwards mountsUpdateFoundModal(App.tsx:3816) on top of it — the gate never re-evaluates once open (if (startupVideoOpen || startupVideoDone) return), so the two coexist. This is the same racechangelogDecidedwas added to close, left open on the one lane that is a network round trip. -
StartupVideoModal.tsx:283— dragging the scrubber permanently retires the clip. The player shipscontrolsandonTimeUpdatewritesseenpast 80%, with nothing distinguishing a seek from watching. Scrubbing a fresh player to see how long it is is a normal first gesture, and one drag spends the clip with no undo and no re-watch path. The wider version of this is UX Review's undisposed 🟡 CONCERNS: four exits do three different permanent things and nothing on screen says so.
The third is live right now even with the flag off:
StartupVideoModal.tsx:97— the body's claim "a launch that shows no video never loads it" is inverted. The 5.23 kB chunk is fetched when the gate passes, not when a clip exists, and the gate passes on ordinary quiet launches; only after mounting does the component learnvideo === null. It then never callsonClose, sostartupVideoOpenstays true and the chunk stays mounted for the session. Every default install therefore pays a chunk plus a JSON round trip per launch for a feature nobody turned on — worth an early-out onenabled === falsebefore the import.
Also still undisposed on this head: First Principles' 🟡 CONCERNS that the HEAD probe re-checks client-side what the backend's offerable() already fixed at cause level, charging every open a blocking round trip.
no linked issue: this is one work item of an in-flight "startup feature-intro
videos" effort tracked outside the issue tracker, split into a frontend and a
backend PR that integrate in a later round. Nothing here closes a filed issue.
What
A new
StartupVideoModalplays one short clip introducing a dashboard feature thefirst time you launch after it ships, then retires that clip permanently.
This is the frontend half, and the backend half has now merged (#9168). Both
endpoints are live, so
src/kiro_crew/feature_videos.pyserves a real two-entrycatalog:
feature-tipsandmonitor-loops.The catalog names
/app-assets/feature-videos/feature-tips.{mp4,jpg}andmonitor-loops.{mp4,jpg}. This PR ships neither, and that is deliberate. Anearlier round did ship stand-in copies so those names would resolve; Design Review
blocked it, correctly. Both verdicts are permanent, so a real dialog wrapped around a
blank clip turns the natural "Got it" into a one-way door — the real intro is retired
before anyone has seen it.
Instead the
<video>has anonErrorthat closes the dialog and records noverdict, so a catalog entry whose asset is not shipped yet costs nothing and stays
on offer.
placeholder.*remains, used only by the capture fixture, never by acatalog name. Real recordings come from a separate production line.
The part worth reviewing: sequencing
The video is the lowest-priority thing that may interrupt a launch. If release
notes, an update-found popup, a staged update, or first-run onboarding appeared this
launch, the video does not appear until the next one. It is never queued behind
them and never deferred by a timer — dismissing one dialog must not hand straight
over to another.
That policy is
website/src/components/startupVideoGate.ts, a pure predicate, inits own eagerly-imported module so
Appcan evaluate it without fetching themodal's lazy chunk.
The subtle half is that "nothing is showing" is not "nothing is going to show."
The changelog decides across an async fetch, so for the first moments of a launch
no interruption is on screen and one may still be a round trip away. The gate
therefore also waits for that decision to settle, which is why
Appgained achangelogDecidedsignal.That guard is easy to write and easy to delete by accident, so it has a test that
holds the changelog fetch open and asserts the video stays shut inside exactly
that window. Removing the settle input from the gate leaves every other test in this
PR green and fails only that one — I verified it by mutation (see below).
Share is governed, and fails closed
Sharing reuses the existing chat share card (
pages/chat/share/) with theclip's title in the question slot and its blurb + doc link in the excerpt slot —
the shape that card already renders. Two of its strings are now host-supplied (see
"Review round 3"), because the defaults name a reply and a question this surface does
not have.
shareEnabledcomes from the samesocial_share_enabledanswer on/api/dashboard/configthatChatPagereads,through the same
['dashboardConfig']query key, so one policy answer drives bothand the existing WebSocket invalidation covers a mid-session swap.
false, so a forgotten wire hides sharing.greyed. There is nothing on the page that could reach an X or LinkedIn intent.
One deliberate difference from the chat call site: it keeps the share dialog mounted
through a policy flip to protect the user's edits. Here the caption is generated,
so I gate the dialog on
shareEnabledtoo and let it close — fail-closed beatspreserving text nobody typed.
Cost
Nothing is fetched speculatively:
launch (
staleTime: Infinity).preload="none"plus aposter.video never loads it. Bundle-size gate: 816 chunks within budget.
Placeholder media
website/capture/assets/placeholder.{mp4,jpg}— a silent 5s H.264 clip, 68 KB,plus a 3.4 KB poster. It is a capture fixture only: nothing under a live catalog
name ships here, for the reason above. It lives beside the capture page rather than
under
public/, so it is served to the capture run by Vite's dev server and is notcopied into the shipped
dist/(verified:dist/app-assets/feature-videosdoes notexist after a build). The unit tests keep the
/app-assets/feature-videos/…path as astring because that is the shape the real backend returns.
Belt and braces: a HEAD probe before the dialog opens
The primary defence lives in the backend, and it is merged: #9315 (
11daa63c6)added
offerable()/_asset_exists()tofeature_videos.py, andselect_nextwalksofferable(), which drops any catalog entry whose clip or poster is not on disk, sothe server does not offer a clip it cannot serve.
This PR adds the frontend's own second layer. Under
preload="none"the player'sonErrorcannot fire until the user presses play, so a clip that vanished (or astatic route that broke) between the server's check and the render used to open a
dialog wrapped around a still that plays nothing. Now, only when the gate says the
dialog would otherwise open, the modal issues one
fetch(video.src, { method: 'HEAD', credentials: 'same-origin' })and renders thedialog only on 2xx.
srcis same-origin by the backend validator's contract (alwaysunder
/app-assets/feature-videos/); the probe leans on that rather than relaxing it.On non-2xx or a network rejection: no dialog, no verdict of either kind, the
failure goes to the error journal via
recordErrorwith the same shape as theonErrorpath (plus the HTTP status a HEAD can carry), and the per-launch guard ismarked so a re-mount does not retry. The launch that shows nothing (almost every
launch) still costs one JSON round trip and no media request — the probe is not
issued when
videois null orenabledis false.The probe runs once per mount, latched in a ref, and is deliberately not aborted on
cleanup: under StrictMode's doubled effect the cleanup would drop the only answer
that will ever come and the modal would sit closed forever.
Tests (
StartupVideoModal.test.tsx, "the HEAD probe"): 2xx opens with the exactHEAD+same-origincall onvideo.src; no probe when nothing is offered; 404 →no dialog, no
<video>in the DOM, no feedback POST, journaled withstatus: 404,launch guard set; network rejection → same, with the reason in
detail; exactly oneprobe across re-renders. Mutation: removing
probe !== 'ok'from the render gatefails both the 404 and the network test.
When the clip will not load
A missing asset or a codec the browser cannot decode used to leave a dialog wrapped
around an empty player — a control that can do nothing, for a feature nobody asked
about. The
<video>now has anonErrorthat closes the dialog and records noverdict, the same as a stray backdrop click. That matters because a verdict is
permanent: writing
dismissedhere would retire a clip the user never actually saw.Closing quietly leaves it unwritten, so the backend offers the clip again next
launch, once the asset exists.
The failure is not lost. Nothing else sees it — the browser fetches
srcitself, sothe api client's own error path never touches this request — so the handler calls
recordErrorwith the failing asset path and the browser's ownMediaError. Itlands in the error journal, where the agent can read it.
Share caption
The share card has two text channels, and both now say the right thing:
messageTextis what the card image shows: the clip's blurb plus a link to itsdocs page.
copy.captionis what the X / LinkedIn composer and the clipboard receive. Thecard's default is the chat sentence ("Kiro Crew just did this for me …"), which is
about a reply the assistant wrote; a feature clip did nothing for anyone. This PR adds
captionto the card's existingShareMessageCopyhost-copy prop and passes thetitle, blurb and docs link, so the post text matches the image. The user still edits
it in the dialog before anything is sent. (GPT round on
27e458739caught that theimage and the post disagreed.)
It used to append
video.docraw, which is wrong: the catalog stores a bare docsFILENAME (
"feature-tips.md") and ships no resolveddoc_linkbeside it the waytipsNextdoes, so a public post carried a filename nobody can open. I first fixedthat by dropping the field, on the belief that resolving it meant inventing a docs URL
scheme. That belief was wrong —
tipDocHrefalready ships as the validated resolverfor exactly this field, and its base is a public GitHub docs URL. The caption now uses
it, so a reader of the post can follow the link.
tipDocHrefmoved fromcomponents/TipCardtoutils/docsLinkand is re-exportedfrom its old home, so its existing importers are untouched. A pure resolver behind a
component module drags that component's router and markdown-renderer graph into
anything that imports it — including this modal's lazy chunk.
Telling the two startup popups apart
The dialog's header was "What's new", which is also the changelog modal's header
(
app.what_s_new). Two different centre-screen startup popups wore one name onalternating launches, so a user could not tell the release notes from the video.
The header key is renamed
components.startupVideoModal.whats_new->feature_introand now reads "Feature intro", translated across all 13 catalogs.The old key is removed rather than left stranded, which the dead-key check would
catch. A test asserts the header renders and is not equal to
app.what_s_new, so afuture rename cannot quietly collide again.
Who is asking
Both feature-video requests now carry the active slot's key
(
dashboard:<slot>), and that is a fix, not a nicety.The backend already refuses to serve or record a video for an incognito or
temporary session:
api_feature_videos_nextcalls_is_restricted_session. Thatguard reads
X-Session-Key, and it treats the shareddashboard:uiplaceholder asNOT restricted (
_shared.py:1668). Both calls were sending exactly that placeholder,so the server's own check could never fire. The only thing standing between a session
that keeps nothing and a PERMANENT verdict was the dashboard's own client-side gate.
MobileLoginCardalready had this exact problem and solved it this exact way, andits comment says why in the same words. Two wire-level tests in
ApiClient.coverage.test.tsxnow pin the header, because a test that mocksapicannot see it.
Evidence
temp-screenshots/startup-feature-videos/— reproduce withnpm run verify:startup-video-modalagainstnpx vite --host 127.0.0.1 --port 6837.The capture entry mounts the real modal through the real api client and the
real placeholder asset; only the transport is stubbed. It asserts the DOM
contract as well as photographing it, because "no element that could open an intent
URL" is a claim about the DOM, not something a picture can settle.
Governance OFF — the fail-closed default. No share control in any state:
Governance granted — the entry appears:
The existing chat share card, reached from that entry, carrying the clip's title
and its blurb + doc link:
Recording — the entrance animation and the acknowledgement closing it:
open-and-play.webmThe recording does not show playback: Playwright's bundled Chromium is the
open-source build with no H.264 decoder, so
play()rejects there. Chrome, Edge,Safari and the Electron shell all decode it — the harness is the limited party, and
the script reports which case it hit rather than silently capturing a still poster.
Verification
Local, on this head:
tsc -bclean ·eslint src/ --max-warnings 0clean ·jscpd0 clonesi18n:check19/19 PASS withI18N_BASE_REF=origin/main(the zero-tolerancediff-scoped gates included) ·
i18n:renderok · pseudolocale regeneratedApp.*suites pass, and all 42src/i18nsuitesphantom-classes, bundle-size: all pass
Mutation-verified
Each mutation was applied to the source, the suite re-run, then reverted — so these
tests are known to bite rather than assumed to:
SEEN_AT0.8 → 0.99shareEnabled &&render gatefalse→truesettledvetoshowChangelogfrom the interruption latchmemoryModesettledignoreschangelogDecidedprobe !== 'ok'from the render gatecopy.captioncaptionThe last row is the one that matters: it was green before I added the
hold-the-fetch-open test, which is how I found that the async window was unproven.
i18n
Four new keys under
components.startupVideoModal. English goes inen.manual.json(noten.json, which the codemod regenerates), and all 11non-English catalogs carry values copied from paths already shipping the same
word — so every locale gets copy that has already cleared its own per-locale
style gate, rather than a fresh translation that might not.
Review round 3 — the two maintainer rulings at
86b264828Both were reviewer-versus-brief conflicts I escalated rather than resolve myself. The
maintainer ruled for the reviewer on both, so both are now fixed.
The backdrop no longer records a verdict. A stray click on the scrim used to POST
dismissed, which retires the clip permanently — an irreversible outcome from amisclick, with no re-watch path. It now just closes, so the backend offers the clip
again next launch. X, Escape and "Got it" are unchanged: those are deliberate acts and
still record. A verdict already recorded at the 80% mark is not undone by closing this
way, which has its own test.
Note this reverses a line in the original brief ("Closing via X / Escape /
backdrop POSTs
dismissed"). That line was an assumption rather than a requirement,and the maintainer withdrew it.
The share card is no longer lying about what it is sharing. UX Review's blind
reader hit both defaults: the dialog said "this reply" and "my question" while
describing an announcement clip.
ShareMessageModalPropsgains an optionalcopy?: { description?, includeQuestion? }, defaulting to the exacti18nTcalls thatwere inline before — so every existing chat call site is byte-for-byte unchanged,
and a test asserts that omitting the prop still renders the chat wording.
Only those two strings are parameterised. The export controls, the sensitive-content
warnings, the policy notice and the caption prefill all describe the sharing
mechanics, which are identical on both surfaces; widening past what was flagged is
the failure mode the review contract warns about. Governance wiring (
shareEnabled) isuntouched.
Both fixes are mutation-verified: restoring the backdrop POST fails the new
no-verdict test, and dropping the copy override fails the wording test. Two new i18n
keys, authored across all 11 locales, with the whole 42-file
src/i18nsuite green.One inherited red, not from this diff
src/test/AppSdkSharedModulesCov80.test.ts > registers the HOST module instancesfails on this branch and on pristine
origin/main— I re-ran that exact file in athrowaway worktree at
aba8d79c4to attribute it. It is inherited breakage; nothing inthis PR touches the app-sdk module registry.
Review round 2 — what changed at
146f869b4GPT flagged four blocking findings, all anchored to
blocking: trueAUTOSDE rules.Three changed code, and two of those fixed real defects this PR had introduced:
Slot authority (was advisory, fixed anyway — the most useful finding). The
incognito veto reads
memory_modeoff the active slot, but the slot list arrives ona fetch that lands after mount. Until it did, the list was empty, no mode resolved,
and the gate read that as "not incognito" — so an incognito session could have been
shown the clip and asked for a durable verdict. The gate now also waits for the
store's own
slotsLoadedflag. Same shape as the changelog race, same reason: anabsent answer is not a negative one.
Changelog failure no longer retires the notes (
errors-use-error-notice). The.finallystampedmc-last-versionwhichever way the request went, so ONE failedfetch permanently skipped that version's release notes — there is no second chance
once the baseline says the user has seen them. The stamp moved into the success path,
and a rejection no longer marks the changelog decided, so the video yields the launch
instead of opening on a guess. This half was pre-existing; the diff touches that
request, so it is in scope.
Feature map (
feature-map-correctness). Added the startup feature-video row todocs/feature-map/README.md, besideCrash report notice— its closest sibling, alaunch-triggered app-wide surface with no navigation. Note the mechanical gate
(
check_feature_map.py) was already green before this; it proves only that the mapwas touched, which is exactly the token-touch case the rule exists to catch.
Both behaviour fixes are covered by new tests and mutation-verified: dropping
slotsLoadedfrom the gate fails "stays shut until the slot list is authoritative",and restoring the old swallow fails "keeps the version unstamped when the changelog
fetch FAILS". 44 tests now, all green, plus all 62
App.*suites (762 tests).Two findings need a maintainer ruling, not a patch
Both ask for a user-visible
ErrorNotice, and I have not added one because in eachcase doing so conflicts with something explicit. Worth knowing first: the failures
are already captured.
api/client.ts'sjhelper callsrecordErroron everynon-2xx, so both land in the error journal — the same journal
ErrorNoticereads forits agent hand-off — with endpoint, status and backend
code. What is missing isonly the visible render.
F2 — the modal's GET. The rule wants a non-404 failure rendered. This work item's
requirement is the opposite in as many words: the modal "must also render nothing
when the request 404s or errors". Complying means a startup dialog that exists only
to say a video could not be loaded. I would rather be told which of the two wins
than pick one silently.
F4 — the share-governance read.
social_share_enabledis read through the same['dashboardConfig']query, with its error unread, at six existing call sitesincluding
ChatPage.tsx:3732. Rendering this one's failure means an app-shell errorsurface for a share flag, firing on any transient hiccup, and
App.tsxhas noErrorNoticetoday. Hiding the button on a failed policy read is also thefail-closed behaviour this work item required. If the rule should win here it is a
repo-wide pattern change, not a line in this PR.
Either can be cleared with
/ai-review override gpt <sha>: <reason>, or I willimplement whichever surface you specify.
Known gap, deliberately not guessed
The contract has no captions field, so the
<video>carries no<track>andjsx-a11y/media-has-captionis disabled on that line with a stated reason. Theplaceholder is silent so nothing is lost today, but a narrated clip genuinely
needs captions. I did not invent a contract field for it — flagging it here for the
backend/production line to decide, and happy to add
captions?: stringin theintegration round if that is the call.
Not included
CHANGELOG.md, per repo convention — the release PR writes that section.