Skip to content

chore(deps): Bump actions/github-script from 7 to 8#8

Merged
tataihono merged 1 commit into
mainfrom
dependabot/github_actions/actions/github-script-8
Feb 16, 2026
Merged

chore(deps): Bump actions/github-script from 7 to 8#8
tataihono merged 1 commit into
mainfrom
dependabot/github_actions/actions/github-script-8

Conversation

@dependabot
Copy link
Copy Markdown
Contributor

@dependabot dependabot Bot commented on behalf of github Feb 15, 2026

Bumps actions/github-script from 7 to 8.

Release notes

Sourced from actions/github-script's releases.

v8.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

New Contributors

Full Changelog: actions/github-script@v7.1.0...v8.0.0

v7.1.0

What's Changed

New Contributors

Full Changelog: actions/github-script@v7...v7.1.0

... (truncated)

Commits

Dependabot compatibility score

Dependabot 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 rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will 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 version will 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 dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)

@dependabot dependabot Bot added dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code labels Feb 15, 2026
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>
@dependabot dependabot Bot force-pushed the dependabot/github_actions/actions/github-script-8 branch from 6d50033 to 1671ac3 Compare February 16, 2026 21:16
@tataihono tataihono merged commit 4be49c6 into main Feb 16, 2026
12 checks passed
@tataihono tataihono deleted the dependabot/github_actions/actions/github-script-8 branch February 16, 2026 23:56
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>
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.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant