From 3b75ec9b3df3831b7fe5702c68cf213f91633733 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:38:33 -0400 Subject: [PATCH 1/4] Animate player display inactive transitions and death state Player display now tracks displayParticipants separately from raw encounter participants so active->inactive can animate out before removal, and inactive-> active can animate in. This preserves 1-list order, keeps DM display behavior unchanged, and avoids fading dying/stable participants. PlayerParticipantCard handles transitions: - active->inactive: scale/slide/fade out, then parent removes from display list - inactive->active: scale/slide/fade in - alive->dead: keep visible with dim/desaturate/red-rim death cue + skull pulse - dying/stable: full visible, no fade/removal DisplayView inactive characterization updated to wait for exit animation before asserting removal. TODO item removed. --- TODO.md | 12 -- src/App.js | 105 ++++++++++++++++-- .../DisplayView.characterization.test.js | 5 +- 3 files changed, 97 insertions(+), 25 deletions(-) diff --git a/TODO.md b/TODO.md index ea91014..0ce3028 100644 --- a/TODO.md +++ b/TODO.md @@ -11,18 +11,6 @@ not sure good way to do this -### FEAT: player display fade transitions for inactive state -- Inactive is DM-triggered via Mark Inactive. -- Player display should fade inactive participant out, then remove from display list. -- Reactivating should fade participant in. -- DM display keeps inactive participant visible. -- Dead state does not imply inactive/disabled. -- Dying/stable must not fade out or leave layout holes. -- Dead can keep skull/Dead label; create some good visual cues, no removal - but a cool transition to death would be nice. - - - - ### TEST GAP: current branch changes need focused coverage - Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`. diff --git a/src/App.js b/src/App.js index f871e2d..a58a731 100644 --- a/src/App.js +++ b/src/App.js @@ -2554,6 +2554,61 @@ function AdminView({ userId }) { // DISPLAY VIEW COMPONENT (Player View) // ============================================================================ +// Player participant card: animate state transitions. +// mount/activate : animate in (opacity+scale+slide) +// deactivate : animate out then signal exit +// alive→dead : transition to death cue (dim+desaturate+skull pulse+red rim) +// conscious/dying/stable: full visible, no fade +function PlayerParticipantCard({ id, isActive, status, onExit, className, children, divRef }) { + // phase: 'hidden' (opacity-0, start state) -> 'visible' (opacity-100) + // disable: 'visible' -> 'exiting' (opacity-0, transition plays) -> onExit + const [phase, setPhase] = useState('hidden'); + const isDead = status === 'dead'; + + useEffect(() => { + if (!isActive) { + // ensure current 'visible' frame painted, THEN flip to exiting so + // browser sees opacity change and plays the fade-out. + const raf = requestAnimationFrame(() => { + setPhase('exiting'); + }); + const t = setTimeout(() => onExit(id), 1000); + return () => { cancelAnimationFrame(raf); clearTimeout(t); }; + } + // enable/reactivate: paint hidden frame first, THEN flip to visible + // so browser sees opacity change and plays the transition. + setPhase('hidden'); + const raf = requestAnimationFrame(() => { + requestAnimationFrame(() => setPhase('visible')); + }); + return () => cancelAnimationFrame(raf); + }, [isActive, id, onExit]); + + // animate: opacity + scale + slide. Visible motion, not just fade. + const fadeClass = phase === 'visible' + ? 'opacity-100 scale-100 translate-y-0' + : 'opacity-0 scale-75 translate-y-4'; + + // death transition: fires once when alive→dead. dim + desaturate + red rim. + const deathClass = isDead + ? 'brightness-50 saturate-0 border-2 border-red-900/70 shadow-red-900/50 shadow-2xl' + : ''; + + return ( +
+ {children} + {isDead && ( +
+ ☠️ +
+ )} +
+ ); +} + function DisplayView() { const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument( getPath.activeDisplay() @@ -2566,9 +2621,37 @@ function DisplayView() { const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [wakeLockEnabled, setWakeLockEnabled] = useState(false); + const [displayParticipants, setDisplayParticipants] = useState([]); const wakeLockRef = useRef(null); const currentParticipantRef = useRef(null); + // Player display transition state. Active participants render normally. + // Active→inactive: keep prior card, mark __displayActive=false, animate out, + // then PlayerParticipantCard calls handleExit to remove from display list. + // Inactive→active: card reappears with __displayActive=true and fades in. + useEffect(() => { + const source = (activeEncounterData && activeEncounterData.participants) || []; + setDisplayParticipants(prev => { + const prevById = new Map(prev.map(p => [p.id, p])); + const out = []; + for (const p of source) { + const prevP = prevById.get(p.id); + if (p.isActive !== false) { + out.push({ ...p, __displayActive: true }); + } else if (prevP && prevP.__displayActive !== false) { + out.push({ ...p, __displayActive: false }); + } else if (prevP && prevP.__displayActive === false) { + out.push(prevP); + } + } + return out; + }); + }, [activeEncounterData]); + + const handleExit = useCallback((id) => { + setDisplayParticipants(prev => prev.filter(p => p.id !== id)); + }, []); + useEffect(() => { const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFsChange); @@ -2706,13 +2789,9 @@ function DisplayView() { const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hideNpcHp = activeDisplayData?.hideNpcHp ?? false; - let participantsToRender = []; - if (participants) { - // 1-list model: participants[] IS the display order (DM drag = source of - // truth). Do NOT re-sort by initiative — that diverges from AdminView / - // turnOrderIds after any cross-init drag (BUG-15). - participantsToRender = participants.filter(p => p.isActive !== false); - } + // 1-list model: displayParticipants IS the display order (participants[] order + // plus temporary exiting cards). Do NOT re-sort by initiative. + const participantsToRender = displayParticipants; const displayStyles = campaignBackgroundUrl ? { @@ -2783,10 +2862,14 @@ function DisplayView() { } return ( -

(Inactive)

)} -

+ ); })}
diff --git a/src/tests/DisplayView.characterization.test.js b/src/tests/DisplayView.characterization.test.js index 842b096..c367bca 100644 --- a/src/tests/DisplayView.characterization.test.js +++ b/src/tests/DisplayView.characterization.test.js @@ -94,7 +94,7 @@ describe('DisplayView characterization', () => { expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0); }); - test('DisplayView hides inactive participants for all types', async () => { + test('DisplayView hides inactive participants for all types after fade-out', async () => { seedActiveDisplay([ { ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true }, { ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false }, @@ -103,7 +103,8 @@ describe('DisplayView characterization', () => { render(); await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument()); - expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(); + // inactive held during exit animation, removed after transition + await waitFor(() => expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(), { timeout: 1500 }); expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument(); }); }); -- 2.34.1 From 9159755846f7bf6cde8dcdc716969666a7626224 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:48:10 -0400 Subject: [PATCH 2/4] Scope player card transitions: ring/shadow fast, fade/transform slow transition-all duration-1000 on PlayerParticipantCard wrapper slowed current- turn ring/border animation. Turn pointer visibly laggy at 400ms turn intervals. Fix: scoped transition properties. opacity/transform/filter/border-color = 1s for fade/death animations. box-shadow = 300ms for current-turn ring highlight, so turn pointer snaps between participants while fades stay smooth. --- src/App.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.js b/src/App.js index 14674e1..b52f429 100644 --- a/src/App.js +++ b/src/App.js @@ -2597,7 +2597,7 @@ function PlayerParticipantCard({ id, isActive, status, onExit, className, childr return (
{children} {isDead && ( -- 2.34.1 From 835a4663f7a3f3f7871927e40870e1028f6844e8 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:22:19 -0400 Subject: [PATCH 3/4] Docker build cache: .dockerignore, cache mount, layer reorder, restart script - .dockerignore: exclude node_modules/.git/tmp/data/logs - Dockerfile: BuildKit cache mount for npm, --omit=dev at install, drop npm prune step - Reorder layers: shared/src/frontend copy before build, server copy + better-sqlite3 rebuild after. Server/shared changes no longer invalidate frontend build layer unless shared (FE dep) changes - Remove duplicate shared COPY in runtime stage (bundled in FE build) - docker/restart.sh: stop + rebuild + start in one script --- .dockerignore | 11 ++++ TODO.md | 149 ---------------------------------------------- docker/Dockerfile | 19 +++--- docker/restart.sh | 21 +++++++ server/index.js | 1 + 5 files changed, 43 insertions(+), 158 deletions(-) create mode 100644 .dockerignore create mode 100755 docker/restart.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aba797c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +**/node_modules +.git +tmp +data +*.log +npm-debug.log* +.DS_Store +.env* +coverage +.nyc_output diff --git a/TODO.md b/TODO.md index 0ce3028..08c4517 100644 --- a/TODO.md +++ b/TODO.md @@ -65,25 +65,6 @@ not sure good way to do this - Ambiguous label. May expand work based on what NPC means here (ally? monster? display-only? skip in turn order?). Clarify intent before UX changes. -### FEAT: first-class undo/redo UI buttons (B --- do now) -- Toolbar buttons ↶/↷ in AdminView header, not buried in /logs. -- Undo = revert latest non-undone log. Redo = re-apply latest undone. -- Uses current 2-write undo (non-tx). Race safety = log refactor later. -- Disabled when stack empty. Keyboard shortcuts (cmd+z / cmd+shift+z). - -### FEAT-LOG: unified log refactor (was FEAT-2 + M6, batched) -- Single event schema, one source of truth: - `{ ts, type, payload, undo_payload, undone, encounterId, - snapshot:{ round, currentTurnParticipantId, turnOrderIds, activeIds } }` -- Common format consumed by: UI log view, download/copy export, - replay-combat, analyze-turns. One shape, four consumers. -- Transactional undo: server endpoint `POST /api/undo/:eventId`. Single - SQLite tx applies undo_payload + flips `undone`. Replaces fragile 2-write - (log update + encounter update as separate calls). -- Download/copy: exports event stream as JSON for offline analysis. -- replay-combat + analyze-turns rewritten to emit/consume same event shape. -- Migration: keep old log entries readable; new format for new writes. - ### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later) @@ -95,133 +76,3 @@ not sure good way to do this ## FEAT - clarify what end encounter does and what initiatives reset means ## Done (history) - -### FEAT: first-class undo/redo UI buttons (DONE) -- ↶/↷ pills in InitiativeControls, always visible when encounter open. -- Undo = latest non-undone log (per encounter). Redo = latest undone. -- encounterPath added to all 14 log contexts (filter key). -- redo:patch (forward) added to undoData. Real redo replays forward state. -- Disabled when stack empty. Tooltip shows target action. -- Uses current 2-write undo (non-tx). Race safety = FEAT-LOG refactor. - -### Architecture: 1-list turn order model (slot, never sort) -- Single source: turnOrderIds === participants.map(id). No re-sort after - startEncounter. nextTurn skips inactive (predicate), inactive stay in slot. -- Drag (reorder) = same-init tie-break only. Cross-init blocked. -- startEncounter sorts ALL participants by init once, then frozen. -- addParticipant/updateParticipant slot by init (slotIndexForInit), preserve - drag order. Display renders participants[] directly (no sort). -- Static guard test errs if `.sort(` reintroduced outside allowlist. -- Design doc: docs/INITIATIVE_ORDERING.md. - -### Single source of truth: combat logic -- All 15 App.js handlers delegate to @ttrpg/shared. ~498 lines inline dupes deleted. -- shared/turn.js = only place turn logic lives. - -### Storage parity (firebase + server adapters) -- Neutral queryConstraints ({__type:'orderBy'|'limit'}) honored by both adapters. -- Shared contract test runs both identically. Memory adapter deleted; factory - throws on unknown mode. -- ws storage mode renamed to server (env var + adapter name). - -### Logging contract -- Every mutating op logs message + undo payload. No-op = null log. -- Structural enforcement: per-op contract test (turn.logging.test.js) + - static source-scan guard (static.no-unlogged.test.js). - -### Custom conditions per campaign (DONE) -- Freeform per-campaign conditions. Add applies to participant + persists to - campaign palette in one step. Badge render uses merged allConditions - (built-ins + custom). toggleCondition accepts any string. Dedup - case-insensitive. maxLength 40. Combat + replay tests prove arbitrary - string ids survive round-trip. - -### Death saves: D&D 5e status model (DONE) -- `status` is source of truth: conscious, dying, stable, dead. -- Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP. -- Death-save actions: Success, Fail, Nat1, Nat20, Stabilize. -- Revive: dead → 0 HP, stable/unconscious, active. -- Dead characters/NPCs stay in encounter/initiative until DM removes or marks inactive. -- Player display hides inactive participants; DM display keeps them visible. - -### FEAT-3: initiative first-class entry (DONE) -- Initiative field at add-char, add-monster, edit participant. -- Inline edit wired. Tie-break = drag order. - -### UI feedback: toast + info modal (DONE) -- All 23 native alert() replaced. ToastStack (6s auto-dismiss + manual X) - for transient failures. InfoModal (persistent OK) for validations. - React context provider wraps all 3 App branches. -- Fixed: native alert vanished instantly on browser focus loss. - -### Filter dup chars from add-participant dropdown (DONE) -- Character dropdown excludes chars already in encounter. Prevents - dup-add at source. No more dup alert path needed. - -### Test timeouts (DONE) -- jest.setTimeout(10000) in setupTests.js (CRA blocks config-level timeout). - -### Warning = failure in tests (DONE) -- console.error/warn throw in test env. - -### BUG-1: addParticipant + pause/resume corrupts rotation -- RESOLVED as side effect of BUG-2 fix. - -### BUG-2: addParticipant allows duplicate id -- FIXED (addParticipant throws on dup id). - -### BUG-4: hide-player-HP breaks display view -- FIXED --- mock honors setDoc{merge}, all 5 activeDisplay sites use merge. - -### BUG-5: mid-round addParticipant/revive corrupts rotation -- FIXED --- slot-array + DRY advance core nextActiveAfter. - -### BUG-6: reorderParticipants doesn't update turnOrderIds -- FIXED structurally by 1-list model. - -### BUG-7: reorderParticipants not logged -- FIXED --- returns log:{message, undo}. Handler calls logAction. deathSave, - addParticipants, updateParticipant logging gaps also closed. - -### BUG-8: server adapter has no reconnect -- FIXED --- onclose reconnects + re-subscribes existing paths. - -### BUG-10: deact+reactivate same round double-acts participant -- FIXED --- 1-list model keeps slot position on toggle. Reactivate does not - grant second turn. Test: turn.bug10.test.js. - -### BUG-11: FE Combat.scenario test crashes -- FIXED --- moved to shared/turn.combat.test.js, pure functions, 100 rounds. - -### BUG-12: campaign selection follows activeDisplay -- FIXED. - -### BUG-13: reorderParticipants crossing current pointer = ambiguous -- FIXED --- block cross-pointer reorder during active encounter (both dirs). - Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double. - Pre-combat: free reorder. Test: turn.bug13.test.js. - -### BUG-14: addParticipant init-insertion breaks after drag-reorder -- FIXED --- slotIndexForInit scans current list (post-drag aware). - -### BUG-15: DisplayView re-sorts (drag order not preserved) -- FIXED --- display renders participants[] directly. - -### BUG-16: subscribeCollection hook drops queryConstraints -- FIXED --- neutral builders, both adapters honor orderBy/limit. - -### BUG-17: dead SDK imports in App.js -- FIXED --- trimmed (auth + getFirestore + getStorage remain). - -### BUG-18: stale comments reference deleted memory adapter -- FIXED. - -### FEAT-1: Dead characters/NPCs stay in turn order -- DONE --- character/NPC death does not auto-remove. DM controls inactive/remove. -- Non-NPC monsters still become inactive automatically at death. - -### combat.scenario 100 rounds not turns -- DONE --- loops by actual round-wrap count. - -### feat: add all characters to participants list -- DONE --- addParticipants bulk add wired. diff --git a/docker/Dockerfile b/docker/Dockerfile index 2b75ad1..14d0f5b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,5 @@ -# docker/Dockerfile — single container: caddy (front) + node (back). +# syntax=docker/dockerfile:1 +# docker/Dockerfile --- single container: caddy (front) + node (back). # Build context = repo root. # ---- build stage: frontend + install backend deps ---- FROM node:18-alpine AS build @@ -7,25 +8,26 @@ WORKDIR /app COPY package*.json ./ COPY shared/package.json ./shared/ COPY server/package.json ./server/ -RUN npm install --include-workspace-root +RUN --mount=type=cache,target=/root/.npm \ + npm install --include-workspace-root --omit=dev --prefer-offline +# frontend code (changes often) COPY shared/ ./shared/ -COPY server/ ./server/ COPY src/ ./src/ COPY public/ ./public/ COPY tailwind.config.js postcss.config.js ./ -# better-sqlite3 native build (alpine musl) -RUN cd server && npm rebuild better-sqlite3 - # build frontend (server storage, same-origin /api + /ws via caddy) ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default ENV REACT_APP_STORAGE=server ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID RUN NODE_OPTIONS=--openssl-legacy-provider npm run build -# prune backend dev deps for runtime -RUN npm prune --omit=dev +# server after backend - it's fast. so changing server only doesnt invalidate slow frontend. +COPY server/ ./server/ +# better-sqlite3 native build (alpine musl). Cached if server unchanged. +RUN cd server && npm rebuild better-sqlite3 + # ---- runtime stage: caddy + node ---- FROM node:18-alpine @@ -38,7 +40,6 @@ COPY --from=build /app/server/node_modules ./server/node_modules COPY --from=build /app/package*.json ./ COPY --from=build /app/shared/package.json ./shared/ COPY --from=build /app/server/package.json ./server/ -COPY shared/ ./shared/ COPY server/ ./server/ # built frontend served by caddy COPY --from=build /app/build /srv diff --git a/docker/restart.sh b/docker/restart.sh new file mode 100755 index 0000000..d9d236d --- /dev/null +++ b/docker/restart.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# scripts/docker-restart.sh — rebuild + restart docker container. +# Usage: ./scripts/docker-restart.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "=== stopping existing container ===" +docker compose -f docker/docker-compose.yml down 2>/dev/null || true + +echo "=== rebuilding image ===" +docker compose -f docker/docker-compose.yml build + +echo "=== starting container ===" +docker compose -f docker/docker-compose.yml up -d + +echo "=== status ===" +docker compose -f docker/docker-compose.yml ps + +echo "" +echo "app: http://localhost:${PORT:-8080}" +echo "logs: docker compose -f docker/docker-compose.yml logs -f" diff --git a/server/index.js b/server/index.js index cb9c8c7..ce81cbc 100644 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,7 @@ // server/index.js — generic KV document store over HTTP + WebSocket. // firebase mirror: doc-tree model. Thin REST, path-based WS push. // Adapter (src/storage/server.js) = passthrough, no shape translation. +// TEST: cache layer rebuild check. 'use strict'; -- 2.34.1 From 02344eb3f57f1e4513d1e443f078ae3ec5d04e97 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:49:47 -0400 Subject: [PATCH 4/4] Bump Docker base images to node 22; mock firestore comment cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Dockerfiles (root firebase-mode + docker/ caddy) bumped node:18-alpine to node:22-alpine. better-sqlite3 v12 (from PR #6) requires node 20+; node 18 builds fail with node-gyp/python errors. openssl-legacy-provider flag kept for react-scripts 5 compat. .dockerignore: removed .env* exclusion so .env.local COPY works in firebase image build. mock firestore applyConstraints comment: offset handled by adapter (firebase.js slices), mock never sees it — clarified dead-code reasoning. --- .dockerignore | 1 - Dockerfile | 4 ++-- TODO.md | 3 +++ docker/Dockerfile | 4 ++-- src/__mocks__/firebase/firestore.js | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.dockerignore b/.dockerignore index aba797c..4f7bee5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,5 @@ data *.log npm-debug.log* .DS_Store -.env* coverage .nyc_output diff --git a/Dockerfile b/Dockerfile index 415e43a..35d0d9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Dockerfile # Stage 1: Build the React application -FROM node:18-alpine AS build +FROM node:22-alpine AS build LABEL stage="build-local-testing" @@ -48,4 +48,4 @@ COPY nginx-docker.conf /etc/nginx/conf.d/default.conf EXPOSE 80 # Start Nginx when the container launches -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/TODO.md b/TODO.md index 08c4517..529c687 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,9 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. not sure good way to do this +### npm install warnings cleanup pass +lots of updates + ### TEST GAP: current branch changes need focused coverage diff --git a/docker/Dockerfile b/docker/Dockerfile index 14d0f5b..53b4971 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ # docker/Dockerfile --- single container: caddy (front) + node (back). # Build context = repo root. # ---- build stage: frontend + install backend deps ---- -FROM node:18-alpine AS build +FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ @@ -30,7 +30,7 @@ RUN cd server && npm rebuild better-sqlite3 # ---- runtime stage: caddy + node ---- -FROM node:18-alpine +FROM node:22-alpine RUN apk add --no-cache caddy WORKDIR /app diff --git a/src/__mocks__/firebase/firestore.js b/src/__mocks__/firebase/firestore.js index 9ba0654..e4ceb8a 100644 --- a/src/__mocks__/firebase/firestore.js +++ b/src/__mocks__/firebase/firestore.js @@ -105,9 +105,9 @@ export function onSnapshot(refOrQuery, onSuccess, onError) { return unsub; } -// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs. -// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit -// supported (App's LOG_QUERY uses exactly these). +// Apply Firestore-style query constraints (orderBy desc/asc, limit, where) +// to mock docs. Mirrors real SDK semantics for contract tests. Offset handled +// by adapter (firebase.js slices) — mock never sees it. function applyConstraints(docs, constraints) { let out = [...docs]; for (const c of constraints) { -- 2.34.1