chore(deps): Bump actions/github-script from 7 to 8#8
Merged
tataihono merged 1 commit intoFeb 16, 2026
Merged
Conversation
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](actions/github-script@v7...v8) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com>
6d50033 to
1671ac3
Compare
Ur-imazing
added a commit
that referenced
this pull request
Apr 20, 2026
AppleScript via osascript was the only remaining non-headless input path in the QA pipeline — it required the Apple TV Simulator to be the frontmost macOS app, meaning developer keystrokes collided with test keystrokes during tvOS flow execution. Swap to idb (Facebook iOS Development Bridge) which routes through SimulatorBridge (XPC) directly to the simulator process — matches Android TV's already-headless adb model. Host window focus becomes irrelevant. No Accessibility permission required. Can run in parallel with mobile/browser flows or while the user types. Changes: - apps/tv/e2e/adapters/tvos.ts: replace osascript sendDpad() with idb ui button invocation; swap Accessibility check for idb presence check in checkAvailability(); screenshots and launch unchanged - .claude/commands/qa.md: remove "tvOS requires exclusive foreground" warning; document that all 4 simulator surfaces are now headless - docs/solutions/platform/local-qa-pipeline-first-runs-20260417.md: update lesson #8 to reflect the idb swap; add idb install steps to prerequisites Prerequisite (one-time install): brew tap facebook/fb brew install idb-companion pipx install fb-idb TV flow YAMLs, runner.ts, types.ts, and unit tests unchanged — the swappable-backend design from the original plan pays off here. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9 tasks
Ur-imazing
added a commit
that referenced
this pull request
Apr 23, 2026
…+ crimson palette (#830) * docs(tv): brainstorm + plan for video-player auto-hide controls Captures the requirements (5s auto-hide via D-pad, play/pause initial focus, crimson palette alignment) and the 7-unit implementation plan for apps/tv/src/components/VideoPlayer.tsx. Brainstorm: docs/brainstorms/2026-04-21-tv-video-player-controls-auto-hide-requirements.md Plan: docs/plans/2026-04-22-001-feat-tv-video-player-auto-hide-controls-plan.md * feat(tv): scaffold video-player auto-hide state + retire warm-salmon palette Unit 1 of docs/plans/2026-04-22-001-feat-tv-video-player-auto-hide-controls-plan.md. Introduces the auto-hide state machine scaffolding in VideoPlayer.tsx and retires the file-local warm-salmon design tokens. This is foundation-only — subscriptions + refs are in place but the behavioral surface (fade, catcher, buffering/error handling) lands in Units 2-7. - Add controlsVisible, controlsFocusable, status, hasError, isScreenReaderEnabled, isReduceMotionEnabled state. - Add revealFocusPending + errorFocusPending one-shot focus flags (I6), mirroring Fix #5's clear-after-render pattern. - Add stable handler refs (scheduleHideRef, revealControlsRef) so subscriptions in Units 2-3 don't churn the native event emitter. - Mirror controlsVisible and isScreenReaderEnabled into refs so the useTVEventHandler callback in Unit 3 can read without re-binding. - Subscribe to AccessibilityInfo screenReaderChanged and reduceMotionChanged (HomeHero subscription shape). - Subscribe to AppState: on 'active', snap visible + rearm timer. - Claim hardware Menu on tvOS via TVEventControl.enableTVMenuKey() with menuKeyEnabledRef bookkeeping so cleanup is idempotent. - Clear inactivityTimerRef on unmount. - Move hasTVPreferredFocus from back pill to play/pause ({shouldRequestFocus || revealFocusPending}); back pill now receives {errorFocusPending} for Unit 5's error-state focus. - Retire the file-local warm-salmon tokens (ACCENT, ACCENT_ON, TEXT_PRIMARY, TEXT_SECONDARY) and swap every call-site to the shared Crimson Gallery COLORS.* tokens. Play button + progress fill are now COLORS.primary; icons + title use COLORS.text; subtitle + time readouts use COLORS.muted. All eight existing numbered fixes (#4, #5, #6, #8, #9, #15, #24, #25) are preserved. Typecheck + lint clean. * feat(tv): wire video-player auto-hide fade + 5s inactivity timer Unit 2 of docs/plans/2026-04-22-001-feat-tv-video-player-auto-hide-controls-plan.md. Implements the core hide/reveal cycle that Unit 1 scaffolded. - Wrap topBar and controlsContainer in Animated.View, both bound to the shared opacityAnim; collapsable={false} preserves z-order above the Android TV VideoView surface. - Add hideControls: setControlsFocusable(false) before the animation so UIFocusEngine releases the controls before they're invisible (I7 ordering), then 150 ms ease-out fade (or opacityAnim.setValue(0) when reduce-motion is active), then flip controlsVisible to false. - Add scheduleHide: idempotent timer arm — clears any in-flight timer, then only re-arms a new 5 s if the state supports auto-hide (not paused, not loading/error, no hasError, no screen reader). - Add revealControls: early-return when already visible to neutralize the catcher-onPress vs useTVEventHandler-select double-dispatch race that Unit 3 introduces. Does NOT reset opacityAnim before animating to 1 — in-flight hide animations reverse from their current mid-fade value, avoiding a black flash. - Assign scheduleHide / revealControls into their stable refs so the AppState handler (Unit 1) and event handlers (Unit 3) see the latest closure — mirrors Fix #15's onDismissRef. - Modify playingChange listener: clear timer on pause, call scheduleHideRef on resume. First isPlaying=true arms the D1 initial 5 s countdown. - Add a 2 s mount fallback that calls scheduleHideRef unconditionally — covers the race where autoplay retry succeeds but playingChange hasn't fired yet. scheduleHide is idempotent so the normal path wins. - Every control Pressable (back, rewind, play/pause, forward) now reads focusable={controlsFocusable} and calls scheduleHide on onFocus; the three active controls also call scheduleHide on onPress after their original handler (satisfies D14). Typecheck + lint clean. * feat(tv): add D-pad catcher + event routing for hidden video-player chrome Unit 3 of docs/plans/2026-04-22-001-feat-tv-video-player-auto-hide-controls-plan.md. Closes the loop from Unit 2: hidden controls now have an owner for D-pad input, so any key reveals the chrome without firing the focused button's action. - Add useTVEventHandler with a ref-stable useCallback (reads controlsVisibleRef / isScreenReaderEnabledRef / revealControlsRef / scheduleHideRef) so the native TV emitter doesn't re-register on every render. Hidden state: any recognized event triggers reveal. Visible state: Siri-remote swipes reset the timer for D14 (arrow / Select events already reset via Pressable onFocus / onPress). Defensive whitelist-or-fallback on the event-type string — names vary across react-native-tvos versions and Siri-remote generations. - Add BackHandler.addEventListener('hardwareBackPress'). Returns true to consume. Hidden → revealControls. Visible → onDismissRef. react-native-tvos bridges tvOS hardware Menu into this event, so a single subscription covers both platforms. Paired with TVEventControl.enableTVMenuKey from Unit 1 to keep Expo Router's Stack from popping before our handler runs. - Render an invisible Pressable catcher as the first child of TVFocusGuideView when !controlsVisible && !isScreenReaderEnabled. StyleSheet.absoluteFillObject lifts it above the sibling flex layout; hasTVPreferredFocus claims focus on mount (fresh mount per hide cycle); onPress={revealControls} is the primary Select path. collapsable={false} preserves Android TV z-order above the VideoView surface. accessibilityLabel + accessibilityRole for App Store review. - revealControls' existing early-return on controlsVisibleRef.current already neutralizes the catcher-onPress vs useTVEventHandler-select double-dispatch race (see Unit 2). Typecheck + lint clean. * docs(tv): document U4 focus-restore mitigation choice on play/pause Pressable Unit 4 of docs/plans/2026-04-22-001-feat-tv-video-player-auto-hide-controls-plan.md. The focus-restore wiring itself was landed across Units 1 and 2: - U1 added revealFocusPending state + clearing useEffect + wired hasTVPreferredFocus={shouldRequestFocus || revealFocusPending} on play/pause. - U2 added setRevealFocusPending(true) inside revealControls(). The plan offered three mitigations for tvOS's continuously-mounted Pressable focus behaviour. We rely on mitigation (b) — per-cycle focusable toggle — because Unit 2 already flips controlsFocusable on every hide/reveal, producing a "new focus target" event for UIFocusEngine. This comment records the choice so device QA knows which path is in play and what the fallback is (mitigation (a): a per-cycle `key` on the Pressable). * feat(tv): handle video-player buffering + terminal playback error Unit 5 of docs/plans/2026-04-22-001-feat-tv-video-player-auto-hide-controls-plan.md. - Subscribe to player.addListener('statusChange', ...). Branches on the expo-video VideoPlayerStatus literal union — confirmed from node_modules as 'idle' | 'loading' | 'readyToPlay' | 'error' (there is no 'buffering' or 'playing' value). Uses controlsVisibleRef and seekTargetRef to avoid stale-closure reads inside the long-lived callback. - 'loading' + seekTargetRef.current !== null → no-op. Fix #4's seek guard already handles the UI; without this branch the timer would suspend (and force-reveal) on every 10 s skip press. - 'loading' + no seek → clear timer, force reveal if hidden. A hidden stall is indistinguishable from 'video ended' for a low-confidence user; showing chrome during buffering gives them an affordance. revealControls' early-return makes this a no-op if already visible. - 'readyToPlay' → scheduleHideRef to restart the 5 s countdown. - 'idle' → no-op (mount-time default, not a runtime transition). - 'error' → setHasError(true), clear the timer permanently, snap chrome visible, set errorFocusPending=true. hasError gates every subsequent scheduleHide call (added to U2's guard list). Back pill claims focus via hasTVPreferredFocus={errorFocusPending} (Unit 1). - Replace titleRow contents with 'Playback failed — press Back to exit.' when hasError. Inline, not a separate layer — preserves the single-file / single-overlay constraint. Subtitle stays for context. - Ghost rewind / play/pause / forward Pressables when hasError: focusable={controlsFocusable && !hasError} plus a new styles.controlDisabled (opacity 0.3) applied to each. The controls remain mounted so the spatial layout doesn't collapse — only the back pill is meaningful in the error state. Typecheck + lint clean. * feat(tv): flash chrome for one paint before video-player playToEnd dismiss Unit 6 of docs/plans/2026-04-22-001-feat-tv-video-player-auto-hide-controls-plan.md. - Modify the playToEnd listener so that when chrome is hidden at end-of-video, we synchronously snap it visible (setControlsVisible / setControlsFocusable / opacityAnim.setValue(1)) and dispatch onDismissRef via setTimeout(0). Intent is imperceptible technical continuity — the chrome appears for one paint so the dismiss transition doesn't start from a black-screen-with-no-UI. setTimeout(0) queues the dismiss after the current render commit; rAF's mapping to the native paint thread is less specified under react-native-tvos, so we avoid it here. Fallback to rAF if device QA shows a black frame on a hidden-to-dismissed transition. - Refactor to a doDismiss closure so both visible- and hidden-state paths share the Fix #24 try/catch wrapping. - Preserves I4a: playToEnd still always calls onDismissRef.current() — the new logic only delays the call by one tick when chrome was hidden, it never suppresses it. - Expand the comment on Unit 1's AppState handler to document the paused-state branch: scheduleHide's internal isPaused guard (U2) makes an explicit AppState-level guard unnecessary, which avoids re-subscribing AppState on every pause toggle. Also clarifies that playback resume behaviour is out of scope per the plan's deferred items. Typecheck + lint clean. * feat(tv): polish video-player accessibility for screen readers Unit 7 of docs/plans/2026-04-22-001-feat-tv-video-player-auto-hide-controls-plan.md. - Add a useEffect keyed on isScreenReaderEnabled that handles mid-session SR toggles. SR→on: reveal chrome if hidden (the user just gained access to controls they couldn't navigate) plus AccessibilityInfo.announceForAccessibility('Player controls visible') for audible confirmation. SR→off: rearm via scheduleHideRef so a passive viewer who briefly toggled VO on-then- off doesn't get stuck with permanent chrome. A srSeededRef guard skips the first invocation so the mount-time seed from AccessibilityInfo doesn't fire spurious side-effects. - Add accessibilityLabel + accessibilityRole='button' to every control Pressable: back: "Back to {subtitle}" when a subtitle is present, else "Back" rewind: "Rewind 10 seconds" play/pause: dynamic — "Play" when paused, "Pause" when playing forward: "Forward 10 seconds" The invisible catcher (Unit 3) already carries "Show player controls" + role=button. - Unit 3's catcher conditional render ({!controlsVisible && !isScreenReaderEnabled && ...}) means the catcher is NEVER rendered when a screen reader is active, so VoiceOver/TalkBack never encounters a blank interactive element. Plus U2's scheduleHide already bails on isScreenReaderEnabled, so auto-hide never fires while SR is on. Typecheck + lint clean. * fix(tv): stop video-player auto-hide loop + shorten inactivity to 3s Two fixes surfaced by manual QA on the Apple TV simulator: 1. Reveal-on-any-event was too aggressive. The hidden-state branch of useTVEventHandler triggered revealControls on ANY TV event, including the synthetic focus/blur/pan events that react-native-tvos fires when the engine reassigns focus. When the catcher mounted post-hide with hasTVPreferredFocus, the focus acquisition fired a synthetic event, the handler saw controlsVisibleRef.current === false, and called revealControls — producing an instant hide→reveal loop. Fix: strict-whitelist the eventType on the hidden-state branch. Only real directional (up/down/left/right), select, longSelect, and swipe* events count as user intent. Focus-change events are explicitly ignored. 2. Shorten the auto-hide inactivity period from 5000 ms → 3000 ms per user feedback (feels snappier during manual QA). Updated the three inline comments that cited the old value. * fix(tv): resolve 4 code-review P1 bugs in video-player auto-hide Discovered via /ce-code-review on the branch. All four break user- visible behaviour on playback transitions that manual QA on the first play cycle happened to miss. P1.1 — Stale-closure in scheduleHide broke resume-from-pause (adversarial 80, correctness+julik cross-corroborated). scheduleHide read isPaused/status/hasError from the render closure. After paused→ play, the onPress wrapper called scheduleHide synchronously before setIsPaused(false) committed, and the playingChange handler then called scheduleHideRef.current() also before the commit — both saw stale isPaused=true and bailed. Auto-hide never rearmed after any resume. Fix: mirror isPaused / status / hasError into refs (mirroring the existing controlsVisibleRef / isScreenReaderEnabledRef pattern). scheduleHide reads from refs. playingChange and statusChange handlers sync the relevant ref BEFORE calling scheduleHideRef so the guard reads the post-transition value. P1.2 — Animated.timing hide completion callback ran after state transitions (reliability rel-1 75, adversarial #4 60, julik-9 75). The callback unconditionally called setControlsVisible(false), which clobbered force-reveals triggered by error / AppState / unmount. Fix: capture the Animated.CompositeAnimation handle in hideAnimRef. Guard the completion callback with `if (finished)` — stopped animations carry finished=false. Call .stop() from revealControls, the error branch, the AppState handler, and the unmount cleanup so no stale completion can clobber a just-revealed chrome. P1.3 — 150 ms fade dead-zone dropped D-pad input (correctness 75, julik-1 75). During the fade, controls were non-focusable (set synchronously) but the catcher wasn't mounted yet (gated on !controlsVisible, which only flipped in the anim-completion). No focusable target → UIFocusEngine dropped input. Fix: gate the catcher on !controlsFocusable instead of !controlsVisible. The catcher mounts synchronously with the focusable flip, giving tvOS a valid focus target throughout the fade. P1.4 — AppState 'active' bypassed revealControls → orphaned focus (julik-5 75). The handler directly flipped state + opacity but never set revealFocusPending, so on foreground resume the catcher unmounted but play/pause had no hasTVPreferredFocus claim. Matches react- native-tvos #852. Fix: AppState handler now reads hasErrorRef and routes focus to the correct Pressable — errorFocusPending for error state (back pill), revealFocusPending for normal playback (play/pause). Also stops any in-flight hide animation so its completion doesn't clobber the force-reveal. Removed hasError from the effect's deps (reads via ref now — avoids AppState re-subscription on every hasError flip). Also includes P2.1 opportunistically: error branch of statusChange now explicitly setRevealFocusPending(false) so a same-tick reveal doesn't double-claim hasTVPreferredFocus against the back pill's errorFocusPending (adversarial #3 65 + julik-4 50). Typecheck + lint clean. * fix(tv): harden video-player listeners for late-emission + media keys Two more fixes from /ce-code-review P2 tier. P2.2 — useTVEventHandler whitelist excluded hardware media keys (julik-3 50, correctness 25). The strict whitelist that fixed the hide→reveal loop in 2fd092c (excluding focus/blur/pan synthetic events) also excluded playPause / fastForward / rewind events that some Android TV remotes and Siri remote gen-1 emit. Users pressing the physical play/pause button while chrome was hidden saw nothing happen. Fix: denylist the four synthetic focus events (focus, blur, pan, panBegin, panEnd) instead of whitelisting a closed set of known user inputs. Anything not synthetic is treated as intent. Also route media-key events in the visible branch to scheduleHideRef so they reset the timer (same D14 behaviour as Siri swipes). P2.3 — Late-emission listeners could setState on an unmounted component (reliability rel-2 75, rel-3 75). Fix #24's try/catch only catches a throwing onDismiss — it doesn't cover the "callback fires after React unmount" case. Risks surface during rapid mount/unmount (e.g. quickly backing out of a video and opening a new one) when expo-video's native emitter delivers a queued statusChange / playing Change / playToEnd AFTER subscription.remove() has run. Fix: new isMountedRef, flipped to false in the existing unmount useEffect cleanup. Early-returns added to the statusChange + playingChange listener bodies and to the playToEnd hidden-chrome setTimeout(0) callback, so late native emissions are silently ignored instead of calling setState / onDismiss on a dead tree. Typecheck + lint clean. * docs(solutions): capture TV video-overlay async-event patterns + refresh 3 related docs New learning (docs/solutions/design-patterns/): - rntvos-video-overlay-async-native-event-patterns-2026-04-23.md Four primary + two extension patterns extracted from the PR #830 code- review fix batch in apps/tv/src/components/VideoPlayer.tsx: ref-mirror + eager-sync for state-gated guards called from native callbacks; Animated.CompositeAnimation handle capture + if(finished) + .stop() on every transition; gate focus-trap catchers on synchronous focusability, not async visibility; set a one-shot hasTVPreferredFocus flag yourself on AppState foreground (react-native-tvos #852 workaround); denylist synthetic focus events in useTVEventHandler; isMountedRef for late- emission native callbacks. Cross-references added (ce-compound-refresh on 3 related best-practices docs — additions only, no rewrites): - react-native-tvos-porting-pitfalls-20260414.md: Related entry noting the new doc extends the pitfall catalog with async-event-vs-React-state issues that aren't covered by the six existing type/layout pitfalls. - playlist-video-player-sdui-mobile-20260409.md: inline "On TV, additionally" note in Section 5 (wasPlayingRef AppState guard) pointing to Pattern 4's one-shot hasTVPreferredFocus flag; Related entry for the TV companion. - tv-focus-driven-hero-patterns-20260420.md: Related entry linking Pattern 5's useTVEventHandler denylist as a complement to Section 6's onFocus-on-leaf rule. * style(docs): prettier --write for brainstorm + plan docs to satisfy CI CI's `format` job runs `prettier --check .` on the whole repo. The brainstorm + plan were written by the ce-brainstorm / ce-plan skills, which don't run prettier on *.md at write time. lint-staged only covers *.{ts,tsx} + package.json on commit, so the drift slipped past locally. Pure formatting; no content changes.
3 tasks
Ur-imazing
added a commit
that referenced
this pull request
May 7, 2026
…nit 3) (#902) * chore(admin): add seed-easter and run-experience-dump dev scripts - seed-easter: insert Easter experience into admin's local Postgres, translated block-for-block from apps/cms/src/bootstrap/seed-easter.ts to admin's BlockSchema. Local-dev parity with Strapi's Easter; UI/E2E fixture without depending on Strapi or the R3 dump pipeline. - run-experience-dump: workstation wrapper around the R3 experience-content-dump service. Bypasses the GraphQL trigger + workflow runtime for local dev. Mirrors run-embeds.ts pattern. Both are dev tooling alongside existing run-embeds, run-sync, pull-mapping. Neither ships to production runtime. * docs(brainstorms,plans): consumer-migration brief + Unit 3 dual-client plan Brief and plan that this PR implements: - docs/brainstorms/2026-05-05-consumer-migration-implementer-brief-requirements.md Web-implementer's brief for the Strapi to admin consumer-side migration. Captures the 7-unit migration shape, dual-client decision, parity-driven per-route flag rollout, and (as of 2026-05-07) the single-owner shape after tatai handed full execution + decision authority over. - docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md Implementation plan covering brief Unit 2 (admin SDL emit + drift CI) and Unit 3 (packages/graphql multi-schema codegen + adminGraphql factory + type-isolation test). All five technical decisions resolved inline (factory naming, schema-source convention, scalar mappings, CI shape, Turbo wiring). Implementation lands as the subsequent commits on this branch. * feat(admin): emit deterministic SDL via Pothos printSchema (U2) - apps/admin/src/scripts/print-schema.ts: emits admin's Pothos schema to apps/admin/schema.graphql via printSchema(lexicographicSortSchema( builder.toSchema())). Strips Pothos plugin directives (e.g. scope-auth's @authScopes) post-print so gql.tada's tsconfig parser can consume the SDL — auth is enforced at the resolver, not declared in the SDL surface consumers see. - apps/admin/schema.graphql: initial committed SDL artifact, 865 lines. Mirrors apps/cms/schema.graphql's role: the contract handoff between admin's TypeScript schema (Pothos) and packages/graphql's typed client (gql.tada). - apps/admin/package.json: adds schema:print script. - turbo.json: adds schema:print task with outputs declaration. Output verified byte-identical across consecutive runs (deterministic). The committed SDL is consumed by packages/graphql's gql.tada codegen in U3; consumers never introspect admin's running server (production introspection is disabled via @envelop/disable-introspection). Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md (U2). * fix(admin): exclude schema.graphql from prettier (U2) apps/cms/schema.graphql is already in .prettierignore for the same reason — Strapi's GraphQL plugin auto-emits canonical SDL, and prettier reformatting GraphQL docstrings (single-line to multi-line) breaks the re-generation byte-equality the schema:print drift CI check depends on. Apply the same exclusion to: - apps/admin/schema.graphql: emitted by schema:print - packages/graphql/src/admin-graphql-env.d.ts: will be emitted by gql.tada codegen in U3 (added preemptively to mirror the existing graphql-env.d.ts entry) Re-emitted apps/admin/schema.graphql to its canonical (un-prettied) shape so the drift CI check (next commit) reads zero. * ci(admin): add admin-schema-drift job (U2) Mirror the existing graphql-generate job (lines 78-95) for admin's SDL artifact. Job runs: pnpm turbo run schema:print --filter=@forge/admin git diff --exit-code apps/admin/schema.graphql Gated on @forge/admin appearing in the affected outputs from Turbo's --affected detection, so the job only fires on PRs that actually touch admin. Catches the case where a Pothos schema change is merged without regenerating apps/admin/schema.graphql, which would silently make consumer codegen wrong. Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md (U2). * feat(graphql): multi-schema codegen for Strapi + admin (U3) Configure packages/graphql for two introspection targets via gql.tada's multi-schema config shape — `schemas: [...]` array in tsconfig with per-entry name, schema path, and tadaOutputLocation. Each schema is emitted to its own .d.ts file with a structurally-distinct `name` property in the introspection type, which is what gives downstream factories compile-time type isolation (U5 verifies). Changes: - packages/graphql/tsconfig.json: switches the gql.tada plugin entry from single `schema` to `schemas: [{ name: 'strapi', ... }, { name: 'admin', ... }]`. - packages/graphql/package.json: adds @forge/admin: workspace:* to devDependencies, mirroring the existing @forge/cms entry. Codegen reads admin's committed SDL via the workspace-relative path; admin itself is not a runtime dependency. - packages/graphql/src/admin-graphql-env.d.ts: NEW. Generated admin introspection types (43KB). Committed alongside the source change per repo convention (graphql-env.d.ts is committed too). - packages/graphql/src/graphql-env.d.ts: regenerated against the upstream Strapi schema refresh that landed in main; behavior- preserving for existing callsites. - turbo.json: extends the existing `generate` task — adds apps/admin/schema.graphql to inputs (alongside apps/cms's) and src/admin-graphql-env.d.ts to outputs. NO dependsOn edge: matches the existing Strapi pattern where the SDL author runs schema:print manually and CI's admin-schema-drift job catches drift if they forget. dependsOn would re-build admin's full Pothos schema on every local generate run. - pnpm-lock.yaml: registers the new workspace link for @forge/admin. R4 verified: apps/web typecheck passes against the new dual-target codegen output. The adminGraphql() factory itself lands in U4. Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md (U3). * feat(graphql): adminGraphql factory + dual-client docs (U4) - packages/graphql/src/admin.ts: new factory module mirroring graphql.ts. Exports adminGraphql = initGraphQLTada<{ introspection }>() bound to admin's introspection types. Re-exports gql.tada's FragmentOf / ResultOf / VariablesOf as AdminFragmentOf / AdminResultOf / AdminVariablesOf so call sites visually distinguish admin types from Strapi types. - packages/graphql/src/index.ts: re-exports both factories side-by-side. Strapi side keeps bare names (graphql, ResultOf, ...); admin side carries the Admin* prefix. - packages/graphql/package.json: adds ./admin subpath export, parity with the existing ./graphql subpath. - packages/graphql/AGENTS.md, CLAUDE.md: rewritten to cover the dual-client conventions — which factory to use when, the auth-posture convention with the acknowledged compile-time-guard gap, the multi-schema generation flow (Strapi + admin), the U5 type-isolation test path as the AE1 enforcement mechanism, and the live-introspection rationale (admin disables it via @envelop/disable-introspection). The dual-client is temporary scaffolding. When Strapi is decommissioned and all consumer routes have moved to admin, this package collapses back to single-target admin (adminGraphql renames to graphql; the Strapi factory + types + graphql-env.d.ts get deleted in one PR). Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md (U4). * test(graphql): type-isolation test enforcing AE1 (U5) Compile-time enforcement that mixing Strapi-typed and admin-typed GraphQL result values fails TypeScript compilation (the AE1 origin acceptance criterion). The test runs as part of pnpm --filter @forge/graphql typecheck — no runtime test runner needed; it's a pure type-level check via @ts-expect-error directives. Two negative cases assert cross-schema assignment fails; two positive cases assert same-schema assignment compiles clean. If a directive is misplaced — missing from a negative case OR present on a positive case — typecheck fails by design, which is what makes the test meaningful (not a vacuous tautology). Query selection (critical): the two negative cases use queries against schema-EXCLUSIVE root fields — bibleBook(documentId) on Strapi (admin has no bibleBook root query) and experienceBySlug(locale, slug) on admin (Strapi has no experienceBySlug root query). The resulting ResultOf<...> shapes have NO overlapping property names, so structural typing rejects the cross-assignment. An inline file-header rule warns future contributors against substituting structurally-overlapping queries — that would silently make the directives unused and drop the real guard. Mutation-tested locally: deleting the @ts-expect-error on the negative case re-surfaces a real 'Property X is missing in type Y' error, confirming the directive is gating a real check. Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md (U5). * fix(graphql,admin): address review findings — Phase A safe_auto fixes Applied seven mechanical fixes from the multi-agent code review on this branch: - ci.yml: extend graphql-generate's git diff --exit-code to also check packages/graphql/src/admin-graphql-env.d.ts (review finding #1). Without this, an admin Pothos change that regenerates one .d.ts but not the other lands silently. - packages/graphql/src/admin.ts: re-export readFragment so the ./admin subpath surface matches ./graphql (review finding #3). readFragment is schema-agnostic in gql.tada — works for both factories. - apps/admin/src/scripts/print-schema.ts: anchor the output path to the script's own location via fileURLToPath(import.meta.url), not process.cwd() (review finding #5). Wrap writeFileSync in try/catch with structured stderr output and exit(1) on failure (review finding #7). cwd-based resolution silently misplaced schema.graphql when invoked outside pnpm/Turbo; an unhandled write error produced a confusing git-diff failure rather than a named cause. - turbo.json: declare explicit inputs on the schema:print task so it invalidates only on Pothos source / printer changes, not on every apps/admin file touch (review finding #8). Mirrors the existing generate task's inputs declaration. - apps/admin/package.json: wire run-experience-dump as a pnpm script alongside run-embeds, run-sync, etc. (review finding #12). The script was committed earlier without a discoverable invocation path. - CLAUDE.md (root): split the GraphQL Change Flow into separate Strapi and admin sub-flows (review finding #9). The single-flow version still documented only the Strapi path post-PR, so an agent reading root context wouldn't know schema:print exists. - apps/admin/AGENTS.md: add 'SDL emission for consumer codegen' and 'Local-dev scripts' sections so an agent scoped to the admin package can discover schema:print and the other tsx-script tools (review finding #9). Mirrors how packages/graphql/AGENTS.md was updated in the U4 commit. R4 verification: packages/graphql + apps/admin typecheck pass. schema:print output unchanged (drift-clean after script-relative path change, confirmed by repeated invocation). * fix(graphql,admin): address review findings — Phase B engineering decisions Three findings that needed engineering judgment, all from the multi-agent code review: - print-schema.ts (review finding #2): replace regex-based directive stripping with AST round-trip via graphql-js parse/visit/print. The regex had three concrete failure modes flagged across six reviewers: (a) [^)]* in @authScopes(...) substring strips DESCRIPTION text that legitimately contains the pattern, silently corrupting committed SDL; (b) only handled single-line directive declarations + a narrow two-line on continuation; (c) broke on nested parens in directive args. The AST visitor removes nodes by kind+name, structurally aware, with no formatting heuristics. Pothos directive set is a single POTHOS_DIRECTIVE_NAMES Set extended as new plugins land. apps/admin/schema.graphql regenerated against the new stripper — output is byte-deterministic across runs (verified twice). Output shrinks from 25419 to 25369 chars due to graphql-js print's canonical formatting (no semantic difference). - dual-client.types.ts (review finding #4 + #11): expand AE1 coverage and clarify what the test actually exercises. Adds positive + negative cross-assignment cases for AdminFragmentOf/FragmentOf and AdminVariablesOf/VariablesOf — the original test only covered ResultOf, but R1's 'both factories independently typed' invariant should hold across all three utility types. File header rewritten to be honest about the mechanism: structural distinctness via schema-exclusive fields, NOT nominal factory branding (the AdminResultOf / etc. aliases carry no nominal information beyond the underlying ResultOf; AdminResultOf<typeof STRAPI_QUERY> would compile cleanly today). Header also explicitly notes what the test does NOT exercise: factory-level query rejection (which gql.tada represents via an error variant of the document type and only surfaces at consumer call sites), and 'as' casts (which deliberately bypass the structural check; documented as an as-cast ban in AGENTS.md / CLAUDE.md with an ESLint rule as a follow-up). Mutation-tested: deleting any of the six @ts-expect-error directives in negative cases re-surfaces the underlying type error, confirming the directives gate real checks. Review finding #6 (eager imports of the full mutation graph at SDL emit time) is deferred — the architectural restructure of builder.ts to lazy-load services is out of scope for this fix pass. Current setup works because skipValidation: !!process.env.CI bypasses zod env validation in CI; documented as residual risk in the review report and surfaces if env validation tightens or local-dev friction increases. * docs(roadmap): feat-120 decouple admin SDL emit from runtime graph Defers review finding #6 from the multi-agent code review on feat/dual-client-codegen-unit-3 to a tracked roadmap ticket. The finding flags that print-schema.ts triggers Prisma client construction + env validation at SDL emit time via the side-effect import chain through @/graphql/schema. Current behavior is correct because: - CI sets CI=true, which triggers skipValidation: !!process.env.CI in apps/admin/src/config/env.ts:154 (zod skips). - Local dev contributors keep a populated .env via pnpm fetch-secrets. Two latent risks the ticket captures: 1. Future side-effect imports propagate. A new service that validates a third-party API key on module load gets triggered every time schema:print runs in CI, including in PRs unrelated to that service. 2. The skipValidation bypass is brittle. If env-validation policy tightens, the admin-schema-drift CI job breaks until the decoupling lands. The ticket sketches two candidate strategies (dedicated build-schema module vs conditional builder construction) and locks the verification criteria, including byte-identical SDL output and a regression test that asserts no Prisma client construction at module load. * docs(solutions): compound learning — dual-client gql.tada multi-schema pattern Captures the reusable architectural pattern surfaced by this PR's work: how to bridge a Pothos-defined TypeScript schema in one app to gql.tada-typed consumer code in other apps via a committed SDL artifact, including the six sub-decisions and one compile-time test that compose the pattern. Sub-decisions documented: 1. Multi-schema gql.tada config (`schemas: [...]` array with named entries; `name` discriminator is load-bearing for type isolation). 2. Two factories, one package (graphql + adminGraphql side-by-side; subpath exports for narrow imports; readFragment shared as schema-agnostic). 3. Committed SDL artifact via Pothos printSchema (lexicographicSortSchema for determinism; .prettierignore exclusion to avoid drift fights; script anchored via fileURLToPath, not process.cwd). 4. AST-based directive stripping via graphql-js parse/visit/print, NOT regex (regex has three concrete failure modes: description corruption, multi-line directive declarations, nested parens). 5. Inputs-based Turbo wiring, NOT dependsOn (avoids re-running the full Pothos schema build on every local generate; relies on schema-author discipline + CI drift gate). 6. Drift CI job mirroring graphql-generate (gated on Turbo affected detection; covers both directions: stale committed SDL AND missing regen of consumer .d.ts files). 7. Compile-time AE1 type-isolation test using @ts-expect-error on schema-exclusive selection sets, with three pitfalls called out: prose containing the literal pattern accidentally creates spurious directives (use block comments instead); typeof-only bindings need explicit eslint-disable in this repo's config; queries must select schema-exclusive fields or the structural distinctness mechanism doesn't fire. Cross-references three related docs (expo-graphql-schema-drift, mocked-shape-vs-real-contract-discipline, nextjs-route-shape-migration); follow-up cross-reference additions in those docs are noted as recommended next steps but deliberately not included in this commit to keep the doc-write atomic. Origin: PR #902 (this branch). Brief: docs/brainstorms/2026-05-05-... Plan: docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Kneesal
added a commit
that referenced
this pull request
May 12, 2026
* feat(admin): semantic-search eval harness Phase 1 (foundation)
First phase of the eval harness from
docs/plans/2026-05-07-001-feat-semantic-search-eval-harness-plan.md.
Lays the foundation modules every later unit compiles against:
- Hard-coded HARNESS_LOCALES (top 30 by Core labeledVideoCounts) +
QUICK_LOCALES + per-locale judge-confidence tier
- Shared types: Verdict (six values), Outcome (discriminated-union),
Fingerprint, Baseline, RunReport — re-exports admin's SearchResult
- Search client: GET /api/search wrapper with codepoint-safe snippet
truncation (200 cap), typed SearchClientError discriminating
rate_limited / validation / server_error / transport / timeout /
validation_failed
- Judge client: OpenRouter chat-completions wrapper with
response_format json_schema + Zod re-parse, 5xx/429/transport
retry honoring Retry-After (capped 30s), per-attempt 45s
AbortSignal.timeout. Default model anthropic/claude-haiku-4-5
override via OPENROUTER_JUDGE_MODEL
- Fingerprint reader: single combined prisma.$queryRaw for
video_scene_locale + video_transcript_chunk + experience_locale
with embedding-IS-NOT-NULL gates. compareFingerprints() returns
human-readable drift summary
- Env vars added to src/config/env.ts: OPENROUTER_JUDGE_MODEL,
EVAL_JUDGE_CONCURRENCY, EVAL_SEARCH_CONCURRENCY, ADMIN_BASE_URL
49 unit tests across 4 modules. Typecheck + lint clean.
Origin: docs/brainstorms/2026-05-06-semantic-search-eval-harness-requirements.md
* feat(admin): semantic-search eval harness Phase 2 (data inputs)
Adds the input-layer modules that feed the eval engine:
- query-generator.ts: corpus-blind synthetic query generation via
OpenRouter. createQueryGenerator() wraps a chat-completions call
with response_format json_schema + Zod re-parse, returns deduped/
trimmed query lists. createSyntheticQueryLoader() persists
generated queries to apps/admin/eval/synthetic-queries/{locale}.json
so re-runs use the same set against the same baseline.
Explicit regenerate command — never silent rebuild.
- regressions.ts: loader for apps/admin/eval/regressions.json.
Hand-edited entries: [{locale, query, notes, addedAt, addedBy}].
Zod-validated; allowMissing=true defaults to [] when no
regressions are authored yet. Friction-free compounding loop —
every bug found in the wild becomes a permanent test entry by
appending one JSON object.
- apps/admin/eval/regressions.json: starter file with a $schema-doc
comment header documenting the schema.
20 new unit tests (11 query-generator + 9 regressions). All 69
search-eval tests across 6 modules pass. Typecheck + lint clean.
* feat(admin): semantic-search eval harness Phase 3 (engine)
Adds the eval engine that composes all the foundation pieces:
- baseline.ts: load/save/getQueriesForRun/detectDrift. Atomic writes
via .tmp + rename so a crash mid-write never leaves a half-written
baseline. Zod-validated on both read and write so a save can never
produce a baseline that wouldn't load.
- calibration.ts + apps/admin/eval/calibration.json: 10 hand-labeled
cases (4 obvious-A-wins, 3 ties, 2 both-irrelevant, 1 prodigal-son).
runCalibration() returns CalibrationReport with passed/matched/
total + per-case detail. ≥80% match threshold; sub-threshold runs
emit `event=judge_calibration_failure` log line at warn level.
Judge errors count as case failures (don't abort).
- runner.ts: end-to-end orchestrator. Loads baseline → reads
fingerprint → detects drift → runs calibration → searches admin
for current results (p-limit search pool, default 4) → judges
pairwise with A/B swap (p-limit judge pool, default 8) → collapses
to win/loss/tie/both-irrelevant/judge-disagreement → aggregates
totals + per-locale + cost + snippet-improvement heuristic.
Search failures are logged as ties (run continues).
- types.ts: re-export Tier from locales.ts so consumers don't have
to know which file owns it.
40 new tests (13 baseline, 12 calibration, 15 runner). All 109
search-eval tests across 9 modules pass. Typecheck + lint clean.
* feat(admin): semantic-search eval harness Phases 4+5 (reporter + CLI)
Completes the harness end-to-end:
- reporter.ts: writeRunJson() persists per-run reports under
apps/admin/.tmp/eval/runs/{runId}.json. renderConsoleSummary()
produces the user-facing summary: header, headline net win rate,
drift warning (if any), calibration PASS/FAIL, per-locale table
sorted by |net win rate|, top-10 regressions sorted by judge
confidence (▼▼ = clearly, ▼ = slightly), snippet-improvement
caveat (if heuristic triggered), cost, JSON file path. 15 tests.
- eval-search.ts: thin CLI entry. Subcommands run | rebaseline
| regenerate-queries | calibrate. Mirrors run-embeds.ts shape
(lazy imports, JSON-line stdout logs, SIGINT/SIGTERM handler
with prisma disconnect, exit 130 on signal). pnpm scripts:
eval:search, eval:search:quick, eval:search:full,
eval:search:rebaseline, eval:search:regenerate-queries,
eval:search:calibrate.
- paths.ts: shared anchor module that resolves all default file
paths via import.meta.url instead of process.cwd(). Lets the
harness work whether invoked from repo root or apps/admin/.
- runner.ts: added baselineOverride injectable so tests can skip
the disk read.
15 reporter tests + CLI smoke tested. All 124 search-eval tests
pass across 10 modules. Typecheck + lint clean.
Harness is now runnable end-to-end. Next steps (post-merge):
1) eval:search:rebaseline to capture baseline against a stable admin
2) iterate on synthetic-query prompts per locale
3) empirically validate the --quick locale set
4) hand-tune calibration cases as drift appears
* docs(plan): mark eval-harness plan completed
* refactor(admin): apply ce-review safe_auto fixes to eval harness
Applies ~10 review findings from /ce:review (interactive mode,
gated_auto P1s deferred for human decision):
P2 #3 — Wire EVAL_JUDGE_CONCURRENCY + EVAL_SEARCH_CONCURRENCY through
the CLI to runEval (previously decorative). Add EVAL_GIT_SHA to env.ts
and replace process.env.GIT_SHA reads (AGENTS.md compliance).
P2 #4 — Remove dead code: RunEvalOptions.syntheticQueryLoader and
loadRegressionsImpl options + their imports were never read. Remove
baseline.ts getQueriesForRun + RunFilter (runner reimplements with
same logic). Delete the 4 corresponding tests.
P2 #5 — JSON-Schema vs Zod drift: judge.ts schema now declares
rationale.minLength=1, maxLength=1000 to match Zod. query-generator.ts
schema now declares minItems=1, maxItems=200, items.minLength=1,
items.maxLength=200 to match Zod, and Zod relaxed from .min(2) to
.min(1) to accept single-codepoint CJK queries.
P2 #6 — Extract shared SearchResultSchema, SearchResponseSchema,
FingerprintSchema into src/services/search-eval/schemas.ts. baseline.ts
and calibration.ts now import the canonical schema. Replace
search-client.ts hand-rolled isSearchResponse/isSearchResult with
SearchResponseSchema.safeParse — three representations of one contract
collapse to one source of truth.
P2 #7 — Widen CalibrationCase.locale from HarnessLocale to string
(operator-authored cases legitimately target out-of-set locales).
Drop the `as unknown as CalibrationCase[]` cast that papered over the
type mismatch.
P2 #8 — Add sanitizeForLog() in runner.ts that strips CR/LF/TAB. Apply
to query, locale, and error message in event=search_error log line +
event=fingerprint_drift_detected. Per the project's documented
log-injection-sanitizer learning.
P2 #9 — Add SQL invariant test in fingerprint.test.ts: asserts
WHERE embedding IS NOT NULL appears 6× and experience_locale uses
status='published'. Catches a refactor that drops the gates.
P2 #11 — Rebaseline now uses pLimit + Promise.allSettled for
loadOrGenerate fan-out across 30 locales. One transient OpenRouter
failure no longer aborts the whole batch.
P3 cleanups — Three `as Judge`/`as PrismaClient` casts in runner.ts
replaced with narrowing local consts + a readCurrentFingerprint helper.
Token accumulation now collected onto JudgeAttempts and reduced once
(removes shared mutable state across pLimit workers). reporter.ts
truncate() now codepoint-safe (Array.from). Type predicate replaces
broad cast in renderTopRegressions. SearchClientError code
"validation_failed" → "response_invalid" for clarity. saveBaseline
unlinks .tmp on rename failure. Add .strict() to JudgeResponseSchema.
Extract extractMessageContent + extractTokenCounts + safeReadBody to
shared openrouter-helpers.ts (was duplicated between judge.ts and
query-generator.ts). LOCALE_TIER and QUICK_LOCALES now use
`as const satisfies` for compile-time typo safety. CLI rejects
conflicting flag combinations (--quick + --locale, --full + --quick)
instead of silently picking one. Add the exact curl + jq refresh
command to HARNESS_LOCALES doc-comment. Add 2026-05-07 date stamp to
Haiku pricing constants.
Tests pass (121 search-eval, 1625 admin total). Typecheck + lint
clean. Deferred for human decision: P1 search-client rate-limit
retry, P1 path-traversal validation, P2 judgeDisagreements counter
double-count, P2 Promise.all → allSettled in runner, P2 search-error
synthetic-tie kind.
* fix(admin): apply ce-review P1 fixes (rate-limit retry + locale validation)
P1 #1 — Search-client now retries on 429 / 5xx / transport / timeout
to match judge.ts. Mirrors the same retry shape: max 3 attempts,
Retry-After honored capped at 30s, exponential backoff (500ms /
1s / 2s), per-attempt AbortSignal.timeout (45s default). Fixes the
documented rate-limit mismatch where a full run would have flooded
admin's 30/min cap and silently corrupted baseline data with empty
search results. 400 / 4xx other than 429 remain non-retryable.
Structured retry log: `event=search.retry attempt=N status=N wait_ms=N`.
Added 8 tests covering retry success on 5xx + 429, Retry-After
honoring + 30s cap, transport-error retry, no-retry-on-400, single-shot
mode, structured log assertion.
P1 #2 — Path-traversal guard for `--locale=<arg>`: query-generator
now validates locale against a strict BCP-47 regex before joining it
into a filename. `../foo`, `../../etc/passwd`, `en/..`, `en\foo`,
whitespace, control characters, and shell metacharacters all reject
with `QueryGeneratorError(code: "validation")` BEFORE any filesystem
op. Canonical forms (`en`, `pt-PT`, `zh-Hans`, `es-419`, `fil`) pass.
Added 4 tests including a regression for "never invokes the
generator when locale is invalid" — confirms the guard short-circuits
before any external work.
133 search-eval tests across 10 modules pass. Typecheck + lint clean.
Both P1s now closed; remaining gated_auto findings (P2 judgeDisagreements
counter, Promise.all → allSettled, search-error outcome kind) still
deferred for human decision.
* docs(solutions): capture external-client retry-parity learning from PR #922
Documents the bug class caught during /ce:review: when >=2 external
clients participate in the same runner fan-out (pLimit, Promise.all),
they MUST share the same retry contract. If one peer has retry and
another doesn't, the runner's per-item try/catch absorbs failures
silently, the run reports success with exit 0, and the persisted data
is corrupted with no loud error.
Concrete instance: search-client.ts had no retry on 429/5xx/transport
while sibling judge.ts had full retry. Both invoked under pLimit() from
runner.ts. Admin's /api/search is 30/min/IP rate-limited; default
search concurrency (4) on a 1500-query run = 40x over cap. Without
retry, ~98% of queries would have hit 429; runner records searchError,
judge evaluates empty current-results list, run exits 0 with thousands
of meaningless ties. Fix in commit 803af81.
Adds back-reference one-liners to two related docs:
- parallel-workflow-error-robustness-20260420.md (aggregation axis)
- bounded-parallelism-per-target-workflow-pattern-20260505.md (pLimit
primitive)
* docs(admin): add eval-harness operational docs
Two complementary surfaces, matching existing admin doc patterns:
apps/admin/CLAUDE.md — new "Semantic search eval harness" section
slotted between manager-enrichment trigger and "Common pitfalls".
Covers architecture (module map + one-paragraph data flow), the
HARNESS_LOCALES decision, full day-zero + day-N runbook with
commands, CLI exit codes, env vars, calibration semantics, drift
detection, and gotchas (read-only against admin, shared OpenRouter
key, retry parity invariant). Mirrors the R1-R5 section pattern.
apps/admin/eval/README.md — collocated quick-reference for anyone
poking around the eval/ directory. Explains each data file
(baselines/, synthetic-queries/, regressions.json, calibration.json),
how operators interact with each, what NOT to commit, and cost
guard rails per run mode.
Ur-imazing
added a commit
that referenced
this pull request
May 14, 2026
* docs(web): brainstorm + plan for series details page
Capture the WHAT (brainstorm requirements) and HOW (implementation plan)
for a new series details page in apps/web. Series-typed slugs currently
land on a single-video layout; the plan adds a /[slug]/[locale] branch
that renders a series page composed of three new components
(SeriesHero, SeriesEpisodesGrid, SeriesPageClient) plus one new
resolver and one new metadata helper.
Plan reflects ce-doc-review edits:
- Pre-implementation gate to lock the COLLECTION/SERIES discriminator
against real admin data before any code is written.
- Shared cache()-wrapped inner fetch consumed by both
resolveWatchVideoBySlug and resolveSeriesBySlug to avoid
double admin round-trips.
- hls (not muxVideo.playbackId) as the canonical playability
discriminator, matching the existing video page.
- Three design judgments deferred to implementation: static-hero
overlay legibility on bright posters, zero-children visual fallback,
episode-card hover state in a persistent grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): add resolveSeriesBySlug resolver (U1)
Add a sibling resolver to resolveWatchVideoBySlug that accepts records
the canonical resolver rejects (no playable variant). Reuses the
existing fetchWatchVideoBySlug HTTP helper so a series-without-trailer
slug never costs two admin round-trips.
Discriminator is intentionally defensive: case-insensitive match
against 'collection' / 'series' (covers both lowercase Strapi enum
values and uppercase admin enum values), with a children-presence
fallback when label is null. The U1 plan called for a verified
admin-data smoke check before locking a single value, but the prod
Strapi token in local .env is dev-only and introspection is disabled
on the public admin endpoint, so the gate resolution went to the
defensive OR path.
isSeriesRecord is exported so unit tests can exercise the
discriminator without standing up Apollo. The resolver itself is
tested at the route layer (U5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): add SeriesHero component (U2)
New component branches between trailer-mode (delegates to HeroPlayer)
and static-mode (sticky <Image> + scrim + overlay anchor + title
overlay). Both modes mount the same hero-player-overlay-anchor
testid so the title rides the body section's scroll the same way as
the video page.
Static mode mirrors HeroPlayer's sticky-top math (useLayoutEffect +
ResizeObserver + min(0px, calc(100svh - heroHeight))) and uses the
same scrim gradient as HeroPlayer:369 (from-black/80 via-black/40
to-transparent) for title legibility against arbitrary posters —
no new gradient definition, R16-compliant.
Playability discriminator is hls per Key Technical Decisions: a
variant with muxVideo.playbackId but no hls falls through to static
mode because <MuxPlayer> consumes hls for streaming.
alt="" on the static Image is intentional and documented in code: the
series title renders in the overlay immediately following the image
in DOM order (per R7), making the image decorative for screen readers.
Tests cover: trailer mode mounts HeroPlayer (AE1); static mode
renders image + scrim + overlay anchor with title/label (AE2);
hls-less variant falls through to static; missing-images renders the
black wrapper without a broken <img>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): add SeriesEpisodesGrid component (U3)
New grid composed of existing VideoCard instances in the verbatim
search-overlay column template (grid grid-cols-1 gap-5 sm:grid-cols-2
lg:grid-cols-3 xl:grid-cols-4 from SearchOverlay.tsx:263). Inline
unexported toSearchResult adapter projects each child onto the
SearchResult shape VideoCard consumes — six lines of pure data
shaping, blast radius scoped to this file. SearchResult type drift is
caught at compile time.
Custom hrefBuilder routes each click to /{episode-slug}/{locale} so
clicking through from the series page lands on the standard video
page for that episode. Locale is preserved from the URL the user is
viewing the series in.
VideoCard's existing hover state (scale + shadow) is inherited
unchanged — R16 forbids new visual primitives or new card variants;
the inherited hover is the YAGNI choice for v1.
Tests cover: 1-card-per-episode happy path; AE5 routing
(storyclubs-birth-of-jesus → /storyclubs-birth-of-jesus/en); locale
preservation across multiple cards; verbatim grid className; empty
children renders an empty grid; image-fallback through the
mobileCinematicHigh → thumbnail chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): add SeriesPageClient orchestrator (U4)
Composes SeriesHero + metadata block (label / title / share pill /
description) + SeriesEpisodesGrid + ShareModal. Modal state machine
narrowed to "none" | "share" — no download (no series-level download
concept), no language picker (locale comes from URL). Floating search
+ JFP logo are inherited from the root layout (verified: layout.tsx
mounts FloatingSearchProvider at line 31), so no per-page wrappers
are needed.
Pluralization rule from R8: N === 1 → "1 EPISODE"; N === 0 or N >= 2
→ "{N} EPISODES". Plural form covers the empty case naturally.
Focus management deferral resolved: the existing ShareModal uses
@base-ui/react/dialog (Base UI Dialog), which provides a built-in
focus trap, focus-on-open (first focusable inside the dialog), and
focus-return-on-close. No additional focus handling is needed in
SeriesPageClient.
Zero-children deferral resolved: render the page with an empty grid
(low-content-acceptable). Hero + metadata still provide value for
mid-population editor states. If product wants ExperienceEmpty
instead, the gate is one branch in this component.
Tests cover: AE4 plural (13 episodes); singular (1 episode); zero
("0 EPISODES"); modal closed by default; modal opens on share click;
modal close via callback; locale + episode count passthrough to
the grid; slug + title passthrough to the share modal; null title
renders empty H1; null description omits the description paragraph.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): wire route branching + add generateSeriesMetadata (U5)
Three-stage routing in /[slug]/[locale]/page.tsx:
1. resolveWatchVideoBySlug → if record is series-shaped (isSeriesRecord
true), render SeriesPageClient with the trailer variant.
2. Else if the same record is video-shaped, render the existing
WatchPageClient (unchanged behavior — regression-guarded by tests).
3. Else if resolveWatchVideoBySlug returned null (the series-without-
trailer case is filtered by the playableVariants guard),
resolveSeriesBySlug as a fallback → SeriesPageClient with
selectedVariant=null. Else fall through to resolveWatchPage.
generateMetadata mirrors the same branch and reuses
resolveWatchVideoBySlug — React's cache() (already wrapping the
resolver at content.ts:1025) dedupes the call between generateMetadata
and SlugLocalePage, so series detection adds zero admin round-trips on
the warm path. The series-without-trailer metadata path makes one
extra call via resolveSeriesBySlug, mirroring the body-render fallthrough.
generateSeriesMetadata in experience-metadata.ts mirrors
getWatchPageMetadata's shape but reads title/description/poster
directly from the series WatchVideoRecord — no Strapi experience lookup,
poster comes from resolvePosterUrl(series.images?.[0]). Both helpers
live in the same file so OG/canonical-URL changes naturally land in
both at once.
Tests cover: COLLECTION → SeriesPageClient (single round-trip — series
resolver not called); 'series' label → SeriesPageClient (defensive OR);
non-series label → WatchPageClient (regression guard); trailerless
series fallthrough → SeriesPageClient via resolveSeriesBySlug;
both-null fallthrough → ExperienceEmpty; selectedVariant prop
passthrough in both trailer-mode and static-mode rendering.
generateSeriesMetadata: title with TITLE_SUFFIX; description fallback
chain (description → snippet → undefined); canonical URL with /watch
prefix; OG image from poster + default fallback; noIndex flag;
title fallback when series.title is null.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(web): apply simplicity review tweaks to series page
Three small cleanups surfaced by ce-code-simplicity-reviewer:
1. Update the isSeriesRecord export comment — it claimed the helper was
exported only for tests, but page.tsx routing + generateMetadata
both consume it in production.
2. Drop the redundant `as SearchResult` cast in SeriesEpisodesGrid's
toSearchResult adapter. The object literal already conforms; the
cast was hiding the future-drift signal the comment promised.
3. Remove the unused playerRef + handlePlayerReady + onPlayerReady
plumbing from SeriesPageClient → SeriesHero. The series page has no
LanguagePickerModal (modal state is narrowed to "none" | "share")
so the lifted player ref had no consumer — HeroPlayer maintains its
own ref internally.
Skipped the deep-clone removal (5a) — the JSON.parse(JSON.stringify())
pattern in tryResolveSeriesBySlug has a documented RSC-boundary
serialization rationale, matching tryResolveWatchVideoBySlug at
content.ts:845.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(web): apply Tier 2 review safe_auto fixes (series page)
Two automatic fixes from the multi-persona code-review pass:
1. content.ts SERIES_LABEL_VALUES — annotate as `new Set<string>([...])`.
The widening for admin-uppercase labels is declared on the container
rather than buried in the String() cast at the call site. Without
this, the Set's inferred type was `Set<"collection" | "series">`
and a typo inside the Set would silently pass typecheck.
2. SeriesPageClient.tsx — delete the dead `export type
SeriesPageClientResolved = ResolvedSeriesBySlug` line. Grep
confirmed zero consumers anywhere in apps/web/src/.
The full review surface (13 residual findings, 4 pre-existing, 4
FYI) is captured at
/tmp/compound-engineering/ce-code-review/20260513-080741-8ab2b9a3/synthesis.md
and routed through the ce-work residual-work gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): apply Tier 2 P1 review fixes (series page)
Three concrete fixes from the multi-persona code review:
1. Wrap fetchWatchVideoBySlug in React cache() so resolveWatchVideoBySlug
and resolveSeriesBySlug dedupe to a single HTTP round-trip within
one RSC render. The cold trailerless-series path previously made
two sequential Strapi calls (each with its own 10 s AbortSignal
budget) before falling through to resolveWatchPage — unstable_cache
around the outer resolvers doesn't dedupe across them because each
has its own cache-key namespace.
2. Wrap the resolver calls inside generateMetadata in try/catch and
fall through to getWatchPageMetadata. Without the guard, a
transient Apollo or GraphQL error caused Next to silently drop
metadata for the 60s revalidate window — page body kept rendering
via its own error boundary, but SEO/OG tags vanished.
3. Filter null/empty slugs in SeriesEpisodesGrid before mapping to
VideoCard. Without the filter, an editor-published episode with a
missing slug produced a card whose href resolves to "//<locale>" —
a protocol-relative URL the browser interprets as
"https://<locale>/", producing a broken off-site navigation.
Deferred from the same review pass:
- #3 (defensive OR discriminator hijacks null-label records) — plan
made the tradeoff explicitly; reconsider once admin data is queryable.
- #5, #10–12 (testing gaps) — follow-up PRs.
- #6 (`as` casts in test fixtures), #7 (title overflow), #8 (typed
error class), #9 (testid rename), #13 (variant strip) — P2/P3 polish.
Synthesis: /tmp/compound-engineering/ce-code-review/20260513-080741-8ab2b9a3/synthesis.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: mark series-page plan completed
* chore(web): series page polish + in-flight language selector
Pre-merge checkpoint. Will likely be partially reverted after main is
merged in if main provides a shared language selector component.
- HeroPlayer darkenOverlay + overlay slot props
- SeriesHero overlay passthrough
- SeriesPageClient hero overlay (label/title/share band)
- ShareModal tab visibility (hidden when no embed) + tighter padding
- In-flight language selector using native select
* feat(web): series page polish — hero band, language combobox, padding
Iterative styling pass on the series details page after merging main's
language switcher infrastructure.
Hero overlay
- Stack the label on top, then a horizontal row with title (left) +
share pill (right) using items-baseline for typographic alignment
(centers were 7px off; baselines now align within 3px).
- Drop whitespace-nowrap on the title so long names wrap instead of
clipping off-screen at large viewports (Tier 2 review's P2 #7).
- Increase overlay pb to pb-8 md:pb-10 so the band sits higher in the
hero with breathing room below it, INSIDE the hero region.
Meta section (description + language combobox)
- Move from md:grid-cols-3 (description col-span-2 / languages 1/3) to
md:grid-cols-4 (description col-span-3 / languages 1/4) so the
description fills more of the row and the combobox column narrows.
- Bump section z-index to z-30 so the LanguageCombobox popover floats
above the episode grid below (both were z-20; later-sibling grid
was painting on top).
- Add bg-stone-900 + horizontal padding so the section visually covers
the sticky hero as the body scrolls — without it, the still-pinned
hero bleeds through the transparent meta section.
- Reduce vertical padding from pt-20/pt-32 (way too much) to pt-10
md:pt-12 — the breathing room moved into the hero overlay's pb.
Language combobox
- Aggregate language options across `series.children[*].variants`
(the series record itself has no variants — they live on each
episode). Dedupe by language slug.
- Resolve URL locale to slug via a bcp47 → slug map built alongside
the options list, so /storyclubs/en correctly defaults to "English"
in the combobox rather than the first-alphabetical "Abkhaz".
- Use writePreferredLanguageSlug on change so the cookie persists
the choice (matches the watch page's behavior).
WatchVideo fragment
- Widen children projection with a minimal `variants { published, hls,
language { slug, name, bcp47 } }` set. Series-page-only data; the
SiblingCarousel that also reads children doesn't touch variants.
Adds ~50 bytes per child variant to the watch-page payload, which
is acceptable for a ~13-episode parent like StoryClubs.
Episode grid wrapper
- Add bg-stone-900 + horizontal padding to cover the sticky hero
(same rationale as the meta section).
Tests
- SeriesPageClient mock: stub useRouter, LanguageCombobox, and
writePreferredLanguageSlug so the suite doesn't need the
app-router context or cookie infrastructure.
- Test fixtures updated for the children-variants projection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): polish series details page + apply review fixes
Major changes:
- useReducer crossfade backdrop in SeriesEpisodesGrid (kills closure
stale-read race; RESET_TO_POSTER writes both slots atomically)
- Extract resolveEpisodeImageUrl shared helper (apps/web/src/lib/episode-image.ts)
- Single-pass series.children traversal in SeriesPageClient with
Map<slug, projection> for canonical dedup
- Globe button + LanguagePickerModal modal-state on series page
- LanguagePickerModal gains kind: "video" | "series" prop; series path
drops ?t / autoplay=1 from the language-switch href
- rawLocale pass-through for slug-form locales (spanish-castilian
now reaches SeriesPageClient unchanged instead of falling back to "en")
- Focus + pointer event delegation on the grid container via
data-backdrop-url (kills tab-through strobe + per-render arrow wrappers)
- Split pan-zoom layer 2 into animated wrapper + image inner div so
URL swaps don't reset the keyframe timeline
- New SeriesEpisodeCard component (16:9 thumbnail, runtime pill,
Episode N label, alt-text fallback)
- Static-blur+pan-zoom backdrop ported from core/apps/watch's
SectionVideoGrid pattern
Tests added:
- SeriesEpisodeCard.test.tsx (25 tests; formatRuntime boundaries,
pickRuntimeSeconds filtering, image fallback priority, href, alt)
- SeriesPageClient globe-button visibility + language-modal state
+ variantsForLanguagePicker dedup invariant
- SeriesEpisodesGrid crossfade reducer state-machine
- page-routing slug-form locale regression for rawLocale fix
- LanguagePickerModal kind: video|series branches
Deferred to follow-up:
- Mobile pointer:fine guard (delegation made the cost effectively zero)
- Empty-series UX when seriesPosterUrl is null (product decision)
- LanguagePickerModal in-flight navigation race (modal-internal scope)
- Globe button vs HeroPlayer globe coordination (design decision)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: prettier --write brainstorm doc
CI format check flagged docs/brainstorms/2026-05-12-series-details-page-requirements.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(solutions): capture series-page rawLocale fix + refresh route-shape contract-drift doc
New ui-bugs/ learning: series page silently normalized slug-form locales
(spanish-castilian, etc.) to DEFAULT_LOCALE through isLocale(), displaying
"English" despite the URL being correct. Second occurrence of the same bug
shape (first was on feat/web-video-language-switcher / PR #936).
Refresh of nextjs-route-shape-migration-cross-cutting-contract-drift-20260430.md:
- Note that isLocale-parity between proxy and page is necessary but not
sufficient when the route accepts both bcp47 and slug-form locales
- Update the isWatchRoute code example to show the current union
(isLocale(last) || PREFERRED_LANG_SLUG.test(last))
- Cross-link the new ui-bugs/ doc as a worked recurrence
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps actions/github-script from 7 to 8.
Release notes
Sourced from actions/github-script's releases.
... (truncated)
Commits
ed59741Merge pull request #653 from actions/sneha-krip/readme-for-v82dc352eBold minimum Actions Runner version in README01e118cUpdate README for Node 24 runtime requirements8b222acApply suggestion from@salmanmkcadc0eeaREADME for updating actions/github-script from v7 to v820fe497Merge pull request #637 from actions/node24e7b7f22update licenses2c81ba0Update Node.js version support to 24.xDependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting
@dependabot rebase.Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
@dependabot rebasewill rebase this PR@dependabot recreatewill recreate this PR, overwriting any edits that have been made to it@dependabot show <dependency name> ignore conditionswill show all of the ignore conditions of the specified dependency@dependabot ignore this major versionwill close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this minor versionwill close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this dependencywill close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)