From 3872b0258ec429ac6992e07468d4381cd80ac2a4 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:14:53 -0700 Subject: [PATCH 001/105] admin report list item carries thumbnailUrl --- .changeset/admin-report-list-thumbnail.md | 7 +++++++ packages/shared/src/schemas/admin/reports.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/admin-report-list-thumbnail.md diff --git a/.changeset/admin-report-list-thumbnail.md b/.changeset/admin-report-list-thumbnail.md new file mode 100644 index 00000000..8bcae58e --- /dev/null +++ b/.changeset/admin-report-list-thumbnail.md @@ -0,0 +1,7 @@ +--- +"@civfix/shared": minor +--- + +Admin report list rows carry a presigned `thumbnailUrl` + +`AdminReportListItemDTO` gains `thumbnailUrl`: a presigned preview of the report's first ready image asset (the pipeline thumbnail when one exists, else the served image), so the admin reports list can render the report's own photo instead of the category pin. Nullable and defaulted to `null`, so a report with no usable image and a response from a server that does not yet send the field both parse unchanged. diff --git a/packages/shared/src/schemas/admin/reports.ts b/packages/shared/src/schemas/admin/reports.ts index 4095f9e7..b2e440bd 100644 --- a/packages/shared/src/schemas/admin/reports.ts +++ b/packages/shared/src/schemas/admin/reports.ts @@ -109,7 +109,10 @@ export type ReportMedia = z.infer /** * A report list row. `status` is the civfix report status; `flagged` is the orthogonal abuse marker. * `confirmations` is the count of report_follows. `submitted` carries both the relative and absolute - * timestamp. `hasPhoto` drives the "photo attached" affordance without sending media on the list. + * timestamp. `hasPhoto` drives the "photo attached" affordance without sending media on the list; + * `thumbnailUrl` is a presigned preview of the report's first ready image (the pipeline thumbnail when + * one exists, else the served image), so a row can show the photo in place of the category pin. It is + * null for a report with no usable image, and defaulted so an older server's response still parses. */ export const AdminReportListItemDTOSchema = z .object({ @@ -125,6 +128,7 @@ export const AdminReportListItemDTOSchema = z coords: AdminCoordsSchema, address: z.string(), hasPhoto: z.boolean(), + thumbnailUrl: z.string().nullable().default(null), }) .strict() export type AdminReportListItemDTO = z.infer From 2d596704f073cfe303c402a49e79d26163179390 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:21:22 -0700 Subject: [PATCH 002/105] feed ranking contract: scored cursor, counts endpoint, realtime topics, FEED_RANKING schema --- .changeset/feed-ranking-contract.md | 18 ++ packages/shared/DECISIONS.md | 69 ++++++ packages/shared/__tests__/client.test.ts | 2 +- .../shared/__tests__/host-schemas.test.ts | 4 +- packages/shared/src/client/endpoints.ts | 11 + .../shared/src/schemas/__tests__/feed.test.ts | 204 ++++++++++++++++++ packages/shared/src/schemas/posts.ts | 95 +++++++- packages/shared/src/types/ws.ts | 9 +- 8 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 .changeset/feed-ranking-contract.md create mode 100644 packages/shared/src/schemas/__tests__/feed.test.ts diff --git a/.changeset/feed-ranking-contract.md b/.changeset/feed-ranking-contract.md new file mode 100644 index 00000000..69a6da0d --- /dev/null +++ b/.changeset/feed-ranking-contract.md @@ -0,0 +1,18 @@ +--- +"@civfix/shared": minor +--- + +Feed ranking contract: scored cursor, counts endpoint, realtime topics, `FEED_RANKING` schema + +`GET /feed/home` keeps its method, path, query and response schemas; its `nextCursor` may now be a +ranked `"|"` cursor alongside the legacy `"|"` one, and the contract +owns the codec (`FeedScoreCursorSchema`, `formatFeedScoreCursor`, `parseFeedScoreCursor`, +`quantizeFeedScore`, `isAfterFeedScoreCursor`, `FEED_SCORE_CURSOR_PRECISION`) so both forms stay +unambiguous and the continuation predicate has one definition. + +New `getFeedCounts` (`POST /feed/counts`, auth required, no CSRF) takes up to +`FEED_COUNTS_MAX_IDS` post ids and returns counts only, with unreadable ids simply absent — +registry 318 → 319. `SignalTopicSchema` gains `feed` and `feed_counts` with `UserSignalSchema` +unchanged, so a stale client drops the new frames instead of failing. `FeedRankingConfigSchema` / +`FeedRankingConfig` / `DEFAULT_FEED_RANKING` add the strict, fully defaulted 27-knob ranking +profile that the backend loads from a JSON `FEED_RANKING` env var. All additive; see DECISIONS §47. diff --git a/packages/shared/DECISIONS.md b/packages/shared/DECISIONS.md index fd755a27..abb5ca86 100644 --- a/packages/shared/DECISIONS.md +++ b/packages/shared/DECISIONS.md @@ -1508,3 +1508,72 @@ the org-payouts amendment inside §33. Amended: §23 (`donationOrg` gone from th growth list, `/orgs/by-slug/:slug/donate` gone, `HostCapability` count), §33 and §34 (the `manage_payments` / `view_donations` capability lines, the donations eligibility gate) and §36 (the insights `money` block). + +## 47. The home feed is ranked, not reverse-chronological (0.49.0) + +`GET /feed/home` keeps its name, method, path, query schema (`HomeFeedQuerySchema`) and response +schema (`FeedPageDTO`). What changes is the ORDER of `items` and the MEANING of the opaque +`nextCursor`: the server now scores every candidate with a linear affinity model (follow / self / +mention / verified-org author / attached event / attached report / image / graded proximity, plus +log-scaled likes, replies and reposts), multiplies by a floored recency half-life, a seen discount +and Twitter's author-diversity discount, and orders `score DESC, id DESC`. Reverse-chron survives +as the legacy path, not as the default. + +**The cursor carries both forms, and that is what makes this additive.** A ranked continuation is +`"|"` — the same two-part shape as the existing near cursor — with the score +quantised to `FEED_SCORE_CURSOR_PRECISION` (6) decimal places so a float comparison against the +cursor is exact. The contract owns the codec (`FeedScoreCursorSchema`, `formatFeedScoreCursor`, +`parseFeedScoreCursor`, `quantizeFeedScore`, `isAfterFeedScoreCursor`) so the server, its tests and +any future consumer read one definition of the continuation predicate +(`score < cursorScore OR (score === cursorScore AND id < cursorId)`). A numeric first segment and +an ISO first segment are unambiguously distinguishable, so the server dispatches: a score cursor +continues the ranked set, an ISO `"|"` cursor continues the legacy chronological +query, anything else is a first page. A TestFlight build in the field holding an old cursor keeps +paginating correctly, and the chronological statement stays tested because it is also the rollback +lever if the ranker misbehaves on staging. + +**The ranking weights are a contract schema with an env-var carrier.** `FeedRankingConfigSchema` is +`.strict()` with all 27 knobs defaulted, and `DEFAULT_FEED_RANKING` is `FeedRankingConfigSchema.parse({})`. +The backend carries an override as one JSON-valued `FEED_RANKING` env var (the precedent is +`FCM_SERVICE_ACCOUNT_JSON`; 27 separate vars would be 27 passes through the four-place env +propagation rule), unset meaning the full default profile and a partial object merging onto it. +`.strict()` makes a typo'd knob a named boot failure rather than a silently ignored setting. The +schema lives here rather than in the backend so that the validator, the ranges and the documented +defaults are shared by the server, its unit tests and any future operator UI that tunes them — +tuning a weight is then a deploy, never a client release. There is no settings table and no +write-config endpoint in this version; a Redis override layer behind two operator-plane endpoints +is designed and deliberately deferred. + +**`getFeedCounts` is a POST that changes nothing (the converse of §17).** `POST /feed/counts`, +`auth: "required"`, `csrf: false`, `v1`, request `FeedCountsRequestSchema` +(`{ postIds }`, 1–`FEED_COUNTS_MAX_IDS` (100) ids, `.strict()`), response +`FeedCountsResponseSchema` (`{ items: [{ id, counts }] }`). §17 said a state-changing endpoint may +not be a GET; this is the other direction — a read whose input is up to 100 UUIDs (~3.7 kB of +query string) takes a body. It is exempt from CSRF deliberately: CSRF protects against forced +WRITES, and this handler performs none. Authorization is by omission — an id that is deleted, +non-public or blocked in either direction against the caller is simply absent from `items`, never a +404 and never an error, so the endpoint cannot be used to probe post existence. It returns counts +and nothing else: no author, body, media or viewer state. Registry 318 → 319. + +**`SignalTopic` grows by `feed` and `feed_counts`.** `UserSignalSchema` is unchanged — a signal +still carries only `{ topic, id? }`, so the `UserChannel` seam's invariant (a frame names a topic +and an optional scoping id, never entity data; the client refetches authoritative state) holds and +no authorization decision moves to the client. `feed` means "a post you would plausibly be served +now exists" and drives the new-posts pill; `feed_counts` means "engagement changed on a post you +are currently being served" and drives a debounced batch read of `getFeedCounts`. Growing the enum +is safe for a client on 0.48.x precisely because `handleRawFrame` `safeParse`s every frame and +DROPS what it cannot parse — an old client silently ignores both topics, which is correct, because +it has no pill to update. Both publishes are fire-and-forget and both are skipped entirely when no +`UserChannel` is wired. + +**"Verified poster" means verified ORG AFFILIATION.** The issue asked to weight a verified poster; +there is no user-level verified flag in this contract, because §34 retired the verified neighbour +in 0.43.0 and replaced it with organization affiliation. `orgVerifiedWeight` therefore applies when +the author's primary affiliation is to a verified organization, or when the post is published as a +verified organization. The operator-granted `user_moderation.report_verified` state stays admin-only +and out of the ranker; surfacing it would need a new `PersonDTO` field and a privacy review. + +**Delivery set (§4.2, no consumer left behind).** civfix-backend `services/api` AND +`services/media-worker`, civfix-admin, civfix-govt-web. `apps/community-web`, +`apps/community-mobile` and `packages/ui` are workspace consumers and move in the same commit +series. The backend must update `test/unit/route-coverage.test.ts` to 319. diff --git a/packages/shared/__tests__/client.test.ts b/packages/shared/__tests__/client.test.ts index c85ca43b..8ff8079a 100644 --- a/packages/shared/__tests__/client.test.ts +++ b/packages/shared/__tests__/client.test.ts @@ -22,7 +22,7 @@ function jsonResponse( describe("endpoint registry", () => { it("covers the full Phase 1 + admin surface with unique paths per method", () => { const names = Object.keys(endpoints) - expect(names.length).toBe(318) + expect(names.length).toBe(319) const seen = new Set() for (const name of names) { const e = endpoints[name as keyof typeof endpoints] diff --git a/packages/shared/__tests__/host-schemas.test.ts b/packages/shared/__tests__/host-schemas.test.ts index 1a1a9d16..f2b6b14a 100644 --- a/packages/shared/__tests__/host-schemas.test.ts +++ b/packages/shared/__tests__/host-schemas.test.ts @@ -129,7 +129,7 @@ describe("host platform enum tuples (mirrored byte-identical by the backend)", ( ]) }) - it("keeps event_broadcast, event_team_invite then org_invite at the tail of NotificationType and host LAST in SignalTopic", () => { + it("keeps event_broadcast, event_team_invite then org_invite at the tail of NotificationType and the feed topics LAST in SignalTopic", () => { expect(NotificationTypeSchema.options.at(-3)).toBe("event_broadcast") expect(NotificationTypeSchema.options.at(-2)).toBe("event_team_invite") expect(NotificationTypeSchema.options.at(-1)).toBe("org_invite") @@ -138,6 +138,8 @@ describe("host platform enum tuples (mirrored byte-identical by the backend)", ( "threads", "reports", "host", + "feed", + "feed_counts", ]) }) diff --git a/packages/shared/src/client/endpoints.ts b/packages/shared/src/client/endpoints.ts index dbe388b1..aa7ddc4f 100644 --- a/packages/shared/src/client/endpoints.ts +++ b/packages/shared/src/client/endpoints.ts @@ -160,6 +160,8 @@ import { PostRepliesQuerySchema, UserPostsQuerySchema, HomeFeedResponseSchema, + FeedCountsRequestSchema, + FeedCountsResponseSchema, ListUserPostsResponseSchema, ListSavesResponseSchema, } from "../schemas/posts.js" @@ -1447,6 +1449,15 @@ export const coreEndpoints = { csrf: false, version: "v1", }), + getFeedCounts: def({ + method: "POST", + path: "/feed/counts", + request: FeedCountsRequestSchema, + response: FeedCountsResponseSchema, + auth: "required", + csrf: false, + version: "v1", + }), listUserPosts: def({ method: "GET", path: "/people/:id/posts", diff --git a/packages/shared/src/schemas/__tests__/feed.test.ts b/packages/shared/src/schemas/__tests__/feed.test.ts new file mode 100644 index 00000000..fd512a2e --- /dev/null +++ b/packages/shared/src/schemas/__tests__/feed.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from "vitest" +import { + DEFAULT_FEED_RANKING, + FEED_COUNTS_MAX_IDS, + FEED_SCORE_CURSOR_PRECISION, + FeedCountsRequestSchema, + FeedCountsResponseSchema, + FeedRankingConfigSchema, + FeedScoreCursorSchema, + formatFeedScoreCursor, + isAfterFeedScoreCursor, + parseFeedScoreCursor, + quantizeFeedScore, +} from "../posts.js" +import { SignalTopicSchema, UserSignalSchema, WsServerMessageSchema } from "../../types/ws.js" +import { endpoints } from "../../client/endpoints.js" + +const UUID_A = "11111111-1111-1111-1111-111111111111" +const UUID_B = "22222222-2222-2222-2222-222222222222" + +describe("FeedRankingConfigSchema", () => { + it("parses an empty object into the full default profile", () => { + const parsed = FeedRankingConfigSchema.parse({}) + expect(parsed).toEqual(DEFAULT_FEED_RANKING) + expect(Object.keys(parsed)).toHaveLength(27) + }) + + it("carries the documented default weights", () => { + expect(DEFAULT_FEED_RANKING).toEqual({ + baseWeight: 10, + followWeight: 100, + selfWeight: 60, + mentionWeight: 40, + nearbyWeight: 50, + nearbyRadiusKm: 40, + orgVerifiedWeight: 30, + attachEventWeight: 30, + attachReportWeight: 30, + imageWeight: 5, + likeWeight: 3, + replyWeight: 8, + repostWeight: 5, + halfLifeHours: 36, + decayFloor: 0.15, + diversityFloor: 0.25, + diversityDecay: 0.5, + seenDiscount: 0.7, + minScore: 12, + minPageItems: 5, + candidateWindowDays: 30, + candidateCap: 400, + clockBucketSeconds: 60, + snapshotTtlSeconds: 180, + servedTtlSeconds: 900, + viewerFanoutMax: 500, + newPostFanoutMax: 1000, + }) + }) + + it("merges a partial override onto the defaults", () => { + const parsed = FeedRankingConfigSchema.parse({ halfLifeHours: 12, followWeight: 70 }) + expect(parsed.halfLifeHours).toBe(12) + expect(parsed.followWeight).toBe(70) + expect(parsed.minScore).toBe(DEFAULT_FEED_RANKING.minScore) + }) + + it("rejects an unknown key rather than ignoring it", () => { + const res = FeedRankingConfigSchema.safeParse({ halfLifeHors: 12 }) + expect(res.success).toBe(false) + }) + + it("rejects out-of-range values", () => { + expect(FeedRankingConfigSchema.safeParse({ decayFloor: 1.5 }).success).toBe(false) + expect(FeedRankingConfigSchema.safeParse({ halfLifeHours: 0 }).success).toBe(false) + expect(FeedRankingConfigSchema.safeParse({ candidateCap: 10 }).success).toBe(false) + expect(FeedRankingConfigSchema.safeParse({ minPageItems: 2.5 }).success).toBe(false) + expect(FeedRankingConfigSchema.safeParse({ followWeight: -1 }).success).toBe(false) + }) +}) + +describe("feed score cursor", () => { + it("round-trips a score and a post id", () => { + const encoded = formatFeedScoreCursor({ score: 115.9, postId: UUID_A }) + expect(encoded).toBe(`115.900000|${UUID_A}`) + expect(parseFeedScoreCursor(encoded)).toEqual({ score: 115.9, postId: UUID_A }) + }) + + it("quantises to six decimal places before encoding", () => { + expect(FEED_SCORE_CURSOR_PRECISION).toBe(6) + expect(quantizeFeedScore(13.12345678)).toBe(13.123457) + const encoded = formatFeedScoreCursor({ score: 13.12345678, postId: UUID_A }) + expect(encoded).toBe(`13.123457|${UUID_A}`) + expect(parseFeedScoreCursor(encoded)?.score).toBe(quantizeFeedScore(13.12345678)) + }) + + it("round-trips a zero score", () => { + const encoded = formatFeedScoreCursor({ score: 0, postId: UUID_B }) + expect(parseFeedScoreCursor(encoded)).toEqual({ score: 0, postId: UUID_B }) + }) + + it("does not claim a legacy ISO time cursor", () => { + expect(parseFeedScoreCursor(`2026-09-14T10:00:00.000Z|${UUID_A}`)).toBeNull() + }) + + it("returns null for anything that is not a score cursor", () => { + expect(parseFeedScoreCursor(null)).toBeNull() + expect(parseFeedScoreCursor(undefined)).toBeNull() + expect(parseFeedScoreCursor("")).toBeNull() + expect(parseFeedScoreCursor("12.5")).toBeNull() + expect(parseFeedScoreCursor(`12.5|not-a-uuid-at-all-not-a-uuid-at-all`)).toBeNull() + expect(parseFeedScoreCursor(`-12.5|${UUID_A}`)).toBeNull() + }) + + it("rejects a negative score at the schema boundary", () => { + expect(FeedScoreCursorSchema.safeParse({ score: -1, postId: UUID_A }).success).toBe(false) + expect( + FeedScoreCursorSchema.safeParse({ score: 1, postId: UUID_A, extra: true }).success, + ).toBe(false) + }) + + it("orders by score descending, then by post id descending", () => { + const cursor = { score: 50, postId: UUID_B } + expect(isAfterFeedScoreCursor({ score: 49.9, postId: UUID_A }, cursor)).toBe(true) + expect(isAfterFeedScoreCursor({ score: 50.1, postId: UUID_A }, cursor)).toBe(false) + expect(isAfterFeedScoreCursor({ score: 50, postId: UUID_A }, cursor)).toBe(true) + expect(isAfterFeedScoreCursor(cursor, cursor)).toBe(false) + }) +}) + +describe("feed counts contract", () => { + it("accepts a bounded batch of post ids", () => { + expect(FeedCountsRequestSchema.parse({ postIds: [UUID_A, UUID_B] })).toEqual({ + postIds: [UUID_A, UUID_B], + }) + }) + + it("rejects an empty, oversized or unknown-keyed request", () => { + expect(FeedCountsRequestSchema.safeParse({ postIds: [] }).success).toBe(false) + expect( + FeedCountsRequestSchema.safeParse({ + postIds: Array.from({ length: FEED_COUNTS_MAX_IDS + 1 }, () => UUID_A), + }).success, + ).toBe(false) + expect(FeedCountsRequestSchema.safeParse({ postIds: [UUID_A], all: true }).success).toBe(false) + }) + + it("parses a counts-only response", () => { + const parsed = FeedCountsResponseSchema.parse({ + items: [{ id: UUID_A, counts: { likes: 3, reposts: 1, replies: 2, saves: 0 } }], + }) + expect(parsed.items[0]?.counts.likes).toBe(3) + }) + + it("allows an empty item list so unreadable ids can simply be absent", () => { + expect(FeedCountsResponseSchema.parse({ items: [] }).items).toEqual([]) + }) + + it("is registered as an authenticated POST on /feed/counts", () => { + expect(endpoints.getFeedCounts.method).toBe("POST") + expect(endpoints.getFeedCounts.path).toBe("/feed/counts") + expect(endpoints.getFeedCounts.auth).toBe("required") + expect(endpoints.getFeedCounts.csrf).toBe(false) + expect(endpoints.getFeedCounts.version).toBe("v1") + }) + + it("leaves the home feed endpoint untouched", () => { + expect(endpoints.homeFeed.method).toBe("GET") + expect(endpoints.homeFeed.path).toBe("/feed/home") + expect(endpoints.homeFeed.auth).toBe("optional") + }) +}) + +describe("SignalTopicSchema", () => { + it("carries the six topics in order", () => { + expect(SignalTopicSchema.options).toEqual([ + "notifications", + "threads", + "reports", + "host", + "feed", + "feed_counts", + ]) + }) + + it("rejects an unknown topic", () => { + expect(SignalTopicSchema.safeParse("feedcounts").success).toBe(false) + }) + + it("carries a post id in the existing scoping slot and nothing more", () => { + expect(UserSignalSchema.parse({ topic: "feed", id: UUID_A })).toEqual({ + topic: "feed", + id: UUID_A, + }) + expect(UserSignalSchema.parse({ topic: "feed" })).toEqual({ topic: "feed" }) + expect( + UserSignalSchema.safeParse({ topic: "feed_counts", id: UUID_A, counts: {} }).success, + ).toBe(false) + }) + + it("round-trips as a server signal frame", () => { + const frame = WsServerMessageSchema.parse({ type: "signal", topic: "feed_counts", id: UUID_A }) + expect(frame).toEqual({ type: "signal", topic: "feed_counts", id: UUID_A }) + }) +}) diff --git a/packages/shared/src/schemas/posts.ts b/packages/shared/src/schemas/posts.ts index 31d3229f..325a7970 100644 --- a/packages/shared/src/schemas/posts.ts +++ b/packages/shared/src/schemas/posts.ts @@ -1,6 +1,6 @@ import { z } from "zod" import { IdSchema, PaginationQuerySchema, pageResponse } from "./common.js" -import { PostDTOSchema, PostKindSchema, type PostDTO } from "./entities.js" +import { PostCountsSchema, PostDTOSchema, PostKindSchema, type PostDTO } from "./entities.js" /** * Social-feed post request/response contracts. The PostDTO + PostKind primitives live in @@ -105,6 +105,99 @@ export type UserPostsQuery = z.infer export const HomeFeedResponseSchema = FeedPageDTOSchema export type HomeFeedResponse = z.infer +export const FEED_SCORE_CURSOR_PRECISION = 6 + +export const FeedScoreCursorSchema = z + .object({ + score: z.number().finite().nonnegative(), + postId: IdSchema, + }) + .strict() +export type FeedScoreCursor = z.infer + +const FEED_SCORE_CURSOR_RE = /^(\d+(?:\.\d+)?)\|([0-9a-fA-F-]{36})$/ + +export function quantizeFeedScore(score: number): number { + const factor = 10 ** FEED_SCORE_CURSOR_PRECISION + return Math.round(score * factor) / factor +} + +export function formatFeedScoreCursor(cursor: FeedScoreCursor): string { + const { score, postId } = FeedScoreCursorSchema.parse(cursor) + return `${quantizeFeedScore(score).toFixed(FEED_SCORE_CURSOR_PRECISION)}|${postId}` +} + +export function parseFeedScoreCursor(cursor: string | null | undefined): FeedScoreCursor | null { + if (typeof cursor !== "string") return null + const match = FEED_SCORE_CURSOR_RE.exec(cursor.trim()) + if (!match) return null + const parsed = FeedScoreCursorSchema.safeParse({ + score: Number(match[1]), + postId: match[2], + }) + return parsed.success ? parsed.data : null +} + +export function isAfterFeedScoreCursor( + candidate: FeedScoreCursor, + cursor: FeedScoreCursor, +): boolean { + const score = quantizeFeedScore(candidate.score) + const cursorScore = quantizeFeedScore(cursor.score) + if (score !== cursorScore) return score < cursorScore + return candidate.postId < cursor.postId +} + +export const FeedRankingConfigSchema = z + .object({ + baseWeight: z.number().min(0).max(1000).default(10), + followWeight: z.number().min(0).max(1000).default(100), + selfWeight: z.number().min(0).max(1000).default(60), + mentionWeight: z.number().min(0).max(1000).default(40), + nearbyWeight: z.number().min(0).max(1000).default(50), + nearbyRadiusKm: z.number().positive().max(500).default(40), + orgVerifiedWeight: z.number().min(0).max(1000).default(30), + attachEventWeight: z.number().min(0).max(1000).default(30), + attachReportWeight: z.number().min(0).max(1000).default(30), + imageWeight: z.number().min(0).max(1000).default(5), + likeWeight: z.number().min(0).max(1000).default(3), + replyWeight: z.number().min(0).max(1000).default(8), + repostWeight: z.number().min(0).max(1000).default(5), + halfLifeHours: z.number().positive().max(8760).default(36), + decayFloor: z.number().min(0).max(1).default(0.15), + diversityFloor: z.number().min(0).max(1).default(0.25), + diversityDecay: z.number().min(0).max(1).default(0.5), + seenDiscount: z.number().min(0).max(1).default(0.7), + minScore: z.number().min(0).max(1000).default(12), + minPageItems: z.number().int().min(0).max(50).default(5), + candidateWindowDays: z.number().int().positive().max(365).default(30), + candidateCap: z.number().int().min(50).max(2000).default(400), + clockBucketSeconds: z.number().int().min(1).max(3600).default(60), + snapshotTtlSeconds: z.number().int().min(0).max(3600).default(180), + servedTtlSeconds: z.number().int().min(60).max(86400).default(900), + viewerFanoutMax: z.number().int().min(0).max(10000).default(500), + newPostFanoutMax: z.number().int().min(0).max(100000).default(1000), + }) + .strict() +export type FeedRankingConfig = z.infer + +export const DEFAULT_FEED_RANKING: FeedRankingConfig = FeedRankingConfigSchema.parse({}) + +export const FEED_COUNTS_MAX_IDS = 100 + +export const FeedCountsRequestSchema = z + .object({ postIds: z.array(IdSchema).min(1).max(FEED_COUNTS_MAX_IDS) }) + .strict() +export type FeedCountsRequest = z.infer + +export const FeedPostCountsDTOSchema = z.object({ id: IdSchema, counts: PostCountsSchema }) +export type FeedPostCountsDTO = z.infer + +export const FeedCountsResponseSchema = z + .object({ items: z.array(FeedPostCountsDTOSchema) }) + .strict() +export type FeedCountsResponse = z.infer + /** * A thread's direct replies, plus `authorReplies`: for each listed reply that the FOCAL POST'S AUTHOR has * answered, their single most recent answer to it. The thread screen renders one of those under its parent diff --git a/packages/shared/src/types/ws.ts b/packages/shared/src/types/ws.ts index 065c11bc..19aac933 100644 --- a/packages/shared/src/types/ws.ts +++ b/packages/shared/src/types/ws.ts @@ -10,7 +10,14 @@ export const EDIT_WINDOW_HOURS = 48 export const RoomKindSchema = z.enum(["cleanup", "dm", "report", "group"]) export type RoomKind = z.infer -export const SignalTopicSchema = z.enum(["notifications", "threads", "reports", "host"]) +export const SignalTopicSchema = z.enum([ + "notifications", + "threads", + "reports", + "host", + "feed", + "feed_counts", +]) export type SignalTopic = z.infer export const UserSignalSchema = z From f44415a993decb635acdc9ce4dd1ebd738f129bd Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:23:13 -0700 Subject: [PATCH 003/105] shared 0.49.0 --- .changeset/admin-report-list-thumbnail.md | 7 ------- .changeset/feed-ranking-contract.md | 18 ------------------ packages/shared/CHANGELOG.md | 23 +++++++++++++++++++++++ packages/shared/package.json | 2 +- 4 files changed, 24 insertions(+), 26 deletions(-) delete mode 100644 .changeset/admin-report-list-thumbnail.md delete mode 100644 .changeset/feed-ranking-contract.md diff --git a/.changeset/admin-report-list-thumbnail.md b/.changeset/admin-report-list-thumbnail.md deleted file mode 100644 index 8bcae58e..00000000 --- a/.changeset/admin-report-list-thumbnail.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@civfix/shared": minor ---- - -Admin report list rows carry a presigned `thumbnailUrl` - -`AdminReportListItemDTO` gains `thumbnailUrl`: a presigned preview of the report's first ready image asset (the pipeline thumbnail when one exists, else the served image), so the admin reports list can render the report's own photo instead of the category pin. Nullable and defaulted to `null`, so a report with no usable image and a response from a server that does not yet send the field both parse unchanged. diff --git a/.changeset/feed-ranking-contract.md b/.changeset/feed-ranking-contract.md deleted file mode 100644 index 69a6da0d..00000000 --- a/.changeset/feed-ranking-contract.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@civfix/shared": minor ---- - -Feed ranking contract: scored cursor, counts endpoint, realtime topics, `FEED_RANKING` schema - -`GET /feed/home` keeps its method, path, query and response schemas; its `nextCursor` may now be a -ranked `"|"` cursor alongside the legacy `"|"` one, and the contract -owns the codec (`FeedScoreCursorSchema`, `formatFeedScoreCursor`, `parseFeedScoreCursor`, -`quantizeFeedScore`, `isAfterFeedScoreCursor`, `FEED_SCORE_CURSOR_PRECISION`) so both forms stay -unambiguous and the continuation predicate has one definition. - -New `getFeedCounts` (`POST /feed/counts`, auth required, no CSRF) takes up to -`FEED_COUNTS_MAX_IDS` post ids and returns counts only, with unreadable ids simply absent — -registry 318 → 319. `SignalTopicSchema` gains `feed` and `feed_counts` with `UserSignalSchema` -unchanged, so a stale client drops the new frames instead of failing. `FeedRankingConfigSchema` / -`FeedRankingConfig` / `DEFAULT_FEED_RANKING` add the strict, fully defaulted 27-knob ranking -profile that the backend loads from a JSON `FEED_RANKING` env var. All additive; see DECISIONS §47. diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index 3642385a..d2867abe 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,5 +1,28 @@ # @civfix/shared +## 0.49.0 + +### Minor Changes + +- 3872b02: Admin report list rows carry a presigned `thumbnailUrl` + + `AdminReportListItemDTO` gains `thumbnailUrl`: a presigned preview of the report's first ready image asset (the pipeline thumbnail when one exists, else the served image), so the admin reports list can render the report's own photo instead of the category pin. Nullable and defaulted to `null`, so a report with no usable image and a response from a server that does not yet send the field both parse unchanged. + +- 2d59670: Feed ranking contract: scored cursor, counts endpoint, realtime topics, `FEED_RANKING` schema + + `GET /feed/home` keeps its method, path, query and response schemas; its `nextCursor` may now be a + ranked `"|"` cursor alongside the legacy `"|"` one, and the contract + owns the codec (`FeedScoreCursorSchema`, `formatFeedScoreCursor`, `parseFeedScoreCursor`, + `quantizeFeedScore`, `isAfterFeedScoreCursor`, `FEED_SCORE_CURSOR_PRECISION`) so both forms stay + unambiguous and the continuation predicate has one definition. + + New `getFeedCounts` (`POST /feed/counts`, auth required, no CSRF) takes up to + `FEED_COUNTS_MAX_IDS` post ids and returns counts only, with unreadable ids simply absent — + registry 318 → 319. `SignalTopicSchema` gains `feed` and `feed_counts` with `UserSignalSchema` + unchanged, so a stale client drops the new frames instead of failing. `FeedRankingConfigSchema` / + `FeedRankingConfig` / `DEFAULT_FEED_RANKING` add the strict, fully defaulted 27-knob ranking + profile that the backend loads from a JSON `FEED_RANKING` env var. All additive; see DECISIONS §47. + ## 0.48.1 ### Patch Changes diff --git a/packages/shared/package.json b/packages/shared/package.json index 4946d6a1..55a328dd 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@civfix/shared", - "version": "0.48.1", + "version": "0.49.0", "description": "civfix shared contract package: zod schemas, domain types, vendor interfaces, in-memory fakes, design tokens, and a typed API client. Single source of truth for backend, web, and mobile.", "license": "UNLICENSED", "private": false, From 8b062c37b48eae0bb8d032d34653e5c15a4fde13 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:39:21 -0700 Subject: [PATCH 004/105] feed live updates: new-posts pill, count patching, ranked cursor adoption --- packages/ui/src/bodies/FeedBody.tsx | 29 +++- .../ui/src/bodies/__tests__/FeedBody.test.ts | 35 +++- packages/ui/src/bodies/feed/NewPostsPill.tsx | 97 +++++++++++ .../feed/__tests__/feedLiveModel.test.ts | 70 ++++++++ packages/ui/src/bodies/feed/feedLiveModel.ts | 23 +++ packages/ui/src/bodies/feed/feedLiveStore.ts | 23 +++ .../ui/src/data/__tests__/signals.test.ts | 5 + .../data/hooks/__tests__/feedRealtime.test.ts | 154 ++++++++++++++++++ packages/ui/src/data/hooks/feedRealtime.ts | 64 ++++++++ packages/ui/src/data/hooks/posts.ts | 18 ++ packages/ui/src/data/signals.ts | 3 + .../ui/src/i18n/locales/de/home-feed.json | 4 +- .../ui/src/i18n/locales/en/home-feed.json | 4 +- .../ui/src/i18n/locales/es/home-feed.json | 4 +- .../ui/src/i18n/locales/ko/home-feed.json | 3 +- .../realtime/__tests__/chatSocketCore.test.ts | 17 ++ 16 files changed, 543 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/bodies/feed/NewPostsPill.tsx create mode 100644 packages/ui/src/bodies/feed/__tests__/feedLiveModel.test.ts create mode 100644 packages/ui/src/bodies/feed/feedLiveModel.ts create mode 100644 packages/ui/src/bodies/feed/feedLiveStore.ts create mode 100644 packages/ui/src/data/hooks/__tests__/feedRealtime.test.ts create mode 100644 packages/ui/src/data/hooks/feedRealtime.ts diff --git a/packages/ui/src/bodies/FeedBody.tsx b/packages/ui/src/bodies/FeedBody.tsx index ea3b9375..ee80102d 100644 --- a/packages/ui/src/bodies/FeedBody.tsx +++ b/packages/ui/src/bodies/FeedBody.tsx @@ -7,13 +7,14 @@ import { RefreshControl, View, } from "react-native" -import type { ViewStyle } from "react-native" +import type { NativeScrollEvent, NativeSyntheticEvent, ViewStyle } from "react-native" import type { PostDTO } from "@civfix/shared" import { POST_SURFACE, makeThemedStyles, useLayoutMode, type Theme } from "../theme" import { Text } from "../typography" import { useAuthState, useRequireAuth } from "../data" import { useT } from "../i18n" import { useHomeFeed } from "../data/hooks/posts" +import { useFeedRealtime } from "../data/hooks/feedRealtime" import { useNavStore } from "../nav" import { alpha } from "../theme/alpha" import { useReducedMotion } from "../theme/useReducedMotion" @@ -27,6 +28,9 @@ import { FeedNotice } from "./FeedNotice" import { PostCard } from "./PostCard" import { InlineComposer } from "./feed/InlineComposer" import { useFeedScrollTopStore } from "./feed/feedScrollStore" +import { useFeedLiveStore } from "./feed/feedLiveStore" +import { clearsPendingAtOffset, dedupePostsById } from "./feed/feedLiveModel" +import { NewPostsPill } from "./feed/NewPostsPill" import { POST_CARD_RHYTHM } from "./postCardRhythm" import { buildFeedHeaderModel, @@ -174,6 +178,8 @@ export function FeedBody() { const layout = useLayoutMode() const isExpanded = layout === "expanded" const feed = useHomeFeed() + useFeedRealtime() + const pendingNewPosts = useFeedLiveStore((s) => s.pendingNewPostIds.length) const entranceStyle = useFeedEntrance() const [entrance] = useState(createFeedEntranceTracker) const reducedMotion = useReducedMotion() @@ -181,7 +187,7 @@ export function FeedBody() { const headerModel = buildFeedHeaderModel({ isAuthenticated, layout }, t) const composeLabel = useT("nav").t("title.post_composer") const posts = useMemo( - () => feed.data?.pages.flatMap((page) => page.items) ?? [], + () => dedupePostsById(feed.data?.pages.flatMap((page) => page.items) ?? []), [feed.data], ) const state = feedViewState({ @@ -197,8 +203,20 @@ export function FeedBody() { const [refreshing, setRefreshing] = useState(false) const onRefresh = useCallback(() => { setRefreshing(true) + useFeedLiveStore.getState().clearNewPosts() void Promise.resolve(refetch()).finally(() => setRefreshing(false)) }, [refetch]) + const showNewPosts = useCallback(() => { + useFeedScrollTopStore.getState().requestScrollTop() + void refetch() + useFeedLiveStore.getState().clearNewPosts() + }, [refetch]) + const onListScroll = useCallback((event: NativeSyntheticEvent) => { + const live = useFeedLiveStore.getState() + if (clearsPendingAtOffset(event.nativeEvent.contentOffset.y, live.pendingNewPostIds.length)) { + live.clearNewPosts() + } + }, []) const fetchNextPage = feed.fetchNextPage const hasNextPage = feed.hasNextPage const isFetchingNextPage = feed.isFetchingNextPage @@ -353,16 +371,17 @@ export function FeedBody() { showsVerticalScrollIndicator={false} onEndReached={loadMore} onEndReachedThreshold={0.6} + onScroll={onListScroll} + scrollEventThrottle={16} refreshControl={refresh} /> ) - if (!isExpanded || !IS_WEB) return list - return ( {list} - + + {isExpanded && IS_WEB ? : null} ) } diff --git a/packages/ui/src/bodies/__tests__/FeedBody.test.ts b/packages/ui/src/bodies/__tests__/FeedBody.test.ts index 881f4035..93d760ef 100644 --- a/packages/ui/src/bodies/__tests__/FeedBody.test.ts +++ b/packages/ui/src/bodies/__tests__/FeedBody.test.ts @@ -200,6 +200,39 @@ describe("the card's timeline has no scrollbar, and a fade instead", () => { expect(SRC).toMatch(/ \[styles\.scrollFade/) expect(SRC).toContain("{ bottom: promoHeight }") - expect(SRC).toContain("if (!isExpanded || !IS_WEB) return list") + expect(SRC).toContain( + '{isExpanded && IS_WEB ? : null}', + ) + expect(SRC).not.toContain("if (!isExpanded || !IS_WEB) return list") + }) +}) + +describe("the new-posts pill overlays the list on every surface", () => { + const SRC = readFileSync(new URL("../FeedBody.tsx", import.meta.url), "utf8") + + it("hosts the list in the one relative wrapper the pill needs, on both platforms", () => { + expect(SRC).toContain("") + expect(SRC).toContain("") + }) + + it("tapping it scrolls to top, refetches the ranked first page, and clears the count", () => { + expect(SRC).toMatch( + /const showNewPosts = useCallback\(\(\) => \{\s*useFeedScrollTopStore\.getState\(\)\.requestScrollTop\(\)\s*void refetch\(\)\s*useFeedLiveStore\.getState\(\)\.clearNewPosts\(\)\s*\}, \[refetch\]\)/, + ) + }) + + it("pull-to-refresh also clears the pending count", () => { + expect(SRC).toMatch(/setRefreshing\(true\)\s*useFeedLiveStore\.getState\(\)\.clearNewPosts\(\)/) + }) + + it("scrolling back to the top clears the pill without a surprise refetch", () => { + expect(SRC).toContain("onScroll={onListScroll}") + expect(SRC).toContain("clearsPendingAtOffset(event.nativeEvent.contentOffset.y") + expect(SRC).not.toMatch(/onListScroll[\s\S]{0,200}?refetch/) + }) + + it("keeps the feed exactly in server rank order, de-duplicated by id only", () => { + expect(SRC).toContain("dedupePostsById(feed.data?.pages.flatMap((page) => page.items) ?? [])") + expect(SRC).not.toMatch(/posts\s*\.\s*sort|\.toSorted\(/) }) }) diff --git a/packages/ui/src/bodies/feed/NewPostsPill.tsx b/packages/ui/src/bodies/feed/NewPostsPill.tsx new file mode 100644 index 00000000..e079c4da --- /dev/null +++ b/packages/ui/src/bodies/feed/NewPostsPill.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useRef } from "react" +import { Animated, Easing, Platform, Pressable, StyleSheet } from "react-native" +import { focusRingProps, makeThemedStyles, motion, useTheme } from "../../theme" +import { useReducedMotion } from "../../theme/useReducedMotion" +import { Icon, Text, iconMap } from "../../typography" +import { useT } from "../../i18n" + +const ENTER = motion.fadeUp +const USE_NATIVE_DRIVER = Platform.OS !== "web" +const LIVE_REGION: "polite" | "none" = Platform.OS === "web" ? "polite" : "none" +const MIN_TOUCH_TARGET = 44 + +export function NewPostsPill({ count, onPress }: { count: number; onPress: () => void }) { + const styles = useStyles() + const th = useTheme() + const { t } = useT("home-feed") + const reducedMotion = useReducedMotion() === true + const progress = useRef(new Animated.Value(0)).current + const visible = count > 0 + + useEffect(() => { + if (!visible) { + progress.setValue(0) + return + } + const animation = Animated.timing(progress, { + toValue: 1, + duration: ENTER.duration, + easing: Easing.bezier(...ENTER.easing), + useNativeDriver: USE_NATIVE_DRIVER, + }) + animation.start() + return () => animation.stop() + }, [visible, progress]) + + if (!visible) return null + + const motionStyle = reducedMotion + ? { opacity: progress } + : { + opacity: progress, + transform: [ + { + translateY: progress.interpolate({ + inputRange: [0, 1], + outputRange: [-ENTER.distance, 0], + }), + }, + ], + } + + const label = t("feed.new_posts", { count }) + + return ( + + [styles.card, pressed ? styles.cardPressed : null]} + > + + + {label} + + + + ) +} + +const useStyles = makeThemedStyles((t) => ({ + slot: { + position: "absolute", + top: t.space["3"], + left: 0, + right: 0, + alignItems: "center", + paddingHorizontal: t.space["4"], + }, + card: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: t.space["2"], + minHeight: MIN_TOUCH_TARGET, + paddingVertical: t.space["2"], + paddingHorizontal: t.space["4"], + borderRadius: t.radius.pill, + backgroundColor: t.colors.bg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.colors.border, + ...t.shadows.s3, + }, + cardPressed: { opacity: 0.7 }, +})) diff --git a/packages/ui/src/bodies/feed/__tests__/feedLiveModel.test.ts b/packages/ui/src/bodies/feed/__tests__/feedLiveModel.test.ts new file mode 100644 index 00000000..b728ad9c --- /dev/null +++ b/packages/ui/src/bodies/feed/__tests__/feedLiveModel.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { + FEED_TOP_CLEAR_OFFSET, + addPendingNewPost, + clearsPendingAtOffset, + dedupePostsById, +} from "../feedLiveModel" +import { useFeedLiveStore } from "../feedLiveStore" + +describe("addPendingNewPost", () => { + it("accumulates ids in arrival order", () => { + expect(addPendingNewPost(addPendingNewPost([], "a"), "b")).toEqual(["a", "b"]) + }) + + it("dedupes a repeated id and keeps the SAME array identity for the no-op", () => { + const once = addPendingNewPost([], "a") + expect(addPendingNewPost(once, "a")).toBe(once) + }) +}) + +describe("clearsPendingAtOffset", () => { + it("clears only when the reader has actually reached the top", () => { + expect(clearsPendingAtOffset(0, 3)).toBe(true) + expect(clearsPendingAtOffset(FEED_TOP_CLEAR_OFFSET, 3)).toBe(true) + expect(clearsPendingAtOffset(-40, 3)).toBe(true) + expect(clearsPendingAtOffset(FEED_TOP_CLEAR_OFFSET + 1, 3)).toBe(false) + expect(clearsPendingAtOffset(400, 3)).toBe(false) + }) + + it("never clears when nothing is pending, so top-of-list scrolling stays write-free", () => { + expect(clearsPendingAtOffset(0, 0)).toBe(false) + }) +}) + +describe("dedupePostsById", () => { + it("keeps the FIRST occurrence and preserves server rank order", () => { + const items = [{ id: "a" }, { id: "b" }, { id: "a" }, { id: "c" }] + expect(dedupePostsById(items).map((p) => p.id)).toEqual(["a", "b", "c"]) + }) + + it("returns the same array identity when nothing is duplicated", () => { + const items = [{ id: "a" }, { id: "b" }] + expect(dedupePostsById(items)).toBe(items) + }) +}) + +describe("useFeedLiveStore", () => { + beforeEach(() => useFeedLiveStore.getState().clearNewPosts()) + + it("counts distinct new posts and clears on demand", () => { + const store = useFeedLiveStore + store.getState().noteNewPost("p1") + store.getState().noteNewPost("p2") + store.getState().noteNewPost("p1") + expect(store.getState().pendingNewPostIds).toEqual(["p1", "p2"]) + store.getState().clearNewPosts() + expect(store.getState().pendingNewPostIds).toEqual([]) + }) + + it("a duplicate note and an empty clear both leave the state identity untouched", () => { + const store = useFeedLiveStore + const empty = store.getState().pendingNewPostIds + store.getState().clearNewPosts() + expect(store.getState().pendingNewPostIds).toBe(empty) + store.getState().noteNewPost("p1") + const one = store.getState().pendingNewPostIds + store.getState().noteNewPost("p1") + expect(store.getState().pendingNewPostIds).toBe(one) + }) +}) diff --git a/packages/ui/src/bodies/feed/feedLiveModel.ts b/packages/ui/src/bodies/feed/feedLiveModel.ts new file mode 100644 index 00000000..2d9b4de2 --- /dev/null +++ b/packages/ui/src/bodies/feed/feedLiveModel.ts @@ -0,0 +1,23 @@ +export const FEED_TOP_CLEAR_OFFSET = 1 + +export function addPendingNewPost( + ids: readonly string[], + id: string, +): readonly string[] { + return ids.includes(id) ? ids : [...ids, id] +} + +export function clearsPendingAtOffset(offsetY: number, pendingCount: number): boolean { + return pendingCount > 0 && offsetY <= FEED_TOP_CLEAR_OFFSET +} + +export function dedupePostsById(items: readonly T[]): readonly T[] { + const seen = new Set() + const unique: T[] = [] + for (const item of items) { + if (seen.has(item.id)) continue + seen.add(item.id) + unique.push(item) + } + return unique.length === items.length ? items : unique +} diff --git a/packages/ui/src/bodies/feed/feedLiveStore.ts b/packages/ui/src/bodies/feed/feedLiveStore.ts new file mode 100644 index 00000000..62924c41 --- /dev/null +++ b/packages/ui/src/bodies/feed/feedLiveStore.ts @@ -0,0 +1,23 @@ +import { create } from "zustand" +import { addPendingNewPost } from "./feedLiveModel" + +const NO_PENDING: readonly string[] = [] + +export interface FeedLiveState { + pendingNewPostIds: readonly string[] + noteNewPost: (id: string) => void + clearNewPosts: () => void +} + +export const useFeedLiveStore = create((set, get) => ({ + pendingNewPostIds: NO_PENDING, + noteNewPost: (id) => { + const prev = get().pendingNewPostIds + const next = addPendingNewPost(prev, id) + if (next !== prev) set({ pendingNewPostIds: next }) + }, + clearNewPosts: () => { + if (get().pendingNewPostIds.length === 0) return + set({ pendingNewPostIds: NO_PENDING }) + }, +})) diff --git a/packages/ui/src/data/__tests__/signals.test.ts b/packages/ui/src/data/__tests__/signals.test.ts index 8f683035..5080c508 100644 --- a/packages/ui/src/data/__tests__/signals.test.ts +++ b/packages/ui/src/data/__tests__/signals.test.ts @@ -26,6 +26,11 @@ describe("invalidationKeysForTopic", () => { expect(invalidationKeysForTopic("reports")).toEqual([queryKeys.myReportsRoot]) }) + it("feed and feed_counts are deliberate no-ops - the pill and the counts patcher own them", () => { + expect(invalidationKeysForTopic("feed", undefined, "post-1")).toEqual([]) + expect(invalidationKeysForTopic("feed_counts", undefined, "post-1")).toEqual([]) + }) + it("an unknown topic yields no keys (never throws)", () => { // Cast through never: a future topic this client predates must be a safe no-op. expect(invalidationKeysForTopic("future_topic" as never)).toEqual([]) diff --git a/packages/ui/src/data/hooks/__tests__/feedRealtime.test.ts b/packages/ui/src/data/hooks/__tests__/feedRealtime.test.ts new file mode 100644 index 00000000..be74bfa3 --- /dev/null +++ b/packages/ui/src/data/hooks/__tests__/feedRealtime.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { QueryClient, type InfiniteData } from "@tanstack/react-query" +import { FEED_COUNTS_MAX_IDS } from "@civfix/shared" +import type { FeedPageDTO, PersonDTO, PostDTO } from "@civfix/shared" +import { queryKeys } from "../../keys" +import { patchPostCountsInCaches } from "../posts" +import { FEED_COUNTS_DEBOUNCE_MS, createFeedCountsBatcher } from "../feedRealtime" + +function person(id: string): PersonDTO { + return { + id, + name: `User ${id}`, + handle: id, + bio: null, + avatar: null, + avatarUrl: null, + followers: 0, + following: 0, + isFollowing: false, + } +} + +function post(id: string, overrides: Partial = {}): PostDTO { + return { + id, + author: person("me"), + kind: "post", + body: `body ${id}`, + createdAt: "2026-09-15T00:00:00.000Z", + counts: { likes: 5, reposts: 2, replies: 1, saves: 0 }, + viewer: { liked: true, reposted: false, saved: false }, + media: [], + mentions: [], + event: null, + report: null, + repostOf: null, + replyToId: null, + threadRootId: null, + ...overrides, + } +} + +function feed(items: PostDTO[]): InfiniteData { + return { pages: [{ items, nextCursor: null }], pageParams: [undefined] } +} + +const FEED_ALL = queryKeys.homeFeed("all", "me") + +describe("createFeedCountsBatcher", () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it("batches every id noted within the window into ONE deduplicated fetch", () => { + const calls: string[][] = [] + const batcher = createFeedCountsBatcher(async (ids) => { + calls.push(ids) + }) + batcher.note("a") + batcher.note("b") + batcher.note("a") + vi.advanceTimersByTime(FEED_COUNTS_DEBOUNCE_MS - 1) + expect(calls).toEqual([]) + vi.advanceTimersByTime(1) + expect(calls).toEqual([["a", "b"]]) + }) + + it("starts a fresh window after a flush instead of fetching per signal", () => { + const calls: string[][] = [] + const batcher = createFeedCountsBatcher(async (ids) => { + calls.push(ids) + }) + batcher.note("a") + vi.advanceTimersByTime(FEED_COUNTS_DEBOUNCE_MS) + batcher.note("b") + batcher.note("c") + vi.advanceTimersByTime(FEED_COUNTS_DEBOUNCE_MS) + expect(calls).toEqual([["a"], ["b", "c"]]) + }) + + it("caps a flush at the contract's FEED_COUNTS_MAX_IDS", () => { + const calls: string[][] = [] + const batcher = createFeedCountsBatcher(async (ids) => { + calls.push(ids) + }) + for (let i = 0; i < FEED_COUNTS_MAX_IDS + 25; i++) batcher.note(`p${i}`) + vi.advanceTimersByTime(FEED_COUNTS_DEBOUNCE_MS) + expect(calls).toHaveLength(1) + expect(calls[0]).toHaveLength(FEED_COUNTS_MAX_IDS) + }) + + it("a rejecting fetch neither throws nor leaves a stuck timer", async () => { + const batcher = createFeedCountsBatcher(async () => { + throw new Error("offline") + }) + batcher.note("a") + vi.advanceTimersByTime(FEED_COUNTS_DEBOUNCE_MS) + await vi.runAllTimersAsync() + batcher.note("b") + expect(() => vi.advanceTimersByTime(FEED_COUNTS_DEBOUNCE_MS)).not.toThrow() + }) + + it("dispose drops the pending buffer and cancels the flush", () => { + const calls: string[][] = [] + const batcher = createFeedCountsBatcher(async (ids) => { + calls.push(ids) + }) + batcher.note("a") + batcher.dispose() + vi.advanceTimersByTime(FEED_COUNTS_DEBOUNCE_MS * 2) + expect(calls).toEqual([]) + }) +}) + +describe("patchPostCountsInCaches", () => { + it("patches counts in place across the list caches AND the post detail, keeping viewer state", () => { + const qc = new QueryClient() + qc.setQueryData(FEED_ALL, feed([post("p1"), post("p2")])) + qc.setQueryData(queryKeys.post("p1"), post("p1")) + + patchPostCountsInCaches(qc, [ + { id: "p1", counts: { likes: 9, reposts: 4, replies: 3, saves: 2 } }, + ]) + + const page = qc.getQueryData>(FEED_ALL)!.pages[0]! + const patched = page.items.find((p) => p.id === "p1")! + expect(patched.counts).toEqual({ likes: 9, reposts: 4, replies: 3, saves: 2 }) + expect(patched.viewer).toEqual({ liked: true, reposted: false, saved: false }) + expect(page.items.find((p) => p.id === "p2")!.counts.likes).toBe(5) + expect(qc.getQueryData(queryKeys.post("p1"))!.counts.likes).toBe(9) + }) + + it("never invalidates the feed - the list must not reshuffle under the reader", () => { + const qc = new QueryClient() + qc.setQueryData(FEED_ALL, feed([post("p1")])) + patchPostCountsInCaches(qc, [ + { id: "p1", counts: { likes: 1, reposts: 0, replies: 0, saves: 0 } }, + ]) + expect(qc.getQueryState(FEED_ALL)?.isInvalidated).toBe(false) + }) + + it("tolerates ids the server left out and ids no cache holds", () => { + const qc = new QueryClient() + qc.setQueryData(FEED_ALL, feed([post("p1")])) + expect(() => + patchPostCountsInCaches(qc, [ + { id: "unknown", counts: { likes: 1, reposts: 0, replies: 0, saves: 0 } }, + ]), + ).not.toThrow() + expect( + qc.getQueryData>(FEED_ALL)!.pages[0]!.items[0]!.counts.likes, + ).toBe(5) + expect(qc.getQueryData(queryKeys.post("unknown"))).toBeUndefined() + }) +}) diff --git a/packages/ui/src/data/hooks/feedRealtime.ts b/packages/ui/src/data/hooks/feedRealtime.ts new file mode 100644 index 00000000..70e4feb3 --- /dev/null +++ b/packages/ui/src/data/hooks/feedRealtime.ts @@ -0,0 +1,64 @@ +import { useEffect } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { FEED_COUNTS_MAX_IDS } from "@civfix/shared" +import { useApi, useAuthState, useChatSocket } from "../context" +import { useFeedLiveStore } from "../../bodies/feed/feedLiveStore" +import { patchPostCountsInCaches } from "./posts" + +export const FEED_COUNTS_DEBOUNCE_MS = 2000 + +export interface FeedCountsBatcher { + note: (postId: string) => void + dispose: () => void +} + +export function createFeedCountsBatcher( + fetchAndPatch: (postIds: string[]) => Promise, + debounceMs: number = FEED_COUNTS_DEBOUNCE_MS, +): FeedCountsBatcher { + let pending = new Set() + let timer: ReturnType | null = null + const flush = () => { + timer = null + const postIds = [...pending].slice(0, FEED_COUNTS_MAX_IDS) + pending = new Set() + if (postIds.length > 0) void fetchAndPatch(postIds).catch(() => undefined) + } + return { + note: (postId) => { + pending.add(postId) + timer ??= setTimeout(flush, debounceMs) + }, + dispose: () => { + if (timer != null) clearTimeout(timer) + timer = null + pending = new Set() + }, + } +} + +export function useFeedRealtime(): void { + const api = useApi() + const qc = useQueryClient() + const socket = useChatSocket() + const { isAuthenticated } = useAuthState() + + useEffect(() => { + if (!isAuthenticated) return + const batcher = createFeedCountsBatcher(async (postIds) => { + const res = await api.getFeedCounts({ postIds }) + patchPostCountsInCaches(qc, res.items) + }) + socket.retain() + const unsubscribe = socket.subscribe((frame) => { + if (frame.type !== "signal" || !frame.id) return + if (frame.topic === "feed") useFeedLiveStore.getState().noteNewPost(frame.id) + else if (frame.topic === "feed_counts") batcher.note(frame.id) + }) + return () => { + unsubscribe() + socket.release() + batcher.dispose() + } + }, [api, qc, socket, isAuthenticated]) +} diff --git a/packages/ui/src/data/hooks/posts.ts b/packages/ui/src/data/hooks/posts.ts index 8e92ff00..9227a2eb 100644 --- a/packages/ui/src/data/hooks/posts.ts +++ b/packages/ui/src/data/hooks/posts.ts @@ -40,7 +40,9 @@ import type { ApiClient, PostDTO, PostComposeInput, + PostCounts, FeedPageDTO, + FeedPostCountsDTO, ListRepliesResponse, DeletePostResponse, } from "@civfix/shared" @@ -105,6 +107,22 @@ export function patchPostInListCaches( ) } +export function applyCounts(post: PostDTO, counts: PostCounts): PostDTO { + return { ...post, counts } +} + +export function patchPostCountsInCaches( + qc: QueryClient, + items: ReadonlyArray, +): void { + for (const { id, counts } of items) { + patchPostInListCaches(qc, id, (post) => applyCounts(post, counts)) + qc.setQueryData(queryKeys.post(id), (prev) => + prev ? applyCounts(prev, counts) : prev, + ) + } +} + /** Find the target post's current value in any list cache under the prefix (for the rollback snapshot). */ export function findPostInLists(qc: QueryClient, postId: string): PostDTO | undefined { for (const [, data] of qc.getQueriesData>({ diff --git a/packages/ui/src/data/signals.ts b/packages/ui/src/data/signals.ts index 32b5f742..fd095a52 100644 --- a/packages/ui/src/data/signals.ts +++ b/packages/ui/src/data/signals.ts @@ -24,6 +24,9 @@ function baseKeysForTopic(topic: SignalTopic, id: string | null): ReadonlyArray< return [queryKeys.myReportsRoot] case "host": return [id ? queryKeys.hostEvent(id) : HOST_ROOT] + case "feed": + case "feed_counts": + return [] default: return [] } diff --git a/packages/ui/src/i18n/locales/de/home-feed.json b/packages/ui/src/i18n/locales/de/home-feed.json index ed435fe4..dbccfa1d 100644 --- a/packages/ui/src/i18n/locales/de/home-feed.json +++ b/packages/ui/src/i18n/locales/de/home-feed.json @@ -17,7 +17,9 @@ "empty_body_before": "Beiträge aus deiner Community erscheinen hier. ", "empty_body_link": "Melde dich an", "empty_body_after": ", um die erste Neuigkeit zu teilen.", - "caught_up": "Du bist auf dem neuesten Stand" + "caught_up": "Du bist auf dem neuesten Stand", + "new_posts_one": "{{count}} neuer Beitrag", + "new_posts_other": "{{count}} neue Beiträge" }, "thread": { "title": "Beitrag", diff --git a/packages/ui/src/i18n/locales/en/home-feed.json b/packages/ui/src/i18n/locales/en/home-feed.json index b2259f43..a56ab65d 100644 --- a/packages/ui/src/i18n/locales/en/home-feed.json +++ b/packages/ui/src/i18n/locales/en/home-feed.json @@ -17,7 +17,9 @@ "empty_body_before": "Posts from your community will show up here. ", "empty_body_link": "Sign in", "empty_body_after": " to share the first update.", - "caught_up": "You're all caught up" + "caught_up": "You're all caught up", + "new_posts_one": "{{count}} new post", + "new_posts_other": "{{count}} new posts" }, "thread": { "title": "Post", diff --git a/packages/ui/src/i18n/locales/es/home-feed.json b/packages/ui/src/i18n/locales/es/home-feed.json index ec10a449..7670fdd3 100644 --- a/packages/ui/src/i18n/locales/es/home-feed.json +++ b/packages/ui/src/i18n/locales/es/home-feed.json @@ -17,7 +17,9 @@ "empty_body_before": "Aquí aparecerán las publicaciones de tu comunidad. ", "empty_body_link": "Inicia sesión", "empty_body_after": " para compartir la primera.", - "caught_up": "Estás al día" + "caught_up": "Estás al día", + "new_posts_one": "{{count}} publicación nueva", + "new_posts_other": "{{count}} publicaciones nuevas" }, "thread": { "title": "Publicación", diff --git a/packages/ui/src/i18n/locales/ko/home-feed.json b/packages/ui/src/i18n/locales/ko/home-feed.json index ffb48fa9..786caba5 100644 --- a/packages/ui/src/i18n/locales/ko/home-feed.json +++ b/packages/ui/src/i18n/locales/ko/home-feed.json @@ -17,7 +17,8 @@ "empty_body_before": "커뮤니티의 게시물이 여기에 표시돼요. ", "empty_body_link": "로그인", "empty_body_after": "하고 첫 소식을 공유해 보세요.", - "caught_up": "모두 확인했어요" + "caught_up": "모두 확인했어요", + "new_posts_other": "새 게시물 {{count}}개" }, "thread": { "title": "게시물", diff --git a/packages/ui/src/realtime/__tests__/chatSocketCore.test.ts b/packages/ui/src/realtime/__tests__/chatSocketCore.test.ts index f3e7a56b..e4fa2bdb 100644 --- a/packages/ui/src/realtime/__tests__/chatSocketCore.test.ts +++ b/packages/ui/src/realtime/__tests__/chatSocketCore.test.ts @@ -267,6 +267,23 @@ describe("inbound frames", () => { expect(received).toEqual([{ type: "signal", topic: "threads" }]) }) + it("delivers the feed signal topics and drops a topic this client predates", () => { + const socket = new ChatSocketCore({ transport: syncTransport() }) + socket.connect() + latest().fireOpen() + const received: unknown[] = [] + socket.subscribe((frame) => received.push(frame)) + + latest().fireMessage(JSON.stringify({ type: "signal", topic: "feed", id: ROOM_A })) + latest().fireMessage(JSON.stringify({ type: "signal", topic: "feed_counts", id: ROOM_B })) + latest().fireMessage(JSON.stringify({ type: "signal", topic: "feed_v2", id: ROOM_A })) + + expect(received).toEqual([ + { type: "signal", topic: "feed", id: ROOM_A }, + { type: "signal", topic: "feed_counts", id: ROOM_B }, + ]) + }) + it("a throwing listener does not take down the socket or its siblings", () => { const socket = new ChatSocketCore({ transport: syncTransport() }) socket.connect() From 5ea06d9abe125a92dea2839c021faeb798a8a057 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:02:42 -0700 Subject: [PATCH 005/105] donation card moved above event sign-ups --- packages/ui/src/bodies/EventDetailBody.tsx | 15 +++++++++------ .../ui/src/bodies/__tests__/donationLink.test.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/bodies/EventDetailBody.tsx b/packages/ui/src/bodies/EventDetailBody.tsx index bcf60bc1..8780fe84 100644 --- a/packages/ui/src/bodies/EventDetailBody.tsx +++ b/packages/ui/src/bodies/EventDetailBody.tsx @@ -467,6 +467,12 @@ function EventDetailContent({ cleanup }: { cleanup: CleanupDTO }) { + {donation ? ( + + + + ) : null} + {isLive && !actsAsHost && hasTicketTypes && (isUpcoming || isRegistered) ? ( @@ -670,12 +676,6 @@ function EventDetailContent({ cleanup }: { cleanup: CleanupDTO }) { - {donation ? ( - - - - ) : null} - ({ color: t.colors.textMuted, }, + donate: { + marginBottom: t.space["4"], + }, rsvp: { marginTop: t.space["4"], marginBottom: t.space["4"], diff --git a/packages/ui/src/bodies/__tests__/donationLink.test.ts b/packages/ui/src/bodies/__tests__/donationLink.test.ts index 003789ca..4c13a07f 100644 --- a/packages/ui/src/bodies/__tests__/donationLink.test.ts +++ b/packages/ui/src/bodies/__tests__/donationLink.test.ts @@ -1,6 +1,9 @@ +import { readFileSync } from "node:fs" import { describe, expect, it } from "vitest" import { donationLinkFor, type DonationLinkSource } from "../donationLink" +const EVENT_DETAIL = readFileSync(new URL("../EventDetailBody.tsx", import.meta.url), "utf8") + const ORG = { id: "o1", slug: "river-keepers", @@ -74,3 +77,14 @@ describe("donationLinkFor", () => { ).toEqual({ url: "https://pay.example.org/creek", ownerName: "Ballona Creek cleanup" }) }) }) + +describe("event page donation placement", () => { + it("renders the donation card between the header and the sign-up blocks, only when a link resolves", () => { + const donate = EVENT_DETAIL.indexOf(" Date: Tue, 15 Sep 2026 23:11:08 -0700 Subject: [PATCH 006/105] resolve the iOS API base URL from the install source at runtime --- .github/workflows/deploy-mobile.yml | 8 ++- apps/community-mobile/README.md | 24 ++++++-- apps/community-mobile/eas.json | 3 +- apps/community-mobile/scripts/store-build.sh | 10 ++-- apps/community-mobile/src/config.ts | 5 +- apps/community-mobile/src/lib/apiUrl.ts | 6 +- apps/community-mobile/src/lib/betaInstall.ts | 20 +++++++ .../src/lib/webOrigin.test.ts | 6 ++ apps/community-mobile/tests/apiUrl.test.ts | 56 ++++++++++++++----- apps/community-mobile/tests/bootShell.test.ts | 23 +++++++- 10 files changed, 129 insertions(+), 32 deletions(-) create mode 100644 apps/community-mobile/src/lib/betaInstall.ts diff --git a/.github/workflows/deploy-mobile.yml b/.github/workflows/deploy-mobile.yml index edb7d3e9..9a13a19d 100644 --- a/.github/workflows/deploy-mobile.yml +++ b/.github/workflows/deploy-mobile.yml @@ -3,8 +3,10 @@ name: Deploy mobile (TestFlight) # Builds apps/community-mobile for iOS and uploads it to App Store Connect, where TestFlight hands it # to the internal tester group. Same shape as deploy-web.yml, one lane: # -# push to main -> the `testflight` profile -> staging API (api.civfix.dev) -# manual run, profile=production -> the `production` profile -> prod API (api.civfix.org) +# push to main -> the `testflight` profile -> staging API (api.civfix.dev), baked +# manual run, profile=production -> the `production` profile -> API chosen at runtime by install +# source: api.civfix.dev in TestFlight, api.civfix.org from the +# App Store (src/lib/apiUrl.ts + src/lib/betaInstall.ts) # # Nothing here submits for App Store review. A production build lands in App Store Connect exactly # like a staging one; attaching it to a version and submitting is the manual step, so a prod mobile @@ -120,7 +122,7 @@ jobs: if (api !== expectedApi) { throw new Error(`profile '${profileName}' bakes EXPO_PUBLIC_API_URL='${api}', expected '${expectedApi}'`) } - console.log(`profile ${profileName}: EXPO_PUBLIC_API_URL='${api || "(unset -> https://api.civfix.org)"}'`) + console.log(`profile ${profileName}: EXPO_PUBLIC_API_URL='${api || "(unset -> resolved at runtime: api.civfix.dev in TestFlight, api.civfix.org from the App Store)"}'`) NODE echo "TARGET=$target" >> "$GITHUB_ENV" diff --git a/apps/community-mobile/README.md b/apps/community-mobile/README.md index b3d81d1e..a5f96efe 100644 --- a/apps/community-mobile/README.md +++ b/apps/community-mobile/README.md @@ -77,9 +77,23 @@ baked API base URL and their EAS Update channel: - `testflight` - dev/testing builds for TestFlight. Bakes `EXPO_PUBLIC_API_URL=https://api.civfix.dev`, so testers hit the staging API. Update channel `testflight`. -- `production` - official App Store releases. Sets no `EXPO_PUBLIC_API_URL`, - so release builds fall back to the prod API `https://api.civfix.org` - (`src/lib/apiUrl.ts`). Update channel `production`. +- `production` - official App Store releases. Sets no `EXPO_PUBLIC_API_URL`, so the base URL is + chosen at RUNTIME by install source: `https://api.civfix.dev` while that build is handed out + through TestFlight, `https://api.civfix.org` once the same build is downloaded from the App Store + (`src/lib/apiUrl.ts`, `src/lib/betaInstall.ts`). Update channel `production`. + +### How the runtime split is decided (iOS) + +iOS ships the App Store and TestFlight copies of a build with different StoreKit receipts: a store +download gets `StoreKit/receipt` in the app's data container, a TestFlight install gets +`StoreKit/sandboxReceipt`. `src/lib/betaInstall.ts` reads those two paths synchronously through +`expo-file-system`, so `API_URL` is a plain module constant and one session can never straddle two +APIs. `expo-application`'s `getIosApplicationReleaseTypeAsync()` cannot make this call: it reads the +embedded provisioning profile, which reports `APP_STORE` for TestFlight and App Store alike. + +Precedence is `EXPO_PUBLIC_API_URL` (when baked) -> `__DEV__` localhost -> the receipt probe. The +probe checks the store receipt FIRST, so a store download can never be routed to staging; anything +it cannot read falls through to production. ### Hand-driven Xcode archive (`scripts/prep-archive.sh`) @@ -286,7 +300,9 @@ to `eas build`), and never map the `testflight` channel onto a prod-published br Config plugins for the native modules (camera/mic/location permission strings, Google sign-in URL scheme, Apple auth, notifications) are declared in `apps/community-mobile/app.config.js`. The API base URL comes from `EXPO_PUBLIC_API_URL`, surfaced via `extra.apiUrl`; when unset, dev builds fall back -to `http://localhost:8080` and release builds to `https://api.civfix.org` (`src/lib/apiUrl.ts`). +to `http://localhost:8080` and release builds to whichever API the install source implies - +`https://api.civfix.dev` from TestFlight, `https://api.civfix.org` from the App Store +(`src/lib/apiUrl.ts`). ## pnpm + Expo + the shared packages diff --git a/apps/community-mobile/eas.json b/apps/community-mobile/eas.json index 39945c73..d49674c3 100644 --- a/apps/community-mobile/eas.json +++ b/apps/community-mobile/eas.json @@ -35,7 +35,7 @@ "channel": "testflight", "autoIncrement": true, "env": { - "//api": "Dev/TestFlight testing builds bake the staging API base URL. App Store releases use the production profile, which sets no EXPO_PUBLIC_API_URL and therefore falls back to https://api.civfix.org (src/lib/apiUrl.ts).", + "//api": "Dev/TestFlight testing builds bake the staging API base URL, pinning this profile to https://api.civfix.dev however it is installed. The production profile below bakes nothing, so src/lib/apiUrl.ts picks its API at RUNTIME from the active StoreKit receipt: https://api.civfix.dev while that build is handed out through TestFlight, https://api.civfix.org once it is downloaded from the App Store.", "//donate": "How a host's external donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. The link is the host's own third-party page; no payment happens anywhere in civfix. OTA-flippable with `eas update` if App Review ever objects to the in-app browser (see APP-REVIEW-NOTES.md).", "EXPO_PUBLIC_DONATE_BROWSER_MODE": "in-app", "EXPO_PUBLIC_API_URL": "https://api.civfix.dev", @@ -58,6 +58,7 @@ "channel": "production", "autoIncrement": true, "env": { + "//api": "Deliberately sets no EXPO_PUBLIC_API_URL. src/lib/apiUrl.ts resolves the base URL at runtime from the active StoreKit receipt, so ONE binary talks to https://api.civfix.dev while it is under test in TestFlight and to https://api.civfix.org once the same build is downloaded from the App Store. Baking a URL here would freeze it to one of the two.", "//dsym": "iOS links React Native's prebuilt core frameworks (buildReactNativeFromSource: false in app.config.js), so App Store Connect's 'Upload Symbols Failed' warning for React.framework / ReactNativeDependencies.framework (alongside hermes and MapLibre) is expected and non-blocking.", "//donate": "How a host's external donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. The link is the host's own third-party page; no payment happens anywhere in civfix. OTA-flippable with `eas update` if App Review ever objects to the in-app browser (see APP-REVIEW-NOTES.md).", "EXPO_PUBLIC_DONATE_BROWSER_MODE": "in-app", diff --git a/apps/community-mobile/scripts/store-build.sh b/apps/community-mobile/scripts/store-build.sh index 61ebf07a..e9ed464a 100755 --- a/apps/community-mobile/scripts/store-build.sh +++ b/apps/community-mobile/scripts/store-build.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # iOS store build + upload to App Store Connect, wrapping the eas.json profiles: # -# scripts/store-build.sh testflight dev/testing build for TestFlight — staging API (https://api.civfix.dev) -# scripts/store-build.sh appstore official App Store release build — prod API (https://api.civfix.org) +# scripts/store-build.sh testflight dev/testing build for TestFlight — staging API (https://api.civfix.dev), baked +# scripts/store-build.sh appstore official App Store release build — bakes NO API URL; the app picks +# api.civfix.dev in TestFlight and api.civfix.org from the App Store +# at runtime (src/lib/apiUrl.ts + src/lib/betaInstall.ts) # # Runs `eas build --local` on this machine — a developer Mac or the macOS GitHub runner that # .github/workflows/deploy-mobile.yml drives. Unlike a raw Xcode archive, this applies the profile's @@ -87,14 +89,14 @@ baked_api_url="$(printf '%s' "$baked_config" | node -e ' process.stdout.write(typeof apiUrl === "string" ? apiUrl : "") ')" if [ "$baked_api_url" != "$expected_api_url" ]; then - echo "Baked API URL is '${baked_api_url:-}' but the ${profile} profile promises '${expected_api_url:-}'. Refusing to upload ${ipa}." >&2 + echo "Baked API URL is '${baked_api_url:-}' but the ${profile} profile promises '${expected_api_url:-}'. Refusing to upload ${ipa}." >&2 exit 1 fi build_version="$(unzip -p "$ipa" 'Payload/*.app/Info.plist' | plutil -convert json -o - - | node -e ' const plist = JSON.parse(require("fs").readFileSync(0, "utf8")) process.stdout.write(`${plist.CFBundleShortVersionString} (${plist.CFBundleVersion})`) ')" -echo "Built ${ipa}: version ${build_version}, profile ${profile}, API ${baked_api_url:-https://api.civfix.org (default)}" +echo "Built ${ipa}: version ${build_version}, profile ${profile}, API ${baked_api_url:-resolved at runtime (api.civfix.dev in TestFlight, api.civfix.org from the App Store)}" if [ "$submit" = 0 ]; then echo "Upload later with: eas submit --platform ios --profile ${profile} --path ${ipa}" diff --git a/apps/community-mobile/src/config.ts b/apps/community-mobile/src/config.ts index 75a8a290..7841fb62 100644 --- a/apps/community-mobile/src/config.ts +++ b/apps/community-mobile/src/config.ts @@ -1,5 +1,6 @@ import Constants from "expo-constants" import { resolveApiUrl } from "./lib/apiUrl" +import { isBetaInstall } from "./lib/betaInstall" import { resolveDonateBrowserMode, type DonateBrowserMode } from "./lib/donateBrowser" import { resolveWebOrigin } from "./lib/webOrigin" @@ -14,9 +15,9 @@ type Extra = { const extra = (Constants.expoConfig?.extra ?? {}) as Extra -export { DEV_API_URL, PROD_API_URL, resolveApiUrl } from "./lib/apiUrl" +export { DEV_API_URL, STAGING_API_URL, PROD_API_URL, resolveApiUrl } from "./lib/apiUrl" -export const API_URL: string = resolveApiUrl(extra.apiUrl, __DEV__) +export const API_URL: string = resolveApiUrl(extra.apiUrl, __DEV__, isBetaInstall()) export const WEB_ORIGIN: string = resolveWebOrigin(API_URL) diff --git a/apps/community-mobile/src/lib/apiUrl.ts b/apps/community-mobile/src/lib/apiUrl.ts index 7ebf1b1f..c31c4b97 100644 --- a/apps/community-mobile/src/lib/apiUrl.ts +++ b/apps/community-mobile/src/lib/apiUrl.ts @@ -16,9 +16,11 @@ */ export const DEV_API_URL = "http://localhost:8080" +export const STAGING_API_URL = "https://api.civfix.dev" export const PROD_API_URL = "https://api.civfix.org" -export function resolveApiUrl(configured: unknown, isDev: boolean): string { +export function resolveApiUrl(configured: unknown, isDev: boolean, isBetaInstall: boolean): string { if (typeof configured === "string" && configured.trim() !== "") return configured.trim() - return isDev ? DEV_API_URL : PROD_API_URL + if (isDev) return DEV_API_URL + return isBetaInstall ? STAGING_API_URL : PROD_API_URL } diff --git a/apps/community-mobile/src/lib/betaInstall.ts b/apps/community-mobile/src/lib/betaInstall.ts new file mode 100644 index 00000000..a611ba9b --- /dev/null +++ b/apps/community-mobile/src/lib/betaInstall.ts @@ -0,0 +1,20 @@ +import { Platform } from "react-native" +import { File, Paths } from "expo-file-system" + +export const STORE_KIT_DIR = "StoreKit" +export const APP_STORE_RECEIPT = "receipt" +export const SANDBOX_RECEIPT = "sandboxReceipt" + +function storeKitReceiptPresent(name: string): boolean { + try { + return new File(Paths.document.parentDirectory, STORE_KIT_DIR, name).exists + } catch { + return false + } +} + +export function isBetaInstall(): boolean { + if (Platform.OS !== "ios") return false + if (storeKitReceiptPresent(APP_STORE_RECEIPT)) return false + return storeKitReceiptPresent(SANDBOX_RECEIPT) +} diff --git a/apps/community-mobile/src/lib/webOrigin.test.ts b/apps/community-mobile/src/lib/webOrigin.test.ts index 0ebe27e6..1bc53277 100644 --- a/apps/community-mobile/src/lib/webOrigin.test.ts +++ b/apps/community-mobile/src/lib/webOrigin.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test" import assert from "node:assert/strict" import { PROD_WEB_ORIGIN, resolveWebOrigin } from "./webOrigin.ts" +import { PROD_API_URL, STAGING_API_URL } from "./apiUrl.ts" test("a build pointed at the staging API shares links to the staging site", () => { assert.equal(resolveWebOrigin("https://api.civfix.dev"), "https://civfix.dev") @@ -18,6 +19,11 @@ test("a local or unrecognised API falls back to the production site", () => { assert.equal(resolveWebOrigin("not a url"), PROD_WEB_ORIGIN) }) +test("every API base URL the resolver can choose has a mapped share origin", () => { + assert.equal(resolveWebOrigin(STAGING_API_URL), "https://civfix.dev") + assert.equal(resolveWebOrigin(PROD_API_URL), PROD_WEB_ORIGIN) +}) + test("never reflects an arbitrary api.* host into a trusted link origin", () => { assert.equal(resolveWebOrigin("https://api.evil.com"), PROD_WEB_ORIGIN) assert.equal(resolveWebOrigin("https://api.civfix.org.evil.com"), PROD_WEB_ORIGIN) diff --git a/apps/community-mobile/tests/apiUrl.test.ts b/apps/community-mobile/tests/apiUrl.test.ts index c434c4a4..c857c87d 100644 --- a/apps/community-mobile/tests/apiUrl.test.ts +++ b/apps/community-mobile/tests/apiUrl.test.ts @@ -15,45 +15,71 @@ import { test } from "node:test" import assert from "node:assert/strict" -import { resolveApiUrl, DEV_API_URL, PROD_API_URL } from "../src/lib/apiUrl.ts" +import { resolveApiUrl, DEV_API_URL, STAGING_API_URL, PROD_API_URL } from "../src/lib/apiUrl.ts" test("an empty object from the baked config falls back - the shipped-outage case", () => { // `??` would have returned the object here. Everything else in this file is scaffolding; this is // the assertion that actually encodes the incident. - assert.equal(resolveApiUrl({}, false), PROD_API_URL) - assert.equal(resolveApiUrl({}, true), DEV_API_URL) + assert.equal(resolveApiUrl({}, false, false), PROD_API_URL) + assert.equal(resolveApiUrl({}, true, false), DEV_API_URL) }) test("a configured string wins in both environments", () => { - assert.equal(resolveApiUrl("https://staging.example.org", false), "https://staging.example.org") - assert.equal(resolveApiUrl("https://staging.example.org", true), "https://staging.example.org") + assert.equal(resolveApiUrl("https://staging.example.org", false, false), "https://staging.example.org") + assert.equal(resolveApiUrl("https://staging.example.org", true, false), "https://staging.example.org") }) test("null and undefined fall back to the environment default", () => { - assert.equal(resolveApiUrl(null, false), PROD_API_URL) - assert.equal(resolveApiUrl(undefined, false), PROD_API_URL) - assert.equal(resolveApiUrl(null, true), DEV_API_URL) - assert.equal(resolveApiUrl(undefined, true), DEV_API_URL) + assert.equal(resolveApiUrl(null, false, false), PROD_API_URL) + assert.equal(resolveApiUrl(undefined, false, false), PROD_API_URL) + assert.equal(resolveApiUrl(null, true, false), DEV_API_URL) + assert.equal(resolveApiUrl(undefined, true, false), DEV_API_URL) }) test("empty and whitespace-only strings fall back rather than producing a bare-path URL", () => { - assert.equal(resolveApiUrl("", false), PROD_API_URL) - assert.equal(resolveApiUrl(" ", false), PROD_API_URL) + assert.equal(resolveApiUrl("", false, false), PROD_API_URL) + assert.equal(resolveApiUrl(" ", false, false), PROD_API_URL) }) test("a non-string of any shape falls back", () => { for (const bogus of [0, 1, true, false, [], { url: "x" }, () => "x"]) { - assert.equal(resolveApiUrl(bogus, false), PROD_API_URL) + assert.equal(resolveApiUrl(bogus, false, false), PROD_API_URL) } }) test("surrounding whitespace is trimmed off a real value", () => { - assert.equal(resolveApiUrl(" https://api.civfix.org ", false), "https://api.civfix.org") + assert.equal(resolveApiUrl(" https://api.civfix.org ", false, false), "https://api.civfix.org") }) test("a release build never resolves to localhost, and a dev build never to production", () => { // The direction of the fallback is the other half of the contract: getting it backwards would ship // a TestFlight build pointed at localhost, which is the same user-visible failure. - assert.equal(resolveApiUrl(undefined, false), "https://api.civfix.org") - assert.equal(resolveApiUrl(undefined, true), "http://localhost:8080") + assert.equal(resolveApiUrl(undefined, false, false), "https://api.civfix.org") + assert.equal(resolveApiUrl(undefined, true, false), "http://localhost:8080") +}) + +test("an unbaked release installed from TestFlight resolves to the staging API", () => { + assert.equal(resolveApiUrl(undefined, false, true), STAGING_API_URL) + assert.equal(resolveApiUrl(undefined, false, true), "https://api.civfix.dev") +}) + +test("an unbaked release installed from the App Store resolves to the production API", () => { + assert.equal(resolveApiUrl(undefined, false, false), PROD_API_URL) +}) + +test("a dev build stays on localhost even when the install looks like a beta one", () => { + assert.equal(resolveApiUrl(undefined, true, true), DEV_API_URL) + assert.equal(resolveApiUrl(null, true, true), DEV_API_URL) + assert.equal(resolveApiUrl({}, true, true), DEV_API_URL) +}) + +test("a baked URL still wins over the runtime install signal", () => { + assert.equal(resolveApiUrl("https://api.civfix.dev", false, false), "https://api.civfix.dev") + assert.equal(resolveApiUrl("https://api.civfix.org", false, true), "https://api.civfix.org") +}) + +test("the three environment defaults stay distinct", () => { + assert.notEqual(DEV_API_URL, STAGING_API_URL) + assert.notEqual(STAGING_API_URL, PROD_API_URL) + assert.notEqual(DEV_API_URL, PROD_API_URL) }) diff --git a/apps/community-mobile/tests/bootShell.test.ts b/apps/community-mobile/tests/bootShell.test.ts index c460383a..d3c9c606 100644 --- a/apps/community-mobile/tests/bootShell.test.ts +++ b/apps/community-mobile/tests/bootShell.test.ts @@ -6,6 +6,7 @@ const layout = readFileSync(new URL("../app/_layout.tsx", import.meta.url), "utf const home = readFileSync(new URL("../app/index.tsx", import.meta.url), "utf8") const config = readFileSync(new URL("../src/config.ts", import.meta.url), "utf8") const apiUrlModule = readFileSync(new URL("../src/lib/apiUrl.ts", import.meta.url), "utf8") +const betaInstallModule = readFileSync(new URL("../src/lib/betaInstall.ts", import.meta.url), "utf8") const appConfig = readFileSync(new URL("../app.config.js", import.meta.url), "utf8") test("the root layout exports an ErrorBoundary so expo-router can catch a boot crash", () => { @@ -57,15 +58,35 @@ test("every external URL is validated before it reaches Linking.openURL", () => test("a bare dev bundle points at localhost, never silently at production", () => { assert.match(apiUrlModule, /export const DEV_API_URL = "http:\/\/localhost:8080"/) + assert.match(apiUrlModule, /export const STAGING_API_URL = "https:\/\/api\.civfix\.dev"/) assert.match(apiUrlModule, /export const PROD_API_URL = "https:\/\/api\.civfix\.org"/) }) test("the base URL goes through the guarded resolver, never a bare ?? on the baked value", () => { - assert.match(config, /resolveApiUrl\(extra\.apiUrl, __DEV__\)/) + assert.match(config, /resolveApiUrl\(extra\.apiUrl, __DEV__, isBetaInstall\(\)\)/) assert.doesNotMatch(config, /extra\.apiUrl \?\?/) assert.match(apiUrlModule, /typeof configured === "string"/) }) +test("the install probe reads the ACTIVE StoreKit receipt, store receipt winning over the sandbox one", () => { + assert.match(betaInstallModule, /if \(Platform\.OS !== "ios"\) return false/) + const body = betaInstallModule.slice(betaInstallModule.indexOf("export function isBetaInstall")) + const store = body.indexOf("storeKitReceiptPresent(APP_STORE_RECEIPT)") + const sandbox = body.indexOf("storeKitReceiptPresent(SANDBOX_RECEIPT)") + assert.ok(store > -1 && sandbox > store) + assert.match(betaInstallModule, /export const APP_STORE_RECEIPT = "receipt"/) + assert.match(betaInstallModule, /export const SANDBOX_RECEIPT = "sandboxReceipt"/) +}) + +test("the probe resolves the StoreKit folder from the app data container, not the bundle", () => { + assert.match(betaInstallModule, /Paths\.document\.parentDirectory, STORE_KIT_DIR, name/) + assert.doesNotMatch(betaInstallModule, /Paths\.bundle/) +}) + +test("an unreadable install probe degrades to the production API rather than throwing at boot", () => { + assert.match(betaInstallModule, /} catch \{\n\s+return false\n\s+}/) +}) + test("the app config OMITS apiUrl when unset rather than baking a null Expo turns into {}", () => { assert.match(appConfig, /\.\.\.\(API_URL \? \{ apiUrl: API_URL \} : \{\}\)/) assert.doesNotMatch(appConfig, /^\s+apiUrl: API_URL,\s*$/m) From 76c1250db77b4724240f5f830e64fc7bd8f14aef Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:31:59 -0700 Subject: [PATCH 007/105] install-source probe hardening and env-scoped storage --- .github/workflows/deploy-mobile.yml | 2 +- README.md | 4 +- apps/community-mobile/APP-REVIEW-NOTES.md | 42 +++++++++++++ apps/community-mobile/README.md | 58 ++++++++++++++---- apps/community-mobile/eas.json | 2 +- apps/community-mobile/scripts/prep-archive.sh | 35 +++++++---- apps/community-mobile/scripts/store-build.sh | 2 +- apps/community-mobile/src/auth/storage.ts | 4 +- apps/community-mobile/src/config.ts | 2 +- apps/community-mobile/src/lib/betaInstall.ts | 20 ------- apps/community-mobile/src/lib/mmkv.ts | 6 +- .../src/lib/nativeBetaInstall.ts | 52 ++++++++++++++++ .../src/lib/nativeSecureStore.ts | 6 +- .../src/lib/storageScope.test.ts | 42 +++++++++++++ apps/community-mobile/src/lib/storageScope.ts | 30 ++++++++++ .../src/lib/storeKitReceipt.test.ts | 31 ++++++++++ .../src/lib/storeKitReceipt.ts | 14 +++++ .../src/lib/webOrigin.test.ts | 12 +++- apps/community-mobile/tests/apiUrl.test.ts | 9 +-- apps/community-mobile/tests/bootShell.test.ts | 60 +++++++++++++------ 20 files changed, 352 insertions(+), 81 deletions(-) delete mode 100644 apps/community-mobile/src/lib/betaInstall.ts create mode 100644 apps/community-mobile/src/lib/nativeBetaInstall.ts create mode 100644 apps/community-mobile/src/lib/storageScope.test.ts create mode 100644 apps/community-mobile/src/lib/storageScope.ts create mode 100644 apps/community-mobile/src/lib/storeKitReceipt.test.ts create mode 100644 apps/community-mobile/src/lib/storeKitReceipt.ts diff --git a/.github/workflows/deploy-mobile.yml b/.github/workflows/deploy-mobile.yml index 9a13a19d..84592b8e 100644 --- a/.github/workflows/deploy-mobile.yml +++ b/.github/workflows/deploy-mobile.yml @@ -6,7 +6,7 @@ name: Deploy mobile (TestFlight) # push to main -> the `testflight` profile -> staging API (api.civfix.dev), baked # manual run, profile=production -> the `production` profile -> API chosen at runtime by install # source: api.civfix.dev in TestFlight, api.civfix.org from the -# App Store (src/lib/apiUrl.ts + src/lib/betaInstall.ts) +# App Store (src/lib/apiUrl.ts + src/lib/nativeBetaInstall.ts) # # Nothing here submits for App Store review. A production build lands in App Store Connect exactly # like a staging one; attaching it to a version and submitting is the manual step, so a prod mobile diff --git a/README.md b/README.md index 673a7a73..d9117aee 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,9 @@ build cannot ship the staging API URL to civfix.org or the production one to the The mobile app deploys through `.github/workflows/deploy-mobile.yml` on the same lane: a push to `main` that touches the app or the packages builds the `testflight` profile (staging API) on a GitHub-hosted Mac and uploads it to App Store Connect, where TestFlight hands it to the internal -testers; a manual run with `profile=production` uploads a prod-API build, and attaching that build to +testers; a manual run with `profile=production` uploads a build that bakes no API URL at all and +picks one at runtime from its iOS install source (`api.civfix.dev` while it is handed out through +TestFlight, `api.civfix.org` once it is downloaded from the App Store), and attaching that build to a version and submitting it for review stays a human step in App Store Connect. The runner executes the same `scripts/store-build.sh` a developer runs locally (`eas build --local` + `eas submit`), so CI and laptop builds share one EAS credential store and one build-number counter. See that app's diff --git a/apps/community-mobile/APP-REVIEW-NOTES.md b/apps/community-mobile/APP-REVIEW-NOTES.md index f0f9728e..8116d28a 100644 --- a/apps/community-mobile/APP-REVIEW-NOTES.md +++ b/apps/community-mobile/APP-REVIEW-NOTES.md @@ -3,6 +3,48 @@ Paste the relevant section into **App Store Connect → App Review Information → Notes** (and into the Play Console reviewer notes where the equivalent field exists). +The **Environment** section below is the exception: it is an internal pre-submission checklist, not +reviewer-facing copy. Do not paste it into App Store Connect. + +--- + +## Environment — App Review runs against STAGING (internal; do not paste) + +**The binary a reviewer runs talks to `api.civfix.dev`, not `api.civfix.org`.** This is deliberate +and needs to be understood before every submission. + +The `production` EAS profile bakes no `EXPO_PUBLIC_API_URL`; the app picks its API at launch from its +iOS install source — a TestFlight/beta install (`StoreKit/sandboxReceipt`) resolves to +`https://api.civfix.dev`, an App Store download (`StoreKit/receipt`) to `https://api.civfix.org` +(`src/lib/apiUrl.ts`, `src/lib/nativeBetaInstall.ts`; the mechanism is written up in `README.md`). +App Review installs through the beta/sandbox path — the same fact behind StoreKit's 21007 sandbox +receipt status — so the reviewer's copy sees a sandbox receipt and runs against **staging**. + +Consequences, accepted knowingly: the approval verdict is rendered against a binary whose production +behaviour was never exercised by the reviewer, and anything the reviewer is asked to find has to exist +on staging. There is no OTA update channel in this project that could flip an override for review +only, and routing beta installs to staging is the property this app deliberately wants — testers must +never write to the live civic record. + +**Pre-submission checklist — all of these are about the STAGING environment:** + +- [ ] `api.civfix.dev` is up and healthy (`/readyz`), and staging is on the same commit as the build + being submitted. +- [ ] Staging carries reviewer-visible demo content: at least one event, organization or profile + that shows a **Donate** card, so the "Reviewer steps" under *External donation links* can + actually be followed. Without it the reviewer finds nothing and the note reads as false. +- [ ] Staging carries a demo event with a ticket QR, so the check-in scanner note can be followed. +- [ ] A working reviewer sign-in exists on staging and the credentials in App Store Connect → + App Review Information match it. **This is currently missing:** `REVIEWER_OTP_BYPASS` and + `REVIEWER_OTP_CODE` are absent from `civfix-infra/secrets/staging/api.sops.env` (and from the + prod one), so the reviewer-OTP path is fail-closed on staging today. Add both to the staging + SOPS file and redeploy before submitting, or give the reviewer an account whose OTP they can + actually receive. +- [ ] Share links opened from the reviewer's build point at `civfix.dev` and resolve there; iOS + universal links are pinned to `civfix.org` only, so those links open in the browser rather than + deep-linking back into the app. Harmless, but do not write a reviewer step that depends on a + share link re-entering the app. + --- ## External donation links diff --git a/apps/community-mobile/README.md b/apps/community-mobile/README.md index a5f96efe..1a68813d 100644 --- a/apps/community-mobile/README.md +++ b/apps/community-mobile/README.md @@ -69,7 +69,7 @@ pnpm --filter community-mobile exec expo run:ios # or run:android Then start the bundler with `pnpm --filter community-mobile start` (runs `expo start --dev-client`). -## Store builds: TestFlight (staging API) vs App Store (prod API) +## Store-distribution profiles: `testflight` vs `production` Two store-distribution profiles exist in `apps/community-mobile/eas.json`, differing only in the baked API base URL and their EAS Update channel: @@ -80,20 +80,50 @@ baked API base URL and their EAS Update channel: - `production` - official App Store releases. Sets no `EXPO_PUBLIC_API_URL`, so the base URL is chosen at RUNTIME by install source: `https://api.civfix.dev` while that build is handed out through TestFlight, `https://api.civfix.org` once the same build is downloaded from the App Store - (`src/lib/apiUrl.ts`, `src/lib/betaInstall.ts`). Update channel `production`. + (`src/lib/apiUrl.ts`, `src/lib/nativeBetaInstall.ts`). Update channel `production`. -### How the runtime split is decided (iOS) +### How the runtime split is decided (iOS only) + +**This split exists on iOS and nowhere else.** `src/lib/nativeBetaInstall.ts` returns `false` for any +other platform, so an Android release build - including one handed to internal-track testers - always +resolves to the production API. Android's own testing tracks have no equivalent on-device marker. iOS ships the App Store and TestFlight copies of a build with different StoreKit receipts: a store download gets `StoreKit/receipt` in the app's data container, a TestFlight install gets -`StoreKit/sandboxReceipt`. `src/lib/betaInstall.ts` reads those two paths synchronously through +`StoreKit/sandboxReceipt`. `src/lib/nativeBetaInstall.ts` reads those two paths synchronously through `expo-file-system`, so `API_URL` is a plain module constant and one session can never straddle two APIs. `expo-application`'s `getIosApplicationReleaseTypeAsync()` cannot make this call: it reads the embedded provisioning profile, which reports `APP_STORE` for TestFlight and App Store alike. -Precedence is `EXPO_PUBLIC_API_URL` (when baked) -> `__DEV__` localhost -> the receipt probe. The -probe checks the store receipt FIRST, so a store download can never be routed to staging; anything -it cannot read falls through to production. +Precedence is `EXPO_PUBLIC_API_URL` (when baked) -> `__DEV__` localhost -> the receipt probe. + +**Both receipts can be on disk at once.** Moving between TestFlight and the App Store is an in-place +update and the previous receipt is not removed, so mere presence decides nothing: the **newer** file +wins (`src/lib/storeKitReceipt.ts`). Everything ambiguous resolves to production - no sandbox receipt, +a tie, an unreadable modification time, an unreadable container, a probe that throws: all `false`. A +store download therefore cannot be routed to staging by any failure mode of this probe. + +**The container path is an assumption.** The probe reconstructs `Bundle.main.appStoreReceiptURL` as +`/StoreKit/`, derived from `Paths.document.parentDirectory`. If Apple ever +changes that layout the probe goes permanently `false` - production for everyone, which is the safe +direction but silent. A dev build logs the resolved container and both receipt stats under +`[install-source]` so the assumption can be checked on a real device. + +**Persisted state is scoped to the API it was written against** (`src/lib/storageScope.ts`): the MMKV +instances, the session token in the keychain and the secure-blob key all carry the API host as a +suffix - except production, which deliberately keeps the legacy un-suffixed ids so existing App Store +users are not signed out by this change. Without that scoping, a tester moving from TestFlight to the +App Store would carry a staging session and a staging cache into a production session. + +**Share links do not follow the split.** `app.config.js` `ios.associatedDomains` pins +`applinks:civfix.org` / `applinks:www.civfix.org` only, so a `civfix.dev` link produced by a +staging-resolved build opens in the browser rather than deep-linking into the app. Universal links +work only against the production domain; adding `civfix.dev` would need a new entitlement and a +matching apple-app-site-association file on that host. + +**App Review runs against staging.** A reviewer's copy is installed through the beta/sandbox path, so +the probe returns `true` and review sessions hit `api.civfix.dev`. That is a deliberate, recorded +property - see the "Environment" section of `APP-REVIEW-NOTES.md` and its pre-submission checklist. ### Hand-driven Xcode archive (`scripts/prep-archive.sh`) @@ -107,8 +137,8 @@ read `.env` at *archive* time. So prep the native project with the target's `.env` in place, then archive by hand: ```sh -pnpm --filter community-mobile prep:testflight # -> https://api.civfix.dev -pnpm --filter community-mobile prep:appstore # -> https://api.civfix.org +pnpm --filter community-mobile prep:testflight # -> bakes https://api.civfix.dev +pnpm --filter community-mobile prep:appstore # -> bakes NOTHING; resolved at runtime apps/community-mobile/scripts/prep-archive.sh appstore --platform android ``` @@ -130,9 +160,11 @@ for every third-party dependency. Then: open `ios/civfix.xcworkspace`, destinati Two things this script exists to stop: -- `.env` is gitignored and *persists*. A `testflight` prep silently governs every later local build - on that machine, so the `appstore` target writes `https://api.civfix.org` **explicitly** rather - than leaning on the fallback in `src/lib/apiUrl.ts`. Re-run the script before switching targets. +- `.env` is gitignored and *persists*. A `testflight` prep would otherwise silently govern every + later local build on that machine. The script **rewrites `.env` on every run**, so the `appstore` + target's omission of `EXPO_PUBLIC_API_URL` is a real omission and not a leftover - and the banner + refuses to let you archive unless the built config carries **no `apiUrl` at all**. Baking one there + would freeze the runtime split to a single API. Re-run the script before switching targets. - `expo prebuild` deletes `DEVELOPMENT_TEAM` from the pbxproj on every run unless `ios.appleTeamId` is set, so a team picked by hand in Xcode's Signing & Capabilities pane vanishes on the next prebuild. The script reads the existing team back out and feeds it in via `CIVFIX_APPLE_TEAM_ID`. @@ -157,7 +189,7 @@ Android ships by hand from this directory: prep the target, re-apply the machine Temurin 25). ```sh -scripts/prep-archive.sh testflight --platform android # or appstore -> https://api.civfix.org +scripts/prep-archive.sh testflight --platform android # or appstore; Android always resolves to https://api.civfix.org scripts/android-release-patches.sh export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-22.jdk/Contents/Home cd android && ./gradlew --no-daemon :app:assembleRelease # or :app:bundleRelease for the Play .aab diff --git a/apps/community-mobile/eas.json b/apps/community-mobile/eas.json index d49674c3..ead4225d 100644 --- a/apps/community-mobile/eas.json +++ b/apps/community-mobile/eas.json @@ -58,7 +58,7 @@ "channel": "production", "autoIncrement": true, "env": { - "//api": "Deliberately sets no EXPO_PUBLIC_API_URL. src/lib/apiUrl.ts resolves the base URL at runtime from the active StoreKit receipt, so ONE binary talks to https://api.civfix.dev while it is under test in TestFlight and to https://api.civfix.org once the same build is downloaded from the App Store. Baking a URL here would freeze it to one of the two.", + "//api": "Deliberately sets no EXPO_PUBLIC_API_URL. On iOS ONLY, src/lib/apiUrl.ts resolves the base URL at runtime from the active StoreKit receipt, so one binary talks to https://api.civfix.dev while it is under test in TestFlight and to https://api.civfix.org once the same build is downloaded from the App Store. Every other platform resolves to https://api.civfix.org, so an Android internal-track tester is on production. Baking a URL here would freeze iOS to one of the two.", "//dsym": "iOS links React Native's prebuilt core frameworks (buildReactNativeFromSource: false in app.config.js), so App Store Connect's 'Upload Symbols Failed' warning for React.framework / ReactNativeDependencies.framework (alongside hermes and MapLibre) is expected and non-blocking.", "//donate": "How a host's external donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. The link is the host's own third-party page; no payment happens anywhere in civfix. OTA-flippable with `eas update` if App Review ever objects to the in-app browser (see APP-REVIEW-NOTES.md).", "EXPO_PUBLIC_DONATE_BROWSER_MODE": "in-app", diff --git a/apps/community-mobile/scripts/prep-archive.sh b/apps/community-mobile/scripts/prep-archive.sh index 9e63a5a8..7792c894 100755 --- a/apps/community-mobile/scripts/prep-archive.sh +++ b/apps/community-mobile/scripts/prep-archive.sh @@ -2,8 +2,10 @@ # Prepare the native iOS project for a HAND-DRIVEN Xcode archive (Product > Archive), with the right # API base URL baked in. Does not build or upload anything. # -# scripts/prep-archive.sh testflight dev/testing build -> staging API https://api.civfix.dev -# scripts/prep-archive.sh appstore App Store release -> prod API https://api.civfix.org +# scripts/prep-archive.sh testflight dev/testing build -> staging API https://api.civfix.dev, baked +# scripts/prep-archive.sh appstore App Store release -> no baked API URL; the app resolves it at +# runtime from the install source (api.civfix.dev in TestFlight, +# api.civfix.org from the App Store) # scripts/prep-archive.sh --platform android same, for the gradle release build # # WHY THIS EXISTS. `eas.json` build profiles are read by `eas build` ONLY. A raw Xcode archive never @@ -15,9 +17,12 @@ # writes `.env`, then regenerates the native project from it. # # `.env` is gitignored and persists, so it silently governs every later local build too. That is -# exactly how a TestFlight prep would otherwise poison a subsequent App Store archive, which is why -# the appstore target writes the prod URL EXPLICITLY instead of leaning on the fallback in -# src/lib/apiUrl.ts, and why the banner below prints the baked URL read back off the built config. +# exactly how a TestFlight prep would otherwise poison a subsequent App Store archive. This script +# REWRITES `.env` on every run, so the appstore target's omission of EXPO_PUBLIC_API_URL is a real +# omission rather than a leftover: a prior testflight prep cannot survive into it. The banner below +# then reads the baked config back off the built app config and refuses the archive unless it matches +# the target - an appstore archive must carry NO apiUrl at all, or the runtime install-source split in +# src/lib/apiUrl.ts is frozen to whichever URL got baked. set -euo pipefail cd "$(dirname "$0")/.." @@ -30,7 +35,7 @@ usage() { target="${1:-}" case "$target" in testflight) api_url="https://api.civfix.dev"; channel="testflight" ;; - appstore) api_url="https://api.civfix.org"; channel="production" ;; + appstore) api_url=""; channel="production" ;; *) usage ;; esac @@ -62,18 +67,24 @@ if [ "$platform" = "ios" ]; then command -v xcodebuild >/dev/null 2>&1 || { echo "xcodebuild not found. Install Xcode + command-line tools." >&2; exit 1; } fi +if [ -n "$api_url" ]; then + api_line="EXPO_PUBLIC_API_URL=${api_url}" +else + api_line="# No EXPO_PUBLIC_API_URL on purpose: src/lib/apiUrl.ts resolves it at runtime from the install source." +fi + # Keep the Google values in lockstep with eas.json so a local archive and an EAS build of the same # target are byte-for-byte equivalent in configuration. These are public client ids, not secrets. cat > .env <"}; ` + + `${APP_STORE_RECEIPT}=${JSON.stringify(probe.store)}, ` + + `${SANDBOX_RECEIPT}=${JSON.stringify(probe.sandbox)} -> beta=${beta}`, + ) + } + return beta +} diff --git a/apps/community-mobile/src/lib/nativeSecureStore.ts b/apps/community-mobile/src/lib/nativeSecureStore.ts index 09fc5535..9c59deaa 100644 --- a/apps/community-mobile/src/lib/nativeSecureStore.ts +++ b/apps/community-mobile/src/lib/nativeSecureStore.ts @@ -4,9 +4,11 @@ import * as Crypto from "expo-crypto" import type { SecureStoreCapability } from "@civfix/ui/capabilities" import type { KeyValueStore } from "@/lib/mmkv" import { mintSecureBlobKey, normalizeSecureBlobKey } from "@/lib/secureBlobKey" +import { scopeStorageId } from "@/lib/storageScope" +import { API_URL } from "@/config" -const ENCRYPTION_KEY_ITEM = "civfix.secure-blobs.key" -const INSTANCE_ID = "civfix.secure" +const ENCRYPTION_KEY_ITEM = scopeStorageId("civfix.secure-blobs.key", API_URL) +const INSTANCE_ID = scopeStorageId("civfix.secure", API_URL) const OPTIONS: SecureStore.SecureStoreOptions = { keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, diff --git a/apps/community-mobile/src/lib/storageScope.test.ts b/apps/community-mobile/src/lib/storageScope.test.ts new file mode 100644 index 00000000..8ca73f2f --- /dev/null +++ b/apps/community-mobile/src/lib/storageScope.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { scopeStorageId, storageNamespace } from "./storageScope.ts" +import { DEV_API_URL, PROD_API_URL, STAGING_API_URL } from "./apiUrl.ts" + +test("a production build keeps the legacy ids, so an existing install is not signed out", () => { + assert.equal(storageNamespace(PROD_API_URL), "") + assert.equal(scopeStorageId("civfix.app", PROD_API_URL), "civfix.app") + assert.equal(scopeStorageId("civfix.session.token", PROD_API_URL), "civfix.session.token") + assert.equal(scopeStorageId("civfix.secure", PROD_API_URL), "civfix.secure") +}) + +test("a trailing slash or stray whitespace is still production", () => { + assert.equal(scopeStorageId("civfix.app", "https://api.civfix.org/"), "civfix.app") + assert.equal(scopeStorageId("civfix.app", " https://api.civfix.org "), "civfix.app") + assert.equal(scopeStorageId("civfix.app", "https://api.civfix.org/v1"), "civfix.app") +}) + +test("a staging build gets its own namespace", () => { + assert.equal(storageNamespace(STAGING_API_URL), "api-civfix-dev") + assert.equal(scopeStorageId("civfix.app", STAGING_API_URL), "civfix.app.api-civfix-dev") + assert.equal( + scopeStorageId("civfix.session.token", STAGING_API_URL), + "civfix.session.token.api-civfix-dev", + ) +}) + +test("a local dev build is namespaced too, port included", () => { + assert.equal(storageNamespace(DEV_API_URL), "localhost-8080") + assert.equal(scopeStorageId("civfix.app", DEV_API_URL), "civfix.app.localhost-8080") +}) + +test("every API base URL the resolver can choose maps to a distinct namespace", () => { + const namespaces = [DEV_API_URL, STAGING_API_URL, PROD_API_URL].map(storageNamespace) + assert.equal(new Set(namespaces).size, namespaces.length) +}) + +test("an unrecognised or unparseable base URL never borrows the production namespace", () => { + for (const bogus of ["", " ", "not a url", "https://api.evil.com", "https://api.civfix.org.evil.com"]) { + assert.notEqual(storageNamespace(bogus), "") + } +}) diff --git a/apps/community-mobile/src/lib/storageScope.ts b/apps/community-mobile/src/lib/storageScope.ts new file mode 100644 index 00000000..f7344349 --- /dev/null +++ b/apps/community-mobile/src/lib/storageScope.ts @@ -0,0 +1,30 @@ +const PROD_API_HOST = "api.civfix.org" + +const LEGACY_NAMESPACE = "" + +const UNRESOLVED_HOST = "unknown" + +function hostSlug(apiUrl: string): string { + const value = apiUrl.trim() + let host = value + try { + host = new URL(value).host + } catch { + host = value + } + const slug = host + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + return slug === "" ? UNRESOLVED_HOST : slug +} + +export function storageNamespace(apiUrl: string): string { + const slug = hostSlug(apiUrl) + return slug === hostSlug(PROD_API_HOST) ? LEGACY_NAMESPACE : slug +} + +export function scopeStorageId(id: string, apiUrl: string): string { + const namespace = storageNamespace(apiUrl) + return namespace === LEGACY_NAMESPACE ? id : `${id}.${namespace}` +} diff --git a/apps/community-mobile/src/lib/storeKitReceipt.test.ts b/apps/community-mobile/src/lib/storeKitReceipt.test.ts new file mode 100644 index 00000000..69d53220 --- /dev/null +++ b/apps/community-mobile/src/lib/storeKitReceipt.test.ts @@ -0,0 +1,31 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { RECEIPT_ABSENT, betaInstallFromReceipts, type ReceiptStat } from "./storeKitReceipt.ts" + +function present(modifiedAt: number | null): ReceiptStat { + return { present: true, modifiedAt } +} + +test("no sandbox receipt means the build was never handed out through TestFlight", () => { + assert.equal(betaInstallFromReceipts(RECEIPT_ABSENT, RECEIPT_ABSENT), false) + assert.equal(betaInstallFromReceipts(present(1_000), RECEIPT_ABSENT), false) +}) + +test("a sandbox receipt with no store receipt is a TestFlight install", () => { + assert.equal(betaInstallFromReceipts(RECEIPT_ABSENT, present(1_000)), true) +}) + +test("with both receipts on disk the newer one wins, in either direction", () => { + assert.equal(betaInstallFromReceipts(present(1_000), present(2_000)), true) + assert.equal(betaInstallFromReceipts(present(2_000), present(1_000)), false) +}) + +test("a tie resolves to the App Store, never to staging", () => { + assert.equal(betaInstallFromReceipts(present(1_000), present(1_000)), false) +}) + +test("an unreadable timestamp falls back to the store receipt rather than guessing staging", () => { + assert.equal(betaInstallFromReceipts(present(null), present(2_000)), false) + assert.equal(betaInstallFromReceipts(present(1_000), present(null)), false) + assert.equal(betaInstallFromReceipts(present(null), present(null)), false) +}) diff --git a/apps/community-mobile/src/lib/storeKitReceipt.ts b/apps/community-mobile/src/lib/storeKitReceipt.ts new file mode 100644 index 00000000..a9d79127 --- /dev/null +++ b/apps/community-mobile/src/lib/storeKitReceipt.ts @@ -0,0 +1,14 @@ +export const STORE_KIT_DIR = "StoreKit" +export const APP_STORE_RECEIPT = "receipt" +export const SANDBOX_RECEIPT = "sandboxReceipt" + +export type ReceiptStat = { present: boolean; modifiedAt: number | null } + +export const RECEIPT_ABSENT: ReceiptStat = { present: false, modifiedAt: null } + +export function betaInstallFromReceipts(store: ReceiptStat, sandbox: ReceiptStat): boolean { + if (!sandbox.present) return false + if (!store.present) return true + if (store.modifiedAt === null || sandbox.modifiedAt === null) return false + return sandbox.modifiedAt > store.modifiedAt +} diff --git a/apps/community-mobile/src/lib/webOrigin.test.ts b/apps/community-mobile/src/lib/webOrigin.test.ts index 1bc53277..cc9fcf97 100644 --- a/apps/community-mobile/src/lib/webOrigin.test.ts +++ b/apps/community-mobile/src/lib/webOrigin.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test" import assert from "node:assert/strict" import { PROD_WEB_ORIGIN, resolveWebOrigin } from "./webOrigin.ts" -import { PROD_API_URL, STAGING_API_URL } from "./apiUrl.ts" +import { DEV_API_URL, PROD_API_URL, STAGING_API_URL } from "./apiUrl.ts" test("a build pointed at the staging API shares links to the staging site", () => { assert.equal(resolveWebOrigin("https://api.civfix.dev"), "https://civfix.dev") @@ -20,8 +20,14 @@ test("a local or unrecognised API falls back to the production site", () => { }) test("every API base URL the resolver can choose has a mapped share origin", () => { - assert.equal(resolveWebOrigin(STAGING_API_URL), "https://civfix.dev") - assert.equal(resolveWebOrigin(PROD_API_URL), PROD_WEB_ORIGIN) + const originFor: Readonly> = { + [DEV_API_URL]: PROD_WEB_ORIGIN, + [STAGING_API_URL]: "https://civfix.dev", + [PROD_API_URL]: PROD_WEB_ORIGIN, + } + for (const [apiUrl, origin] of Object.entries(originFor)) { + assert.equal(resolveWebOrigin(apiUrl), origin) + } }) test("never reflects an arbitrary api.* host into a trusted link origin", () => { diff --git a/apps/community-mobile/tests/apiUrl.test.ts b/apps/community-mobile/tests/apiUrl.test.ts index c857c87d..8a81c7b7 100644 --- a/apps/community-mobile/tests/apiUrl.test.ts +++ b/apps/community-mobile/tests/apiUrl.test.ts @@ -15,7 +15,7 @@ import { test } from "node:test" import assert from "node:assert/strict" -import { resolveApiUrl, DEV_API_URL, STAGING_API_URL, PROD_API_URL } from "../src/lib/apiUrl.ts" +import { resolveApiUrl, DEV_API_URL, PROD_API_URL } from "../src/lib/apiUrl.ts" test("an empty object from the baked config falls back - the shipped-outage case", () => { // `??` would have returned the object here. Everything else in this file is scaffolding; this is @@ -59,7 +59,6 @@ test("a release build never resolves to localhost, and a dev build never to prod }) test("an unbaked release installed from TestFlight resolves to the staging API", () => { - assert.equal(resolveApiUrl(undefined, false, true), STAGING_API_URL) assert.equal(resolveApiUrl(undefined, false, true), "https://api.civfix.dev") }) @@ -77,9 +76,3 @@ test("a baked URL still wins over the runtime install signal", () => { assert.equal(resolveApiUrl("https://api.civfix.dev", false, false), "https://api.civfix.dev") assert.equal(resolveApiUrl("https://api.civfix.org", false, true), "https://api.civfix.org") }) - -test("the three environment defaults stay distinct", () => { - assert.notEqual(DEV_API_URL, STAGING_API_URL) - assert.notEqual(STAGING_API_URL, PROD_API_URL) - assert.notEqual(DEV_API_URL, PROD_API_URL) -}) diff --git a/apps/community-mobile/tests/bootShell.test.ts b/apps/community-mobile/tests/bootShell.test.ts index d3c9c606..5b8539d9 100644 --- a/apps/community-mobile/tests/bootShell.test.ts +++ b/apps/community-mobile/tests/bootShell.test.ts @@ -6,7 +6,17 @@ const layout = readFileSync(new URL("../app/_layout.tsx", import.meta.url), "utf const home = readFileSync(new URL("../app/index.tsx", import.meta.url), "utf8") const config = readFileSync(new URL("../src/config.ts", import.meta.url), "utf8") const apiUrlModule = readFileSync(new URL("../src/lib/apiUrl.ts", import.meta.url), "utf8") -const betaInstallModule = readFileSync(new URL("../src/lib/betaInstall.ts", import.meta.url), "utf8") +const betaInstallModule = readFileSync( + new URL("../src/lib/nativeBetaInstall.ts", import.meta.url), + "utf8", +) +const storeKitModule = readFileSync(new URL("../src/lib/storeKitReceipt.ts", import.meta.url), "utf8") +const mmkvModule = readFileSync(new URL("../src/lib/mmkv.ts", import.meta.url), "utf8") +const secureStoreModule = readFileSync( + new URL("../src/lib/nativeSecureStore.ts", import.meta.url), + "utf8", +) +const authStorage = readFileSync(new URL("../src/auth/storage.ts", import.meta.url), "utf8") const appConfig = readFileSync(new URL("../app.config.js", import.meta.url), "utf8") test("the root layout exports an ErrorBoundary so expo-router can catch a boot crash", () => { @@ -68,23 +78,39 @@ test("the base URL goes through the guarded resolver, never a bare ?? on the bak assert.match(apiUrlModule, /typeof configured === "string"/) }) -test("the install probe reads the ACTIVE StoreKit receipt, store receipt winning over the sandbox one", () => { +test("the install probe is iOS-only and reads the ACTIVE StoreKit receipt", () => { assert.match(betaInstallModule, /if \(Platform\.OS !== "ios"\) return false/) - const body = betaInstallModule.slice(betaInstallModule.indexOf("export function isBetaInstall")) - const store = body.indexOf("storeKitReceiptPresent(APP_STORE_RECEIPT)") - const sandbox = body.indexOf("storeKitReceiptPresent(SANDBOX_RECEIPT)") - assert.ok(store > -1 && sandbox > store) - assert.match(betaInstallModule, /export const APP_STORE_RECEIPT = "receipt"/) - assert.match(betaInstallModule, /export const SANDBOX_RECEIPT = "sandboxReceipt"/) -}) - -test("the probe resolves the StoreKit folder from the app data container, not the bundle", () => { - assert.match(betaInstallModule, /Paths\.document\.parentDirectory, STORE_KIT_DIR, name/) - assert.doesNotMatch(betaInstallModule, /Paths\.bundle/) -}) - -test("an unreadable install probe degrades to the production API rather than throwing at boot", () => { - assert.match(betaInstallModule, /} catch \{\n\s+return false\n\s+}/) + assert.match(betaInstallModule, /betaInstallFromReceipts\(probe\.store, probe\.sandbox\)/) + assert.match(betaInstallModule, /Paths\.document\.parentDirectory/) + assert.match(storeKitModule, /export const APP_STORE_RECEIPT = "receipt"/) + assert.match(storeKitModule, /export const SANDBOX_RECEIPT = "sandboxReceipt"/) +}) + +test("the receipt decision runs store-first, and only a NEWER sandbox receipt means staging", () => { + assert.match(storeKitModule, /if \(!sandbox\.present\) return false/) + assert.match(storeKitModule, /if \(!store\.present\) return true/) + assert.match( + storeKitModule, + /if \(store\.modifiedAt === null \|\| sandbox\.modifiedAt === null\) return false/, + ) + assert.match(storeKitModule, /return sandbox\.modifiedAt > store\.modifiedAt/) +}) + +test("expo-file-system is required inside the guarded probe, never imported into the boot chain", () => { + assert.doesNotMatch(betaInstallModule, /^import .*"expo-file-system"/m) + const probe = betaInstallModule.slice(betaInstallModule.indexOf("function probeStoreKit")) + const guard = probe.indexOf("try {") + const load = probe.indexOf('require("expo-file-system")') + assert.ok(guard > -1 && load > guard) + assert.match(probe, /catch \{\s*return PROBE_UNAVAILABLE\s*}/) + assert.match(betaInstallModule, /const PROBE_UNAVAILABLE: StoreKitProbe = \{\s*dir: null,/) +}) + +test("persisted state is scoped to the API environment, production keeping the legacy ids", () => { + assert.match(mmkvModule, /scopeStorageId\("civfix\.app", API_URL\)/) + assert.match(secureStoreModule, /scopeStorageId\("civfix\.secure-blobs\.key", API_URL\)/) + assert.match(secureStoreModule, /scopeStorageId\("civfix\.secure", API_URL\)/) + assert.match(authStorage, /scopeStorageId\("civfix\.session\.token", API_URL\)/) }) test("the app config OMITS apiUrl when unset rather than baking a null Expo turns into {}", () => { From 02c4cbaa7806956405d2e50aa5f359709a0aaddd Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:21:22 -0700 Subject: [PATCH 008/105] render the shell below a pushed route from its own nav stack --- apps/community-mobile/app/index.tsx | 17 +-- .../src/components/DetailRouteHost.tsx | 69 +++++++----- .../src/lib/nestedShellSignal.test.ts | 103 ++++++++++++------ .../src/lib/nestedShellSignal.ts | 50 +++++---- packages/ui/src/shell/AppShell.tsx | 14 ++- packages/ui/src/shell/ExpandedShell.tsx | 12 +- .../ui/src/shell/PortraitShell.shared.tsx | 5 +- .../ui/src/shell/SearchBodyReveal.native.tsx | 4 +- .../__tests__/shellStackOverride.test.ts | 62 +++++++++++ packages/ui/src/shell/types.ts | 1 + 10 files changed, 233 insertions(+), 104 deletions(-) create mode 100644 packages/ui/src/shell/__tests__/shellStackOverride.test.ts diff --git a/apps/community-mobile/app/index.tsx b/apps/community-mobile/app/index.tsx index 481c0dc9..d2d67404 100644 --- a/apps/community-mobile/app/index.tsx +++ b/apps/community-mobile/app/index.tsx @@ -26,13 +26,11 @@ import { shouldAdoptCenter, PRECISE_ZOOM, APPROX_ZOOM, - type DetailEntry, type MapCenterSource, type MapCenterTarget, type MapHandle, type MapProps, type RememberedCenter, - type View as NavView, } from "@civfix/ui" import { queryKeys, @@ -44,8 +42,9 @@ import { import { useHaptics } from "@civfix/ui/capabilities" import { mobileHostMapPlan } from "@/components/hostMapPlan" import { + ROOT_SHELL_ID, clearNestedShellHosts, - nestedShellBodyEntry, + shellStackBelow, useNestedShellStore, } from "@/lib/nestedShellSignal" import { LocationPrimerSheet } from "@/components/LocationPrimerSheet" @@ -520,19 +519,13 @@ export default function MapHomeScreen() { focusedCleanupId, ]) - const nestedShell = useNestedShellStore() - const renderRootBody = useCallback( - (bodyEntry: DetailEntry | null, view: NavView): React.ReactNode => { - const plan = nestedShellBodyEntry(nestedShell, bodyEntry) - return plan.render ? defaultRenderBody(plan.entry, view) : null - }, - [nestedShell], - ) + const ownedStack = useNestedShellStore((s) => shellStackBelow(s, ROOT_SHELL_ID)) return ( <> s.stack.length <= 1) + const ownedStack = useNestedShellStore((s) => shellStackBelow(s, hostId)) + const liveStackLength = useNavStore((s) => s.stack.length) + const nativeGestureOwnsBack = (ownedStack ? ownedStack.length : liveStackLength) <= 1 const entryRef = useRef(entry) entryRef.current = entry @@ -48,23 +56,38 @@ export default function DetailRouteHost({ entry }: DetailRouteHostProps): React. focusedBridgeKeyRef.current = focusedBridgeKey useLayoutEffect(() => { - if (!entry) return if (restoreRef.current === null) { restoreRef.current = useNavStore .getState() .stack.filter((e) => entryIdentity(e) !== seedKey) teardownEpochRef.current = navTeardownEpoch() } - seedEntry(entry) + enterNestedShell(hostId, restoreRef.current) + if (entry) seedEntry(entry) // eslint-disable-next-line react-hooks/exhaustive-deps }, [seedKey]) useEffect(() => { - const restore = restoreRef.current - enterNestedShell( - hostId, - restore && restore.length > 0 ? (restore[restore.length - 1] ?? null) : null, - ) + return () => { + const restore = restoreRef.current + restoreRef.current = null + if (restore === null) return + const state = useNavStore.getState() + const plan = detailRestorePlan({ + restore, + seedKey: seedKeyRef.current, + activeKey: entryIdentity(state.active), + stackLength: state.stack.length, + left: leftRef.current, + tornDown: navTeardownEpoch() !== teardownEpochRef.current, + focusedBridgeKey: focusedBridgeKeyRef.current(), + }) + if (plan.type === "restore") state.setStack(plan.stack) + else if (plan.type === "clear") state.setStack([]) + } + }, []) + + useEffect(() => { return () => exitNestedShell(hostId) }, [hostId]) @@ -118,31 +141,17 @@ export default function DetailRouteHost({ entry }: DetailRouteHostProps): React. }, [router]), ) - useEffect(() => { - return () => { - const restore = restoreRef.current - restoreRef.current = null - if (restore === null) return - const state = useNavStore.getState() - const plan = detailRestorePlan({ - restore, - seedKey: seedKeyRef.current, - activeKey: entryIdentity(state.active), - stackLength: state.stack.length, - left: leftRef.current, - tornDown: navTeardownEpoch() !== teardownEpochRef.current, - focusedBridgeKey: focusedBridgeKeyRef.current(), - }) - if (plan.type === "restore") state.setStack(plan.stack) - else if (plan.type === "clear") state.setStack([]) - } - }, []) - return ( <> - + ) diff --git a/apps/community-mobile/src/lib/nestedShellSignal.test.ts b/apps/community-mobile/src/lib/nestedShellSignal.test.ts index 720c8303..233b0118 100644 --- a/apps/community-mobile/src/lib/nestedShellSignal.test.ts +++ b/apps/community-mobile/src/lib/nestedShellSignal.test.ts @@ -1,39 +1,79 @@ import assert from "node:assert/strict" import { test } from "node:test" +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" import type { DetailEntry } from "@civfix/ui" import { NO_NESTED_SHELL, - nestedShellBodyEntry, + ROOT_SHELL_ID, rootIsTopRoute, + shellStackBelow, withNestedShellHost, withoutNestedShellHost, withoutNestedShellHosts, } from "./nestedShellSignal.ts" +const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), "..") +const APP_DIR = join(SRC_DIR, "..", "app") + const cleanup = { kind: "cleanup", id: "c1" } as DetailEntry const org = { kind: "org", slug: "acme" } as DetailEntry +const person = { kind: "person", id: "u1" } as DetailEntry + +test("with no host mounted every shell reads the live store", () => { + assert.equal(shellStackBelow(NO_NESTED_SHELL, ROOT_SHELL_ID), null) + assert.equal(shellStackBelow(NO_NESTED_SHELL, "h1"), null) +}) + +test("the root renders the stack it owned when the first host took over", () => { + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]) + assert.deepEqual(shellStackBelow(state, ROOT_SHELL_ID), [cleanup]) +}) + +test("a root that owned no detail renders an EMPTY stack, never the host's entry", () => { + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", []) + assert.deepEqual(shellStackBelow(state, ROOT_SHELL_ID), []) +}) -test("a host ABOVE the root defers the root body to the entry the root held before it", () => { - const state = withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup) - assert.deepEqual(nestedShellBodyEntry(state, org), { render: true, entry: cleanup }) +test("the top host reads the live store, and every host below it reads its own snapshot", () => { + const one = withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]) + const two = withNestedShellHost(one, "h2", [org]) + assert.deepEqual(shellStackBelow(two, ROOT_SHELL_ID), [cleanup]) + assert.deepEqual(shellStackBelow(two, "h1"), [org]) + assert.equal(shellStackBelow(two, "h2"), null) }) -test("a host above the root with an empty snapshot renders no root body", () => { - const state = withNestedShellHost(NO_NESTED_SHELL, "h1", null) - assert.deepEqual(nestedShellBodyEntry(state, cleanup), { render: false }) +test("a host that has not registered yet reads the live store", () => { + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]) + assert.equal(shellStackBelow(state, "unregistered"), null) }) -test("a host left BELOW the root by a POP_TO replace stops deferring the root body", () => { - const mounted = withNestedShellHost(NO_NESTED_SHELL, "h1", null) - assert.deepEqual(nestedShellBodyEntry(mounted, cleanup), { render: false }) - const rootFocused = withoutNestedShellHosts(mounted) - assert.deepEqual(rootFocused, NO_NESTED_SHELL) - assert.deepEqual(nestedShellBodyEntry(rootFocused, cleanup), { render: true, entry: cleanup }) +test("a snapshot is returned by reference, so a selector does not churn the shell", () => { + const snapshot = [cleanup] + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", snapshot) + assert.equal(shellStackBelow(state, ROOT_SHELL_ID), snapshot) +}) + +test("hosts are counted by identity, so a double exit cannot unfreeze a shell still covered", () => { + const two = withNestedShellHost(withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]), "h2", [org]) + const once = withoutNestedShellHost(two, "h2") + assert.deepEqual( + once.hosts.map((host) => host.id), + ["h1"], + ) + assert.deepEqual(withoutNestedShellHost(once, "h2"), once) + assert.deepEqual(shellStackBelow(once, ROOT_SHELL_ID), [cleanup]) + assert.deepEqual(withoutNestedShellHost(once, "h1"), NO_NESTED_SHELL) +}) + +test("re-entering with the same host id keeps the first snapshot", () => { + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]) + assert.equal(withNestedShellHost(state, "h1", [person]), state) }) test("the root focusing clears every host, since nothing can sit above a focused root", () => { - const two = withNestedShellHost(withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup), "h2", null) - assert.deepEqual(two.hosts, ["h1", "h2"]) + const two = withNestedShellHost(withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]), "h2", [org]) assert.equal(withoutNestedShellHosts(two).hosts.length, 0) assert.equal(withoutNestedShellHosts(NO_NESTED_SHELL), NO_NESTED_SHELL) }) @@ -42,29 +82,26 @@ test("a blurred host only leaves the signal when the ROOT is the top route", () assert.equal(rootIsTopRoute({ name: "index" }), true) assert.equal(rootIsTopRoute({ name: "cleanups/[id]" }), false) assert.equal(rootIsTopRoute({ name: "cleanups/[id]/ticket/index" }), false) - assert.equal(rootIsTopRoute({ name: "auth" }), false) assert.equal(rootIsTopRoute({}), false) assert.equal(rootIsTopRoute(null), false) assert.equal(rootIsTopRoute(undefined), false) }) -test("hosts are counted by identity, so a double exit cannot unblank a shell still above the root", () => { - const two = withNestedShellHost(withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup), "h2", null) - const once = withoutNestedShellHost(two, "h1") - assert.deepEqual(once.hosts, ["h2"]) - assert.deepEqual(withoutNestedShellHost(once, "h1"), once) - assert.deepEqual(nestedShellBodyEntry(once, org), { render: true, entry: cleanup }) - assert.deepEqual(withoutNestedShellHost(once, "h2"), NO_NESTED_SHELL) -}) - -test("re-entering with the same host id keeps the first snapshot", () => { - const state = withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup) - assert.equal(withNestedShellHost(state, "h1", org), state) +test("the root shell hands its owned stack to AppShell instead of blanking its body", () => { + const rootScreen = readFileSync(join(APP_DIR, "index.tsx"), "utf8") + assert.match( + rootScreen, + /const ownedStack = useNestedShellStore\(\(s\) => shellStackBelow\(s, ROOT_SHELL_ID\)\)/, + ) + assert.match(rootScreen, /\{\.\.\.\(ownedStack \? \{ stack: ownedStack \} : \{\}\)\}/) + assert.doesNotMatch(rootScreen, /nestedShellBodyEntry/) }) -test("with no host mounted the root always renders its own entry", () => { - assert.deepEqual(nestedShellBodyEntry(NO_NESTED_SHELL, cleanup), { render: true, entry: cleanup }) - assert.deepEqual(nestedShellBodyEntry(NO_NESTED_SHELL, null), { render: true, entry: null }) - const state = withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup) - assert.deepEqual(nestedShellBodyEntry(state, null), { render: true, entry: null }) +test("a detail host registers BEFORE it seeds, so the shell below never renders its entry", () => { + const host = readFileSync(join(SRC_DIR, "components", "DetailRouteHost.tsx"), "utf8") + const register = host.indexOf("enterNestedShell(hostId, restoreRef.current)") + const seed = host.indexOf("if (entry) seedEntry(entry)") + assert.ok(register > 0, "DetailRouteHost no longer registers its snapshot") + assert.ok(seed > register, "DetailRouteHost seeds the store before it registers its snapshot") + assert.match(host, /const ownedStack = useNestedShellStore\(\(s\) => shellStackBelow\(s, hostId\)\)/) }) diff --git a/apps/community-mobile/src/lib/nestedShellSignal.ts b/apps/community-mobile/src/lib/nestedShellSignal.ts index 049e9e1d..64b9ddfd 100644 --- a/apps/community-mobile/src/lib/nestedShellSignal.ts +++ b/apps/community-mobile/src/lib/nestedShellSignal.ts @@ -3,12 +3,20 @@ import type { DetailEntry } from "@civfix/ui" export const ROOT_ROUTE_NAME = "index" +export type ShellId = string | null + +export const ROOT_SHELL_ID: ShellId = null + +export interface NestedShellHost { + id: string + snapshot: readonly DetailEntry[] +} + export interface NestedShellState { - hosts: readonly string[] - rootEntry: DetailEntry | null + hosts: readonly NestedShellHost[] } -export const NO_NESTED_SHELL: NestedShellState = { hosts: [], rootEntry: null } +export const NO_NESTED_SHELL: NestedShellState = { hosts: [] } export function rootIsTopRoute(route: { name?: string } | null | undefined): boolean { return route?.name === ROOT_ROUTE_NAME @@ -17,28 +25,36 @@ export function rootIsTopRoute(route: { name?: string } | null | undefined): boo export function withNestedShellHost( state: NestedShellState, id: string, - rootEntry: DetailEntry | null, + snapshot: readonly DetailEntry[], ): NestedShellState { - if (state.hosts.includes(id)) return state - return state.hosts.length === 0 - ? { hosts: [id], rootEntry } - : { hosts: [...state.hosts, id], rootEntry: state.rootEntry } + if (state.hosts.some((host) => host.id === id)) return state + return { hosts: [...state.hosts, { id, snapshot }] } } export function withoutNestedShellHost(state: NestedShellState, id: string): NestedShellState { - if (!state.hosts.includes(id)) return state - const hosts = state.hosts.filter((host) => host !== id) - return hosts.length === 0 ? NO_NESTED_SHELL : { hosts, rootEntry: state.rootEntry } + if (!state.hosts.some((host) => host.id === id)) return state + const hosts = state.hosts.filter((host) => host.id !== id) + return hosts.length === 0 ? NO_NESTED_SHELL : { hosts } } export function withoutNestedShellHosts(state: NestedShellState): NestedShellState { return state.hosts.length === 0 ? state : NO_NESTED_SHELL } +export function shellStackBelow( + state: NestedShellState, + id: ShellId, +): readonly DetailEntry[] | null { + if (id === ROOT_SHELL_ID) return state.hosts[0]?.snapshot ?? null + const index = state.hosts.findIndex((host) => host.id === id) + if (index === -1) return null + return state.hosts[index + 1]?.snapshot ?? null +} + export const useNestedShellStore = create(() => NO_NESTED_SHELL) -export function enterNestedShell(id: string, rootEntry: DetailEntry | null): void { - useNestedShellStore.setState((state) => withNestedShellHost(state, id, rootEntry)) +export function enterNestedShell(id: string, snapshot: readonly DetailEntry[]): void { + useNestedShellStore.setState((state) => withNestedShellHost(state, id, snapshot)) } export function exitNestedShell(id: string): void { @@ -48,11 +64,3 @@ export function exitNestedShell(id: string): void { export function clearNestedShellHosts(): void { useNestedShellStore.setState(withoutNestedShellHosts) } - -export function nestedShellBodyEntry( - state: NestedShellState, - entry: DetailEntry | null, -): { render: false } | { render: true; entry: DetailEntry | null } { - if (state.hosts.length === 0 || entry === null) return { render: true, entry } - return state.rootEntry === null ? { render: false } : { render: true, entry: state.rootEntry } -} diff --git a/packages/ui/src/shell/AppShell.tsx b/packages/ui/src/shell/AppShell.tsx index 15474ede..de31d49f 100644 --- a/packages/ui/src/shell/AppShell.tsx +++ b/packages/ui/src/shell/AppShell.tsx @@ -14,10 +14,17 @@ import { SEARCH_IS_OVERLAY } from "./searchRevealPlatform" import { useTabBarStore } from "./tabBarStore" import type { AppShellProps } from "./types" -export function AppShell({ map, mapControls, authOverlay, renderBody = defaultRenderBody }: AppShellProps) { +export function AppShell({ + map, + mapControls, + authOverlay, + renderBody = defaultRenderBody, + stack, +}: AppShellProps) { const mode = useLayoutMode() const view = useNavStore((state) => state.view) - const active = useNavStore((state) => state.active) + const liveActive = useNavStore((state) => state.active) + const active = stack ? (stack[stack.length - 1] ?? null) : liveActive const seededDetailPage = useNavStore((state) => state.seededDetailPage) const fullPageDetails = DETAILS_ARE_FULL_PAGE || (mode === "compact" && seededDetailPage) const searchOverlayUp = SEARCH_IS_OVERLAY && view === "search" @@ -54,7 +61,7 @@ export function AppShell({ map, mapControls, authOverlay, renderBody = defaultRe ) : null} {mountMapControls ? {mapControls} : null} {mode === "expanded" ? ( - + ) : ( )} {authOverlay ? ( diff --git a/packages/ui/src/shell/ExpandedShell.tsx b/packages/ui/src/shell/ExpandedShell.tsx index d551e994..07aee2e6 100644 --- a/packages/ui/src/shell/ExpandedShell.tsx +++ b/packages/ui/src/shell/ExpandedShell.tsx @@ -75,15 +75,21 @@ const PANEL_SCROLL_HOST = makeKeyboardAwareScrollHost(PLAIN_SCROLL_HOST) export interface ExpandedShellProps { renderBody?: RenderBody + stack?: readonly DetailEntry[] } -export function ExpandedShell({ renderBody = defaultRenderBody }: ExpandedShellProps) { +export function ExpandedShell({ + renderBody = defaultRenderBody, + stack: ownedStack, +}: ExpandedShellProps) { const styles = useStyles() const th = useTheme() const { t } = useT("nav") const { width } = useWindowDimensions() - const stack = useNavStore((s) => s.stack) - const active = useNavStore((s) => s.active) + const liveStack = useNavStore((s) => s.stack) + const liveActive = useNavStore((s) => s.active) + const stack = ownedStack ?? liveStack + const active = ownedStack ? (ownedStack[ownedStack.length - 1] ?? null) : liveActive const view = useNavStore((s) => s.view) const storedWidth = useSidebarStore((s) => s.width) const setWidth = useSidebarStore((s) => s.setWidth) diff --git a/packages/ui/src/shell/PortraitShell.shared.tsx b/packages/ui/src/shell/PortraitShell.shared.tsx index d07b1a56..fedf8615 100644 --- a/packages/ui/src/shell/PortraitShell.shared.tsx +++ b/packages/ui/src/shell/PortraitShell.shared.tsx @@ -62,6 +62,7 @@ export interface PortraitShellProps { view: NavView baseView: NavView fullPageDetails?: boolean + stack?: readonly DetailEntry[] } interface PortraitShellFrameProps extends PortraitShellProps { @@ -80,12 +81,14 @@ export function PortraitShellFrame({ keyboardInset, plan, renderBody, + stack: ownedStack, topInset, view, }: PortraitShellFrameProps) { const styles = useStyles() const tabBarHeight = useTabBarStore((state) => state.tabBarHeight) - const stack = useNavStore((state) => state.stack) + const liveStack = useNavStore((state) => state.stack) + const stack = ownedStack ?? liveStack const direction = useStackDirection(stack.length) const frame = portraitFramePlan( baseView, diff --git a/packages/ui/src/shell/SearchBodyReveal.native.tsx b/packages/ui/src/shell/SearchBodyReveal.native.tsx index 7dfce037..bee29d53 100644 --- a/packages/ui/src/shell/SearchBodyReveal.native.tsx +++ b/packages/ui/src/shell/SearchBodyReveal.native.tsx @@ -81,6 +81,8 @@ export function SearchBodyReveal({ active, renderBody, topInset, bottomInset }: return { opacity, transform: [{ translateY }] } }) + const body = useMemo(() => renderBody(null, "search"), [renderBody]) + if (!mounted) return null return ( @@ -90,7 +92,7 @@ export function SearchBodyReveal({ active, renderBody, topInset, bottomInset }: > - {renderBody(null, "search")} + {body} diff --git a/packages/ui/src/shell/__tests__/shellStackOverride.test.ts b/packages/ui/src/shell/__tests__/shellStackOverride.test.ts new file mode 100644 index 00000000..a5d5adf0 --- /dev/null +++ b/packages/ui/src/shell/__tests__/shellStackOverride.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" +import { portraitFramePlan, portraitShellPlan } from "../bodyLayout" +import type { DetailEntry } from "../../nav" + +const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") +const appShell = read("../AppShell.tsx") +const portrait = read("../PortraitShell.shared.tsx") +const expanded = read("../ExpandedShell.tsx") +const types = read("../types.ts") +const searchReveal = read("../SearchBodyReveal.native.tsx") + +const cleanup = { kind: "cleanup", id: "c1" } as DetailEntry + +describe("a shell renders the stack it owns, not whatever the store holds", () => { + it("takes the override as a prop", () => { + expect(types).toContain("stack?: readonly DetailEntry[]") + }) + + it("derives the active entry from the override, so the store cannot leak an entry in", () => { + expect(appShell).toContain("const liveActive = useNavStore((state) => state.active)") + expect(appShell).toContain( + "const active = stack ? (stack[stack.length - 1] ?? null) : liveActive", + ) + }) + + it("hands the override to BOTH shells", () => { + expect(appShell).toContain("") + expect(appShell).toMatch(//) + }) + + it("prefers the override over the live store in each shell, and falls back when absent", () => { + expect(portrait).toContain("const liveStack = useNavStore((state) => state.stack)") + expect(portrait).toContain("const stack = ownedStack ?? liveStack") + expect(expanded).toContain("const stack = ownedStack ?? liveStack") + expect(expanded).toContain( + "const active = ownedStack ? (ownedStack[ownedStack.length - 1] ?? null) : liveActive", + ) + }) + + it("keeps the search overlay's body out of every unrelated shell re-render", () => { + expect(searchReveal).toContain('const body = useMemo(() => renderBody(null, "search"), [renderBody])') + expect(searchReveal).not.toMatch(/\{renderBody\(null, "search"\)\}/) + }) +}) + +describe("an empty owned stack presents no page at all, so the base body shows through", () => { + const shellPlan = portraitShellPlan("home", null, true, true) + + it("mounts no overlay for an empty stack", () => { + const frame = portraitFramePlan("home", null, shellPlan, 0, 0, [], true) + expect(frame.overlay.bodyMounted).toBe(false) + expect(frame.overlay.entries).toEqual([]) + expect(frame.base.bodyMounted).toBe(true) + }) + + it("mounts exactly the pages the owned stack names", () => { + const frame = portraitFramePlan("home", cleanup, shellPlan, 0, 0, [cleanup], true) + expect(frame.overlay.entries).toEqual([cleanup]) + expect(frame.overlay.entry).toEqual(cleanup) + }) +}) diff --git a/packages/ui/src/shell/types.ts b/packages/ui/src/shell/types.ts index a7ad42b3..adfe1b9f 100644 --- a/packages/ui/src/shell/types.ts +++ b/packages/ui/src/shell/types.ts @@ -31,4 +31,5 @@ export interface AppShellProps { * omitted the shell falls back to `defaultRenderBody` (the BodyRouter). */ renderBody?: (entry: DetailEntry | null, view: View) => React.ReactNode + stack?: readonly DetailEntry[] } From bf241fc3a1f758a3c3f802dd685271ebe5bcd779 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:21:27 -0700 Subject: [PATCH 009/105] cut the work that runs while a swipe transition is in flight --- apps/community-mobile/src/lib/nativeCamera.ts | 17 ++- .../tests/cameraWarmup.test.ts | 17 +++ packages/ui/src/bodies/ReportFlowBody.tsx | 14 +-- .../bodies/__tests__/inboxRowActions.test.ts | 2 +- packages/ui/src/map/Map.native.tsx | 111 ++++++++++-------- .../ui/src/map/__tests__/markerFocus.test.ts | 51 ++++++++ packages/ui/src/map/markerFocus.ts | 11 ++ .../__tests__/swipeActionsModel.test.ts | 16 +++ .../__tests__/swipeReplyModel.test.ts | 16 +++ packages/ui/src/primitives/backSwipeEdge.ts | 7 ++ .../ui/src/primitives/swipeActionsModel.ts | 10 +- packages/ui/src/primitives/swipeReplyModel.ts | 5 +- packages/ui/src/primitives/useSwipeActions.ts | 2 +- packages/ui/src/primitives/useSwipeReply.ts | 2 +- .../src/report/__tests__/wizardSteps.test.ts | 12 +- .../src/shell/__tests__/cameraWarmup.test.ts | 9 +- 16 files changed, 223 insertions(+), 79 deletions(-) create mode 100644 packages/ui/src/map/__tests__/markerFocus.test.ts create mode 100644 packages/ui/src/map/markerFocus.ts create mode 100644 packages/ui/src/primitives/backSwipeEdge.ts diff --git a/apps/community-mobile/src/lib/nativeCamera.ts b/apps/community-mobile/src/lib/nativeCamera.ts index 62d98ddb..94fe226d 100644 --- a/apps/community-mobile/src/lib/nativeCamera.ts +++ b/apps/community-mobile/src/lib/nativeCamera.ts @@ -1,10 +1,10 @@ -import { InteractionManager } from "react-native" import { Directory, File, Paths } from "expo-file-system" import * as Crypto from "expo-crypto" import * as ImagePicker from "expo-image-picker" import { ImageManipulator, SaveFormat } from "expo-image-manipulator" import { Image as ImageCompressor, Video as VideoCompressor } from "react-native-compressor" import { AppError, ErrorCode } from "@civfix/shared" +import { motion } from "@civfix/ui/theme" import type { CameraCapability, CapturedMedia, @@ -40,6 +40,7 @@ const VIDEO_MIN_COMPRESS_MB = 0 const STALE_TEMP_AGE_MS = 60 * 60_000 const SWEEP_DELETE_LIMIT = 40 const SWEEP_SCAN_LIMIT = 400 +const SWEEP_DELAY_MS = motion.pagePop.duration const TEMP_OUTPUT_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|jpeg|png|mp4|mov|m4a)$/i @@ -95,14 +96,20 @@ function sweepDirectory(directory: Directory, now: number, budget: number): numb function sweepStaleMediaTempFiles(): void { if (sweptThisSession) return sweptThisSession = true - InteractionManager.runAfterInteractions(() => { + setTimeout(() => { const now = Date.now() + let removed = 0 try { - const removed = sweepDirectory(new Directory(Paths.cache, "ImageManipulator"), now, SWEEP_DELETE_LIMIT) - sweepDirectory(Paths.cache, now, SWEEP_DELETE_LIMIT - removed) + removed = sweepDirectory(new Directory(Paths.cache, "ImageManipulator"), now, SWEEP_DELETE_LIMIT) } catch { } - }) + setTimeout(() => { + try { + sweepDirectory(Paths.cache, now, SWEEP_DELETE_LIMIT - removed) + } catch { + } + }, 0) + }, SWEEP_DELAY_MS) } async function sha256Hex(bytes: Uint8Array): Promise { diff --git a/apps/community-mobile/tests/cameraWarmup.test.ts b/apps/community-mobile/tests/cameraWarmup.test.ts index 444c4a07..bea81d02 100644 --- a/apps/community-mobile/tests/cameraWarmup.test.ts +++ b/apps/community-mobile/tests/cameraWarmup.test.ts @@ -6,6 +6,7 @@ import { test } from "node:test" const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") const mapHome = read("../app/index.tsx") const viewfinder = read("../src/components/report/ReportViewfinder.tsx") +const nativeCamera = read("../src/lib/nativeCamera.ts") function mapElementMemo(): string { const start = mapHome.indexOf("const mapElement = useMemo(") @@ -156,3 +157,19 @@ test("the photo shutter asks the library to stay silent", () => { assert.ok(take.includes("enableShutterSound: false")) assert.ok(take.includes('flash: "off"')) }) + +test("the stale-temp sweep waits out the pop on a real clock, not on runAfterInteractions", () => { + assert.ok( + !nativeCamera.includes("InteractionManager"), + "runAfterInteractions is a bare setImmediate under RN 0.81 - it defers nothing", + ) + assert.ok(nativeCamera.includes("const SWEEP_DELAY_MS = motion.pagePop.duration")) + assert.ok(nativeCamera.includes("}, SWEEP_DELAY_MS)")) +}) + +test("the sweep yields between directories, so one turn is never two full scans", () => { + const sweep = nativeCamera.slice(nativeCamera.indexOf("function sweepStaleMediaTempFiles()")) + const body = sweep.slice(0, sweep.indexOf("\n}\n")) + assert.equal((body.match(/setTimeout\(/g) ?? []).length, 2) + assert.ok(body.includes("}, 0)")) +}) diff --git a/packages/ui/src/bodies/ReportFlowBody.tsx b/packages/ui/src/bodies/ReportFlowBody.tsx index 3322e1fc..aa23c3db 100644 --- a/packages/ui/src/bodies/ReportFlowBody.tsx +++ b/packages/ui/src/bodies/ReportFlowBody.tsx @@ -1,10 +1,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { View, Platform, Pressable, ScrollView, StyleSheet, ActivityIndicator, InteractionManager } from "react-native" +import { View, Platform, Pressable, ScrollView, StyleSheet, ActivityIndicator } from "react-native" import { useQueryClient, type QueryClient } from "@tanstack/react-query" import type { ReportCategory, ReportType as SharedReportType } from "@civfix/shared" import type { ApiClient } from "@civfix/shared/client" import { type LatLng } from "@civfix/shared/geocode" -import { makeThemedStyles, useTheme, categoryColor, wash, useLayoutMode, focusRingProps, type LayoutMode } from "../theme" +import { makeThemedStyles, motion, useTheme, categoryColor, wash, useLayoutMode, focusRingProps, type LayoutMode } from "../theme" import { alpha } from "../theme/alpha" import { Text, Icon, iconMap } from "../typography" import { TextField, Toggle, KeyboardPinnedFooter, KeyboardPinnedSurface, PrimaryButton, CategoryChip, MediaPreview, SuccessCheck } from "../primitives" @@ -366,7 +366,7 @@ function DetailsStep() { const DEVICE_FIX_TIMEOUT_MS = 4000 -const VIEWFINDER_MOUNT_DEADLINE_MS = 600 +const VIEWFINDER_MOUNT_DELAY_MS = motion.pagePush.duration const PICK_LAYER_LINGER_MS = 400 @@ -883,12 +883,8 @@ export function ReportFlowBody() { const viewfinderVisible = rendersEmbeddedViewfinder(activeStep, hasMedia, Viewfinder != null, mode) useEffect(() => { - const handle = InteractionManager.runAfterInteractions(() => setViewfinderMountable(true)) - const deadline = setTimeout(() => setViewfinderMountable(true), VIEWFINDER_MOUNT_DEADLINE_MS) - return () => { - handle.cancel() - clearTimeout(deadline) - } + const handle = setTimeout(() => setViewfinderMountable(true), VIEWFINDER_MOUNT_DELAY_MS) + return () => clearTimeout(handle) }, []) const viewfinderMounted = viewfinderVisible && viewfinderMountable diff --git a/packages/ui/src/bodies/__tests__/inboxRowActions.test.ts b/packages/ui/src/bodies/__tests__/inboxRowActions.test.ts index cc7d19d6..4862777f 100644 --- a/packages/ui/src/bodies/__tests__/inboxRowActions.test.ts +++ b/packages/ui/src/bodies/__tests__/inboxRowActions.test.ts @@ -74,7 +74,7 @@ describe("the native swipe uses the house gesture primitive", () => { expect(swipeHook).toContain("onStartShouldSetPanResponder: () => false") expect(swipeHook).toContain("onStartShouldSetPanResponderCapture: () => false") expect(swipeHook).toContain("onPanResponderTerminationRequest: () => false") - expect(swipeHook).toContain("shouldCaptureActionsSwipe(g.dx, g.dy, openRef.current)") + expect(swipeHook).toContain("shouldCaptureActionsSwipe(g.dx, g.dy, openRef.current, g.x0)") }) it("keeps every threshold in the pure model, the way swipeReplyModel does", () => { diff --git a/packages/ui/src/map/Map.native.tsx b/packages/ui/src/map/Map.native.tsx index 2a920700..7cf4b1ca 100644 --- a/packages/ui/src/map/Map.native.tsx +++ b/packages/ui/src/map/Map.native.tsx @@ -29,6 +29,7 @@ import { useMapFocus } from "./mapFocusStore" import { useMapViewport } from "./mapViewportStore" import { useDroppedPin } from "./droppedPinStore" import { longPressHitsMarker, type LongPressMarker } from "./longPressGate" +import { markerNodeIsActive } from "./markerFocus" import { clusterFallbackZoom, clusterListReports, @@ -48,6 +49,47 @@ const NO_REPORTS: MapProps["reports"] = [] const NO_CLEANUPS: MapProps["cleanups"] = [] const NO_AGGREGATES: MapProps["reportAggregates"] = [] +interface MarkerNodeProps { + node: ClusterNode + markerId: string + active: boolean + onPress: (event: NativeSyntheticEvent) => void +} + +const MarkerNode = memo(function MarkerNode({ node, markerId, active, onPress }: MarkerNodeProps) { + const lngLat = useMemo<[number, number]>(() => [node.lng, node.lat], [node.lng, node.lat]) + + if (node.type === "cluster") { + return ( + + + + ) + } + if (node.type === "report") { + return ( + + + + ) + } + if (node.type === "event") { + return ( + + + + ) + } + return ( + + + + ) +}) + export const Map = memo(forwardRef(function Map(props, ref) { const { reports = NO_REPORTS, @@ -356,58 +398,23 @@ export const Map = memo(forwardRef(function Map(props, ref) ) ) : ( <> - {markerNodes.rendered.map(({ node, markerId }) => - node.type === "cluster" ? ( - - - - ) : node.type === "report" ? ( - - - - ) : node.type === "event" ? ( - - - - ) : ( - - - - ), - )} + {markerNodes.rendered.map(({ node, markerId }) => ( + + ))} )} diff --git a/packages/ui/src/map/__tests__/markerFocus.test.ts b/packages/ui/src/map/__tests__/markerFocus.test.ts new file mode 100644 index 00000000..e07cfdb3 --- /dev/null +++ b/packages/ui/src/map/__tests__/markerFocus.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" +import { markerNodeIsActive } from "../markerFocus" +import type { ClusterNode } from "../clusterer" + +const mapNative = readFileSync(new URL("../Map.native.tsx", import.meta.url), "utf8") + +const reportNode = { type: "report", id: "r1", key: "r:r1", lat: 0, lng: 0 } as unknown as ClusterNode +const eventNode = { type: "event", id: "c1", key: "e:c1", lat: 0, lng: 0 } as unknown as ClusterNode +const blendNode = { type: "blend", id: "c1", key: "b:c1", lat: 0, lng: 0 } as unknown as ClusterNode +const clusterNode = { type: "cluster", key: "cl:1", lat: 0, lng: 0 } as unknown as ClusterNode + +describe("markerNodeIsActive", () => { + it("lights the focused report and nothing else", () => { + expect(markerNodeIsActive(reportNode, "r1", null)).toBe(true) + expect(markerNodeIsActive(reportNode, "other", null)).toBe(false) + expect(markerNodeIsActive(reportNode, null, "r1")).toBe(false) + }) + + it("lights the focused event on both the plain and the blended pin", () => { + expect(markerNodeIsActive(eventNode, null, "c1")).toBe(true) + expect(markerNodeIsActive(blendNode, null, "c1")).toBe(true) + expect(markerNodeIsActive(eventNode, "c1", null)).toBe(false) + }) + + it("never lights a cluster", () => { + expect(markerNodeIsActive(clusterNode, "cl:1", "cl:1")).toBe(false) + }) + + it("treats a null focus as no match, so an id-less node cannot light up", () => { + const idless = { type: "report", key: "r:", lat: 0, lng: 0 } as unknown as ClusterNode + expect(markerNodeIsActive(idless, null, null)).toBe(false) + }) +}) + +describe("a focus change must not re-render every marker", () => { + it("renders each marker through one memoized component, not inline JSX", () => { + expect(mapNative).toContain("const MarkerNode = memo(function MarkerNode(") + expect(mapNative).toContain(" { + expect(mapNative).toContain( + "const lngLat = useMemo<[number, number]>(() => [node.lng, node.lat], [node.lng, node.lat])", + ) + expect(mapNative).not.toMatch(/lngLat=\{\[node\.lng, node\.lat\]\}/) + }) +}) diff --git a/packages/ui/src/map/markerFocus.ts b/packages/ui/src/map/markerFocus.ts new file mode 100644 index 00000000..92fdf09d --- /dev/null +++ b/packages/ui/src/map/markerFocus.ts @@ -0,0 +1,11 @@ +import type { ClusterNode } from "./clusterer" + +export function markerNodeIsActive( + node: ClusterNode, + focusedPinId: string | null, + focusedCleanupId: string | null, +): boolean { + if (node.type === "cluster") return false + if (node.type === "report") return focusedPinId !== null && focusedPinId === node.id + return focusedCleanupId !== null && focusedCleanupId === node.id +} diff --git a/packages/ui/src/primitives/__tests__/swipeActionsModel.test.ts b/packages/ui/src/primitives/__tests__/swipeActionsModel.test.ts index 47cc52ff..b2fc72f2 100644 --- a/packages/ui/src/primitives/__tests__/swipeActionsModel.test.ts +++ b/packages/ui/src/primitives/__tests__/swipeActionsModel.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest" +import { BACK_SWIPE_EDGE_PX } from "../backSwipeEdge" import { SWIPE_ACTIONS_CAPTURE_SLOP_PX, SWIPE_ACTIONS_FLING_VX, @@ -94,3 +95,18 @@ describe("shouldSnapOpen", () => { expect(shouldSnapOpen(-200, 0, -2)).toBe(false) }) }) + +describe("the back-swipe edge is not the row's to claim", () => { + it("refuses a drag that starts inside the back-swipe edge, open or closed", () => { + expect(shouldCaptureActionsSwipe(-80, 0, false, 0)).toBe(false) + expect(shouldCaptureActionsSwipe(80, 0, true, BACK_SWIPE_EDGE_PX)).toBe(false) + }) + + it("still captures a drag that starts past the edge", () => { + expect(shouldCaptureActionsSwipe(-80, 0, false, BACK_SWIPE_EDGE_PX + 1)).toBe(true) + }) + + it("captures with no start position supplied, which is every non-native caller", () => { + expect(shouldCaptureActionsSwipe(-80, 0, false)).toBe(true) + }) +}) diff --git a/packages/ui/src/primitives/__tests__/swipeReplyModel.test.ts b/packages/ui/src/primitives/__tests__/swipeReplyModel.test.ts index 127e017d..32fe5ef4 100644 --- a/packages/ui/src/primitives/__tests__/swipeReplyModel.test.ts +++ b/packages/ui/src/primitives/__tests__/swipeReplyModel.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest" +import { BACK_SWIPE_EDGE_PX } from "../backSwipeEdge" import { SWIPE_CAPTURE_SLOP_PX, SWIPE_MAX_TRANSLATE_PX, @@ -56,3 +57,18 @@ describe("swipeProgress", () => { expect(swipeProgress(-10)).toBe(0) }) }) + +describe("the back-swipe edge is not the row's to claim", () => { + it("refuses a drag that starts inside the back-swipe edge, however far it travels", () => { + expect(shouldCaptureSwipe(80, 0, 0)).toBe(false) + expect(shouldCaptureSwipe(80, 0, BACK_SWIPE_EDGE_PX)).toBe(false) + }) + + it("still captures a drag that starts past the edge", () => { + expect(shouldCaptureSwipe(80, 0, BACK_SWIPE_EDGE_PX + 1)).toBe(true) + }) + + it("captures with no start position supplied, which is every non-native caller", () => { + expect(shouldCaptureSwipe(80, 0)).toBe(true) + }) +}) diff --git a/packages/ui/src/primitives/backSwipeEdge.ts b/packages/ui/src/primitives/backSwipeEdge.ts new file mode 100644 index 00000000..d321ad23 --- /dev/null +++ b/packages/ui/src/primitives/backSwipeEdge.ts @@ -0,0 +1,7 @@ +import { MOTION } from "../theme/motion" + +export const BACK_SWIPE_EDGE_PX = MOTION.pageEdgeWidth + +export function startsInBackSwipeEdge(startX: number): boolean { + return Number.isFinite(startX) && startX <= BACK_SWIPE_EDGE_PX +} diff --git a/packages/ui/src/primitives/swipeActionsModel.ts b/packages/ui/src/primitives/swipeActionsModel.ts index 58247253..1de8f3b7 100644 --- a/packages/ui/src/primitives/swipeActionsModel.ts +++ b/packages/ui/src/primitives/swipeActionsModel.ts @@ -1,3 +1,5 @@ +import { startsInBackSwipeEdge } from "./backSwipeEdge" + export const SWIPE_ACTIONS_CAPTURE_SLOP_PX = 10 export const SWIPE_ACTION_WIDTH_PX = 72 export const SWIPE_ACTIONS_SNAP_RATIO = 0.5 @@ -11,7 +13,13 @@ export function actionsRestingX(open: boolean, width: number): number { return open ? -width : 0 } -export function shouldCaptureActionsSwipe(dx: number, dy: number, open: boolean): boolean { +export function shouldCaptureActionsSwipe( + dx: number, + dy: number, + open: boolean, + startX = Number.POSITIVE_INFINITY, +): boolean { + if (startsInBackSwipeEdge(startX)) return false if (Math.abs(dx) <= SWIPE_ACTIONS_CAPTURE_SLOP_PX) return false if (Math.abs(dx) <= Math.abs(dy)) return false return open || dx < 0 diff --git a/packages/ui/src/primitives/swipeReplyModel.ts b/packages/ui/src/primitives/swipeReplyModel.ts index 37d8b366..41451411 100644 --- a/packages/ui/src/primitives/swipeReplyModel.ts +++ b/packages/ui/src/primitives/swipeReplyModel.ts @@ -10,6 +10,8 @@ * drags never capture at all (dx must be POSITIVE - there is no left-swipe affordance). */ +import { startsInBackSwipeEdge } from "./backSwipeEdge" + /** Minimum rightward travel (px) before the gesture claims the responder (finger slop). */ export const SWIPE_CAPTURE_SLOP_PX = 10 /** Release at/past this rightward travel (px) triggers the reply action (and the haptic tick). */ @@ -22,7 +24,8 @@ export const SWIPE_MAX_TRANSLATE_PX = 64 * dominant (|dx| > |dy|) so the list's vertical scroll keeps vertical-ish drags. Positive-dx checks * make the |dx| explicit-abs redundant on the dx side. */ -export function shouldCaptureSwipe(dx: number, dy: number): boolean { +export function shouldCaptureSwipe(dx: number, dy: number, startX = Number.POSITIVE_INFINITY): boolean { + if (startsInBackSwipeEdge(startX)) return false return dx > SWIPE_CAPTURE_SLOP_PX && dx > Math.abs(dy) } diff --git a/packages/ui/src/primitives/useSwipeActions.ts b/packages/ui/src/primitives/useSwipeActions.ts index 825ce6ec..3830efe8 100644 --- a/packages/ui/src/primitives/useSwipeActions.ts +++ b/packages/ui/src/primitives/useSwipeActions.ts @@ -112,7 +112,7 @@ export function useSwipeActions({ enabled, actionCount }: SwipeActionsOptions): onMoveShouldSetPanResponder: (_evt, g) => stateRef.current.enabled && stateRef.current.width > 0 && - shouldCaptureActionsSwipe(g.dx, g.dy, openRef.current), + shouldCaptureActionsSwipe(g.dx, g.dy, openRef.current, g.x0), onPanResponderGrant: () => { tickedRef.current = false closeOtherRow() diff --git a/packages/ui/src/primitives/useSwipeReply.ts b/packages/ui/src/primitives/useSwipeReply.ts index 12d2d9ea..626b47ea 100644 --- a/packages/ui/src/primitives/useSwipeReply.ts +++ b/packages/ui/src/primitives/useSwipeReply.ts @@ -71,7 +71,7 @@ export function useSwipeReply({ enabled, onTrigger }: SwipeReplyOptions): SwipeR onStartShouldSetPanResponder: () => false, onStartShouldSetPanResponderCapture: () => false, onMoveShouldSetPanResponderCapture: () => false, - onMoveShouldSetPanResponder: (_evt, g) => stateRef.current.enabled && shouldCaptureSwipe(g.dx, g.dy), + onMoveShouldSetPanResponder: (_evt, g) => stateRef.current.enabled && shouldCaptureSwipe(g.dx, g.dy, g.x0), onPanResponderGrant: () => { tickedRef.current = false }, diff --git a/packages/ui/src/report/__tests__/wizardSteps.test.ts b/packages/ui/src/report/__tests__/wizardSteps.test.ts index 53c225f3..1f3276ac 100644 --- a/packages/ui/src/report/__tests__/wizardSteps.test.ts +++ b/packages/ui/src/report/__tests__/wizardSteps.test.ts @@ -317,7 +317,7 @@ describe("the capture step's embedded viewfinder wiring", () => { it("defers CONSTRUCTING the camera past the tab transition, without flashing the card it replaced", () => { expect(wizardSource).toContain( - "const handle = InteractionManager.runAfterInteractions(() => setViewfinderMountable(true))", + "const handle = setTimeout(() => setViewfinderMountable(true), VIEWFINDER_MOUNT_DELAY_MS)", ) expect(wizardSource).toContain("{viewfinderVisible ? (") expect(wizardSource).toContain("const viewfinderMounted = viewfinderVisible && viewfinderMountable") @@ -647,15 +647,15 @@ describe("the derived-viewfinder wiring (source-pinned)", () => { expect(wizardCode).not.toMatch(/setViewfinder(Visible|Open)/) }) - it("keeps the deferral cancellable, mount-scoped, and bounded by a deadline", () => { + it("keeps the deferral cancellable, mount-scoped, and on a clock that actually defers", () => { const effect = - /useEffect\(\(\) => \{\n\s*const handle = InteractionManager\.runAfterInteractions[\s\S]*?\}, \[\]\)/.exec( + /useEffect\(\(\) => \{\n\s*const handle = setTimeout[\s\S]*?\}, \[\]\)/.exec( wizardSource, )?.[0] expect(effect).toBeTruthy() - expect(effect).toContain("handle.cancel()") - expect(effect).toContain("setTimeout(() => setViewfinderMountable(true), VIEWFINDER_MOUNT_DEADLINE_MS)") - expect(effect).toContain("clearTimeout(deadline)") + expect(effect).toContain("clearTimeout(handle)") + expect(wizardSource).toContain("const VIEWFINDER_MOUNT_DELAY_MS = motion.pagePush.duration") + expect(wizardSource).not.toMatch(/InteractionManager/) }) it("passes the host surface ONLY what the capability contract says it gets", () => { diff --git a/packages/ui/src/shell/__tests__/cameraWarmup.test.ts b/packages/ui/src/shell/__tests__/cameraWarmup.test.ts index aec7f5fc..3075974e 100644 --- a/packages/ui/src/shell/__tests__/cameraWarmup.test.ts +++ b/packages/ui/src/shell/__tests__/cameraWarmup.test.ts @@ -77,10 +77,15 @@ describe("4. the body swap declares itself a non-interaction", () => { expect(bodyTransition).toMatch(/useNativeDriver: true,\n\s*isInteraction: false,/) }) - it("leaves the camera deferral's own deadline in place", () => { + it("defers the camera mount on a real clock, past the page-push transition", () => { expect(reportBody).toContain( - "const deadline = setTimeout(() => setViewfinderMountable(true), VIEWFINDER_MOUNT_DEADLINE_MS)", + "const handle = setTimeout(() => setViewfinderMountable(true), VIEWFINDER_MOUNT_DELAY_MS)", ) + expect(reportBody).toContain("const VIEWFINDER_MOUNT_DELAY_MS = motion.pagePush.duration") + }) + + it("does NOT gate the camera mount on runAfterInteractions either", () => { + expect(reportBody).not.toMatch(/InteractionManager/) }) }) From cf34264a299ae08520ddd88fb0583334a1887a44 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:46:12 -0700 Subject: [PATCH 010/105] pressed hover and focus affordances across ui pressables --- packages/ui/src/bodies/EventDetailBody.tsx | 1 + packages/ui/src/bodies/LinkedReportCard.tsx | 15 ++++++-- packages/ui/src/bodies/MembersBody.tsx | 5 ++- packages/ui/src/bodies/MessagingListBody.tsx | 1 + packages/ui/src/bodies/ReportDetailBody.tsx | 8 +++- .../ui/src/bodies/conversation/ConvoBar.tsx | 2 +- .../ui/src/bodies/thread/ThreadReplyRow.tsx | 1 + packages/ui/src/map/LayersPopover.tsx | 22 ++++++----- packages/ui/src/map/MapControls.tsx | 32 ++++++++++++++-- packages/ui/src/primitives/BrandAboutCard.tsx | 9 ++++- packages/ui/src/primitives/BringInput.tsx | 2 + packages/ui/src/primitives/EventCard.tsx | 13 ++++++- packages/ui/src/primitives/FollowButton.tsx | 12 ++++-- packages/ui/src/primitives/GuestRsvpSheet.tsx | 10 ++++- .../ui/src/primitives/MessageContextMenu.tsx | 1 + .../ui/src/primitives/ReportContentSheet.tsx | 9 ++++- packages/ui/src/primitives/SettingsRow.tsx | 11 ++++-- .../primitives/__tests__/settingsRow.test.ts | 2 +- packages/ui/src/promo/AppPromoCard.tsx | 21 ++++++++-- packages/ui/src/share/ShareActionTile.tsx | 14 ++++++- packages/ui/src/share/SharePeople.tsx | 21 ++++++++-- packages/ui/src/shell/DetailBar.tsx | 14 ++++++- .../ui/src/shell/DetailTrailingButton.tsx | 3 +- packages/ui/src/shell/ExpandedShell.tsx | 9 ++++- packages/ui/src/shell/SearchHeader.native.tsx | 3 +- packages/ui/src/shell/SearchHeader.styles.ts | 6 +++ packages/ui/src/shell/SearchHeader.web.tsx | 38 ++++++++++++++++--- packages/ui/src/shell/TabBar.shared.tsx | 2 +- 28 files changed, 232 insertions(+), 55 deletions(-) diff --git a/packages/ui/src/bodies/EventDetailBody.tsx b/packages/ui/src/bodies/EventDetailBody.tsx index 8780fe84..a2544fcc 100644 --- a/packages/ui/src/bodies/EventDetailBody.tsx +++ b/packages/ui/src/bodies/EventDetailBody.tsx @@ -130,6 +130,7 @@ function GoingRow({ accessibilityRole="button" accessibilityLabel={expanded ? t("going.a11y_show_fewer") : t("going.a11y_show_everyone")} {...focusRingProps} + style={({ pressed }) => (pressed ? styles.pressed : null)} > {data?.scope === "following" diff --git a/packages/ui/src/bodies/LinkedReportCard.tsx b/packages/ui/src/bodies/LinkedReportCard.tsx index efb91bf9..89922bfa 100644 --- a/packages/ui/src/bodies/LinkedReportCard.tsx +++ b/packages/ui/src/bodies/LinkedReportCard.tsx @@ -186,11 +186,12 @@ export function LinkedReportCard({ onPress={onRemove} accessibilityRole="button" accessibilityLabel={t("card.removeA11yLabel", { title })} - hitSlop={8} {...focusRingProps} style={({ pressed }) => [styles.removeBtn, webCursor(), pressed ? styles.pressed : null]} > - + + + ) @@ -332,8 +333,14 @@ const useStyles = makeThemedStyles((t) => ({ }, removeBtn: { position: "absolute", - top: -6, - right: -6, + top: -16, + right: -16, + width: 44, + height: 44, + alignItems: "center", + justifyContent: "center", + }, + removeVisual: { width: 24, height: 24, borderRadius: 12, diff --git a/packages/ui/src/bodies/MembersBody.tsx b/packages/ui/src/bodies/MembersBody.tsx index d5652c0c..c048e8d8 100644 --- a/packages/ui/src/bodies/MembersBody.tsx +++ b/packages/ui/src/bodies/MembersBody.tsx @@ -200,12 +200,15 @@ function LinkedEntityRow({ onPress={onPress} accessibilityRole="button" accessibilityLabel={a11y} + {...focusRingProps} style={({ pressed }) => [styles.linkedRow, pressed ? styles.linkedRowPressed : null]} > - {label} + + {label} + ) diff --git a/packages/ui/src/bodies/MessagingListBody.tsx b/packages/ui/src/bodies/MessagingListBody.tsx index 6fe50a3f..147b6cf4 100644 --- a/packages/ui/src/bodies/MessagingListBody.tsx +++ b/packages/ui/src/bodies/MessagingListBody.tsx @@ -214,6 +214,7 @@ function ThreadRowAction({ onPress={onPress} accessibilityRole="button" accessibilityLabel={a11yLabel ?? label} + {...focusRingProps} style={({ pressed }) => [ styles.swipeAction, tone === "read" diff --git a/packages/ui/src/bodies/ReportDetailBody.tsx b/packages/ui/src/bodies/ReportDetailBody.tsx index 16059984..d7e2d64e 100644 --- a/packages/ui/src/bodies/ReportDetailBody.tsx +++ b/packages/ui/src/bodies/ReportDetailBody.tsx @@ -406,7 +406,7 @@ function ReportGallery({ accessibilityRole="button" accessibilityLabel={t("gallery.view_fullscreen_a11y")} {...focusRingProps} - style={styles.heroPress} + style={({ pressed }) => [styles.heroPress, pressed ? styles.pressed : null]} > [ + styles.thumb, + isActive ? styles.thumbActive : null, + pressed ? styles.pressed : null, + ]} > {m.kind === "video" ? ( diff --git a/packages/ui/src/bodies/conversation/ConvoBar.tsx b/packages/ui/src/bodies/conversation/ConvoBar.tsx index e0f875d9..96d70f86 100644 --- a/packages/ui/src/bodies/conversation/ConvoBar.tsx +++ b/packages/ui/src/bodies/conversation/ConvoBar.tsx @@ -82,7 +82,7 @@ export function ConvoBar({ accessibilityLabel={titlePressLabel} hitSlop={6} {...focusRingProps} - style={styles.avatarTap} + style={({ pressed }) => [styles.avatarTap, pressed ? styles.convoSubPressed : null]} > diff --git a/packages/ui/src/bodies/thread/ThreadReplyRow.tsx b/packages/ui/src/bodies/thread/ThreadReplyRow.tsx index 890de8c2..67164deb 100644 --- a/packages/ui/src/bodies/thread/ThreadReplyRow.tsx +++ b/packages/ui/src/bodies/thread/ThreadReplyRow.tsx @@ -156,6 +156,7 @@ export const ThreadReplyRow = React.memo(function ThreadReplyRow({ accessibilityLabel={identityA11yLabel(identity, t)} hitSlop={5} {...focusRingProps} + style={({ pressed }) => (pressed ? styles.pressed : null)} > - + ) } @@ -141,7 +141,7 @@ export function LayersPopover({ eventsNearby, isClosing = false, onClosed }: Lay because none of the four rows was tagged for the house `[data-focus-ring]:focus-visible` rule. Each row already owns its own radius, so the outline traces the control as-drawn. */} [styles.row, pressed ? styles.rowPressed : null]} onPress={toggleEvents} accessibilityRole="switch" accessibilityState={{ checked: eventsEnabled }} @@ -168,7 +168,7 @@ export function LayersPopover({ eventsNearby, isClosing = false, onClosed }: Lay {/* Reports row (expands) */} [styles.row, pressed ? styles.rowPressed : null]} onPress={() => setReportsOpen((o) => !o)} accessibilityRole="button" accessibilityLabel={t("layers.reportsA11y")} @@ -192,10 +192,11 @@ export function LayersPopover({ eventsNearby, isClosing = false, onClosed }: Lay {reportsOpen ? ( [styles.allRow, pressed ? styles.rowPressed : null]} onPress={toggleAll} accessibilityRole="button" accessibilityLabel={allOn ? t("layers.clearAllA11y") : t("layers.selectAllA11y")} + hitSlop={6} {...focusRingProps} > @@ -219,7 +220,7 @@ export function LayersPopover({ eventsNearby, isClosing = false, onClosed }: Lay accessibilityLabel={t("layers.categoryPinsA11y", { category: label })} hitSlop={6} {...focusRingProps} - style={styles.catRow} + style={({ pressed }) => [styles.catRow, pressed ? styles.rowPressed : null]} > @@ -264,6 +265,9 @@ const useStyles = makeThemedStyles((t) => ({ paddingHorizontal: t.space["2"], paddingVertical: t.space["2"], }, + rowPressed: { + opacity: 0.6, + }, iconTile: { width: 32, height: 32, @@ -291,7 +295,7 @@ const useStyles = makeThemedStyles((t) => ({ marginHorizontal: t.space["2"], }, toggle: { - width: 38, + width: 40, height: 24, borderRadius: 12, justifyContent: "center", @@ -328,7 +332,7 @@ const useStyles = makeThemedStyles((t) => ({ alignItems: "center", gap: t.space["3"], paddingHorizontal: t.space["2"], - paddingVertical: 7, + paddingVertical: t.space["2"], }, catPin: { width: 20, diff --git a/packages/ui/src/map/MapControls.tsx b/packages/ui/src/map/MapControls.tsx index d270f952..5be7f659 100644 --- a/packages/ui/src/map/MapControls.tsx +++ b/packages/ui/src/map/MapControls.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react" import { View, Pressable, StyleSheet } from "react-native" -import { space, useLayoutMode, focusRingProps, makeThemedStyles, useTheme } from "../theme" +import { space, useLayoutMode, focusRingProps, makeThemedStyles, useTheme, webCursor, webHover, webTransition } from "../theme" import { Brand, GlassButton, Avatar, openBrandAbout } from "../primitives" import { Text, Icon, iconMap } from "../typography" import { BlurSurface } from "../surface" @@ -82,7 +82,14 @@ export function MapControls({ topInset = 0, onLocate }: MapControlsProps) { accessibilityRole="button" accessibilityLabel={t("brand.home")} {...focusRingProps} - style={({ pressed }) => [styles.logoPill, th.shadows.s2, pressed ? styles.pressed : null]} + style={(state) => [ + styles.logoPill, + th.shadows.s2, + webCursor(), + webTransition, + webHover(state) ? styles.hovered : null, + state.pressed ? styles.pressed : null, + ]} > @@ -187,7 +194,14 @@ function ProfileEntry() { accessibilityLabel={t("profile.signIn")} hitSlop={6} {...focusRingProps} - style={({ pressed }) => [styles.signInPill, th.shadows.s2, pressed ? styles.pressed : null]} + style={(state) => [ + styles.signInPill, + th.shadows.s2, + webCursor(), + webTransition, + webHover(state) ? styles.hovered : null, + state.pressed ? styles.pressed : null, + ]} > {t("profile.signIn")} @@ -202,7 +216,14 @@ function ProfileEntry() { accessibilityLabel={t("profile.open")} hitSlop={6} {...focusRingProps} - style={({ pressed }) => [styles.profileWrap, th.shadows.s2, pressed ? styles.pressed : null]} + style={(state) => [ + styles.profileWrap, + th.shadows.s2, + webCursor(), + webTransition, + webHover(state) ? styles.hovered : null, + state.pressed ? styles.pressed : null, + ]} > ({ fontSize: 14, color: t.colors.text, }, + hovered: { + opacity: 0.85, + }, pressed: { opacity: 0.9, transform: [{ scale: 0.96 }], diff --git a/packages/ui/src/primitives/BrandAboutCard.tsx b/packages/ui/src/primitives/BrandAboutCard.tsx index 4cdfc005..f825300d 100644 --- a/packages/ui/src/primitives/BrandAboutCard.tsx +++ b/packages/ui/src/primitives/BrandAboutCard.tsx @@ -99,7 +99,8 @@ export function BrandAboutCard({ onClose }: BrandAboutCardProps) { accessibilityRole="link" accessibilityLabel={t("legal.terms_a11y")} hitSlop={6} - {...focusRingProps} + {...focusRingProps} + style={({ pressed }) => (pressed ? styles.legalPressed : null)} > {t("legal.terms")} @@ -109,7 +110,8 @@ export function BrandAboutCard({ onClose }: BrandAboutCardProps) { accessibilityRole="link" accessibilityLabel={t("legal.privacy_a11y")} hitSlop={6} - {...focusRingProps} + {...focusRingProps} + style={({ pressed }) => (pressed ? styles.legalPressed : null)} > {t("legal.privacy")} @@ -211,6 +213,9 @@ const useStyles = makeThemedStyles((t) => ({ color: t.colors.textSubtle, textDecorationLine: "underline", }, + legalPressed: { + opacity: 0.6, + }, legalDot: { fontFamily: t.fontFamily.bodyRegular, fontSize: 12.5, diff --git a/packages/ui/src/primitives/BringInput.tsx b/packages/ui/src/primitives/BringInput.tsx index 2619997b..dae7fc86 100644 --- a/packages/ui/src/primitives/BringInput.tsx +++ b/packages/ui/src/primitives/BringInput.tsx @@ -97,6 +97,7 @@ export function BringInput({ value, onChange }: BringInputProps) { onPress={() => remove(item)} accessibilityRole="button" accessibilityLabel={t("chip.remove_a11y", { item })} + hitSlop={8} {...focusRingProps} style={(state) => [ styles.chip, @@ -132,6 +133,7 @@ export function BringInput({ value, onChange }: BringInputProps) { onPress={() => add(s)} accessibilityRole="button" accessibilityLabel={t("suggestions.add_a11y", { item: s })} + hitSlop={8} {...focusRingProps} style={(state) => [ styles.suggestPill, diff --git a/packages/ui/src/primitives/EventCard.tsx b/packages/ui/src/primitives/EventCard.tsx index b94ecd66..7d390165 100644 --- a/packages/ui/src/primitives/EventCard.tsx +++ b/packages/ui/src/primitives/EventCard.tsx @@ -1,7 +1,7 @@ import React from "react" import { View, Pressable, StyleSheet } from "react-native" import type { CleanupDTO } from "@civfix/shared" -import { makeThemedStyles, useTheme, focusRingProps } from "../theme" +import { makeThemedStyles, useTheme, focusRingProps, webCursor, webHover, webTransition } from "../theme" import { Text, Icon, iconMap } from "../typography" import { useT, useEventWhen } from "../i18n" import { Avatar } from "./Avatar" @@ -20,7 +20,13 @@ export function EventCard({ cleanup, onPress }: { cleanup: CleanupDTO; onPress: accessibilityRole="button" accessibilityLabel={t("a11y.card", { title: cleanup.title, count: cleanup.going })} {...focusRingProps} - style={({ pressed }) => [styles.card, pressed ? styles.cardPressed : null]} + style={(state) => [ + styles.card, + webCursor(), + webTransition, + webHover(state) ? styles.cardHovered : null, + state.pressed ? styles.cardPressed : null, + ]} > @@ -98,6 +104,9 @@ const useStyles = makeThemedStyles((t) => ({ padding: t.space["4"], ...t.shadows.s1, }, + cardHovered: { + backgroundColor: t.colors.surfaceTint, + }, cardPressed: { opacity: 0.92, transform: [{ scale: 0.995 }], diff --git a/packages/ui/src/primitives/FollowButton.tsx b/packages/ui/src/primitives/FollowButton.tsx index f1cb981e..bf08ac23 100644 --- a/packages/ui/src/primitives/FollowButton.tsx +++ b/packages/ui/src/primitives/FollowButton.tsx @@ -13,6 +13,12 @@ import { Text, Icon, iconMap } from "../typography" import { useFollowPerson, useRequireAuth } from "../data" import { useT } from "../i18n" +const FOLLOW_MIN_TOUCH_TARGET = 44 +const FOLLOW_HEIGHT_SM = 30 +const FOLLOW_HEIGHT_MD = 42 +const FOLLOW_SM_HIT_SLOP = (FOLLOW_MIN_TOUCH_TARGET - FOLLOW_HEIGHT_SM) / 2 +const FOLLOW_MD_HIT_SLOP = (FOLLOW_MIN_TOUCH_TARGET - FOLLOW_HEIGHT_MD) / 2 + export interface FollowButtonProps { personId: string isFollowing: boolean @@ -42,7 +48,7 @@ export function FollowButton({ personId, isFollowing, nextPath, size = "md", sty accessibilityRole="button" accessibilityState={{ selected: isFollowing, busy: follow.isPending }} accessibilityLabel={isFollowing ? t("button.following") : t("button.follow")} - hitSlop={6} + hitSlop={compact ? FOLLOW_SM_HIT_SLOP : FOLLOW_MD_HIT_SLOP} {...focusRingProps} style={(state) => [ styles.base, @@ -79,12 +85,12 @@ const useStyles = makeThemedStyles((t) => ({ }, sm: { paddingHorizontal: t.space["3"], - height: 30, + height: FOLLOW_HEIGHT_SM, minWidth: 92, }, md: { paddingHorizontal: t.space["5"], - height: 42, + height: FOLLOW_HEIGHT_MD, }, idle: { backgroundColor: t.colors.brand.bloom, diff --git a/packages/ui/src/primitives/GuestRsvpSheet.tsx b/packages/ui/src/primitives/GuestRsvpSheet.tsx index 9f303386..0eec2b04 100644 --- a/packages/ui/src/primitives/GuestRsvpSheet.tsx +++ b/packages/ui/src/primitives/GuestRsvpSheet.tsx @@ -468,11 +468,13 @@ export function GuestRsvpSheet({ accessibilityLabel={ channel === "email" ? t("form.channel_email") : t("form.channel_sms") } + hitSlop={5} {...focusRingProps} - style={[ + style={({ pressed }) => [ styles.segBtn, selected ? styles.segBtnOn : null, disabled ? styles.segBtnOff : null, + pressed && !disabled ? styles.pressedFeedback : null, ]} > @@ -592,8 +594,9 @@ export function GuestRsvpSheet({ accessibilityRole="button" accessibilityState={{ disabled: secondsLeft > 0 || sendPending }} accessibilityLabel={t("code.resend_a11y")} + hitSlop={10} {...focusRingProps} - style={styles.resend} + style={({ pressed }) => [styles.resend, pressed ? styles.pressedFeedback : null]} > 0 ? styles.resendTextOff : null]}> {secondsLeft > 0 ? t("code.resend_wait", { seconds: secondsLeft }) : t("code.resend")} @@ -673,6 +676,9 @@ const useStyles = makeThemedStyles((t) => ({ paddingVertical: t.space["1"], borderRadius: t.radius.sm, }, + pressedFeedback: { + opacity: 0.6, + }, resendText: { fontFamily: t.fontFamily.bodyBold, fontSize: 13, diff --git a/packages/ui/src/primitives/MessageContextMenu.tsx b/packages/ui/src/primitives/MessageContextMenu.tsx index 8b31ffbd..12926a46 100644 --- a/packages/ui/src/primitives/MessageContextMenu.tsx +++ b/packages/ui/src/primitives/MessageContextMenu.tsx @@ -163,6 +163,7 @@ export function MessageContextMenu({ accessibilityRole="button" accessibilityLabel={tReactions(`label.${emoji}`)} accessibilityState={{ selected }} + hitSlop={compact ? { top: 8, bottom: 8 } : { top: 4, bottom: 4 }} {...focusRingProps} style={(state) => [ compact ? styles.glyphButtonCompact : styles.glyphButton, diff --git a/packages/ui/src/primitives/ReportContentSheet.tsx b/packages/ui/src/primitives/ReportContentSheet.tsx index ada204bd..bbc90ccd 100644 --- a/packages/ui/src/primitives/ReportContentSheet.tsx +++ b/packages/ui/src/primitives/ReportContentSheet.tsx @@ -92,7 +92,11 @@ export function ReportContentSheet({ [ + styles.reasonRow, + selected ? styles.reasonRowSelected : null, + pressed ? styles.reasonRowPressed : null, + ]} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ selected }} @@ -147,6 +151,9 @@ const useStyles = makeThemedStyles((t) => ({ reasonRowSelected: { borderColor: t.colors.accent, }, + reasonRowPressed: { + opacity: 0.6, + }, reasonLabel: { flex: 1, }, diff --git a/packages/ui/src/primitives/SettingsRow.tsx b/packages/ui/src/primitives/SettingsRow.tsx index 0242fa41..c7d788b5 100644 --- a/packages/ui/src/primitives/SettingsRow.tsx +++ b/packages/ui/src/primitives/SettingsRow.tsx @@ -1,6 +1,6 @@ import React from "react" import { View, Pressable, StyleSheet, type StyleProp, type ViewStyle } from "react-native" -import { makeThemedStyles, space, useTheme, focusRingProps, webCursor, headingLevel } from "../theme" +import { makeThemedStyles, space, useTheme, focusRingProps, webCursor, webHover, webTransition, headingLevel } from "../theme" import { Text, Icon, iconMap, type IconName } from "../typography" import { SettingsToggle } from "./SettingsToggle" @@ -153,11 +153,13 @@ export function SettingsRow({ accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} {...focusRingProps} - style={({ pressed }) => [ + style={(state) => [ styles.row, webCursor(disabled), + webTransition, disabled ? styles.rowDisabled : null, - pressed && !disabled ? styles.rowPressed : null, + webHover(state) && !disabled ? styles.rowHovered : null, + state.pressed && !disabled ? styles.rowPressed : null, ]} > @@ -205,6 +207,9 @@ const useStyles = makeThemedStyles((t) => ({ paddingVertical: t.space["2"], paddingHorizontal: ROW_PAD_H, }, + rowHovered: { + backgroundColor: t.colors.surfaceTint, + }, rowPressed: { backgroundColor: t.colors.bgAlt, }, diff --git a/packages/ui/src/primitives/__tests__/settingsRow.test.ts b/packages/ui/src/primitives/__tests__/settingsRow.test.ts index 7c90e6cd..e11fae0d 100644 --- a/packages/ui/src/primitives/__tests__/settingsRow.test.ts +++ b/packages/ui/src/primitives/__tests__/settingsRow.test.ts @@ -31,7 +31,7 @@ describe("SettingsRow", () => { it("gives every focusable stop the coral ring", () => { expect(row).toContain( - 'import { makeThemedStyles, space, useTheme, focusRingProps, webCursor, headingLevel } from "../theme"', + 'import { makeThemedStyles, space, useTheme, focusRingProps, webCursor, webHover, webTransition, headingLevel } from "../theme"', ) expect(row.match(/\{\.\.\.focusRingProps\}/g) ?? []).toHaveLength(2) }) diff --git a/packages/ui/src/promo/AppPromoCard.tsx b/packages/ui/src/promo/AppPromoCard.tsx index c0e0c1cf..935b99ab 100644 --- a/packages/ui/src/promo/AppPromoCard.tsx +++ b/packages/ui/src/promo/AppPromoCard.tsx @@ -7,7 +7,7 @@ import { StyleSheet, type LayoutChangeEvent, } from "react-native" -import { makeThemedStyles, useTheme, focusRingProps, headingLevel } from "../theme" +import { makeThemedStyles, useTheme, focusRingProps, headingLevel, webCursor, webTransition, webHover } from "../theme" import { Text, Icon, iconMap } from "../typography" import { useT } from "../i18n" import { useAppPromo } from "./useAppPromo" @@ -53,7 +53,13 @@ export function AppPromoCard() { accessibilityLabel={t("app_promo.dismiss")} hitSlop={6} {...focusRingProps} - style={({ pressed }) => [styles.dismiss, pressed ? styles.dismissPressed : null]} + style={(state) => [ + styles.dismiss, + webCursor(), + webTransition, + webHover(state) ? styles.hovered : null, + state.pressed ? styles.dismissPressed : null, + ]} > @@ -70,7 +76,13 @@ export function AppPromoCard() { accessibilityLabel={t(link.labelKey)} {...linkKeyProps(() => void Linking.openURL(link.href))} {...focusRingProps} - style={({ pressed }) => [styles.badge, pressed ? styles.badgePressed : null]} + style={(state) => [ + styles.badge, + webCursor(), + webTransition, + webHover(state) ? styles.hovered : null, + state.pressed ? styles.badgePressed : null, + ]} > ({ dismissPressed: { opacity: 0.55, }, + hovered: { + opacity: 0.85, + }, body: { fontFamily: t.fontFamily.bodyRegular, fontSize: 12.5, diff --git a/packages/ui/src/share/ShareActionTile.tsx b/packages/ui/src/share/ShareActionTile.tsx index 04063eeb..6ef7cfc0 100644 --- a/packages/ui/src/share/ShareActionTile.tsx +++ b/packages/ui/src/share/ShareActionTile.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from "react" import { AccessibilityInfo, Pressable, StyleSheet, View } from "react-native" -import { makeThemedStyles, useTheme, focusRingProps, type Theme } from "../theme" +import { makeThemedStyles, useTheme, focusRingProps, type Theme, webCursor, webTransition, webHover } from "../theme" import { Text, Icon, iconMap, type IconName } from "../typography" import type { ShareTileTone } from "./shareSheetModel" @@ -58,7 +58,14 @@ export function ShareActionTile({ accessibilityValue={status === undefined ? undefined : { text: status }} accessibilityState={{ disabled }} {...focusRingProps} - style={({ pressed }) => [styles.tile, pressed ? styles.pressed : null, disabled ? styles.disabled : null]} + style={(state) => [ + styles.tile, + webCursor(disabled), + webTransition, + webHover(state) && !disabled ? styles.hovered : null, + state.pressed ? styles.pressed : null, + disabled ? styles.disabled : null, + ]} > @@ -89,6 +96,9 @@ const useStyles = makeThemedStyles((t) => ({ label: { textAlign: "center", }, + hovered: { + opacity: 0.85, + }, pressed: { opacity: 0.7, }, diff --git a/packages/ui/src/share/SharePeople.tsx b/packages/ui/src/share/SharePeople.tsx index d238ea24..43b32673 100644 --- a/packages/ui/src/share/SharePeople.tsx +++ b/packages/ui/src/share/SharePeople.tsx @@ -1,7 +1,7 @@ import React, { memo, useCallback } from "react" import { ActivityIndicator, FlatList, Pressable, StyleSheet, View } from "react-native" import type { PersonDTO, UserSearchResultDTO } from "@civfix/shared" -import { makeThemedStyles, useTheme, focusRingProps } from "../theme" +import { makeThemedStyles, useTheme, focusRingProps, webCursor, webTransition, webHover } from "../theme" import { Text, Icon, iconMap } from "../typography" import { useT } from "../i18n" import { Avatar } from "../primitives/Avatar" @@ -48,7 +48,13 @@ const PersonTile = memo(function PersonTile({ accessibilityState={{ checked: selected }} accessibilityLabel={t("recipients.select_a11y", { name: person.displayName })} {...focusRingProps} - style={({ pressed }) => [styles.tile, pressed ? styles.pressed : null]} + style={(state) => [ + styles.tile, + webCursor(), + webTransition, + webHover(state) ? styles.hovered : null, + state.pressed ? styles.pressed : null, + ]} > [styles.row, pressed ? styles.pressed : null]} + style={(state) => [ + styles.row, + webCursor(), + webTransition, + webHover(state) ? styles.hovered : null, + state.pressed ? styles.pressed : null, + ]} > ({ borderWidth: 2, borderColor: t.colors.surface, }, + hovered: { + opacity: 0.85, + }, pressed: { opacity: 0.7, }, diff --git a/packages/ui/src/shell/DetailBar.tsx b/packages/ui/src/shell/DetailBar.tsx index 91f9a37d..75dd5811 100644 --- a/packages/ui/src/shell/DetailBar.tsx +++ b/packages/ui/src/shell/DetailBar.tsx @@ -1,6 +1,6 @@ import React from "react" import { View, Pressable } from "react-native" -import { makeThemedStyles, motion, useTheme } from "../theme" +import { focusRingProps, makeThemedStyles, motion, useTheme, webCursor, webHover, webTransition } from "../theme" import { Text, Icon, iconMap } from "../typography" import { useT } from "../i18n" import { DETAIL_BACK_SIZE, DETAIL_BACK_RADIUS, DETAIL_BACK_ICON_SIZE, detailTitleStyle } from "./detailHeader" @@ -44,7 +44,14 @@ export function DetailBar({ accessibilityRole="button" accessibilityLabel={isClose ? t("a11y.close") : t("a11y.back")} hitSlop={8} - style={({ pressed }) => [styles.back, pressed ? styles.pressed : null]} + {...focusRingProps} + style={(state) => [ + styles.back, + webCursor(), + webTransition, + webHover(state) ? styles.backHovered : null, + state.pressed ? styles.pressed : null, + ]} > ({ justifyContent: "center", marginLeft: -t.space["2"], }, + backHovered: { + backgroundColor: t.colors.surfaceTint, + }, pressed: { opacity: 0.6, backgroundColor: t.colors.bgAlt, diff --git a/packages/ui/src/shell/DetailTrailingButton.tsx b/packages/ui/src/shell/DetailTrailingButton.tsx index c01d9b2f..ae3dd07c 100644 --- a/packages/ui/src/shell/DetailTrailingButton.tsx +++ b/packages/ui/src/shell/DetailTrailingButton.tsx @@ -1,6 +1,6 @@ import React from "react" import { Pressable } from "react-native" -import { focusRingProps, makeThemedStyles, useTheme, webHover, webTransition } from "../theme" +import { focusRingProps, makeThemedStyles, useTheme, webCursor, webHover, webTransition } from "../theme" import { Icon, iconMap } from "../typography" import { useNavStore } from "../nav" import { useT } from "../i18n" @@ -31,6 +31,7 @@ export function DetailTrailingButton({ action }: DetailTrailingButtonProps) { {...focusRingProps} style={(state) => [ styles.chip, + webCursor(), webTransition, webHover(state) ? styles.chipHovered : null, state.pressed ? styles.pressed : null, diff --git a/packages/ui/src/shell/ExpandedShell.tsx b/packages/ui/src/shell/ExpandedShell.tsx index 07aee2e6..6b9dc594 100644 --- a/packages/ui/src/shell/ExpandedShell.tsx +++ b/packages/ui/src/shell/ExpandedShell.tsx @@ -10,7 +10,7 @@ import { type ViewStyle, } from "react-native" import { ArrowLeft } from "lucide-react-native/icons" -import { motion, wash, makeThemedStyles, useTheme, webCursorColResize, focusRingProps } from "../theme" +import { motion, wash, makeThemedStyles, useTheme, webCursor, webCursorColResize, focusRingProps } from "../theme" import { Text, Icon } from "../typography" import { useT } from "../i18n" import { useNavStore, titleForEntry, titleParamsForEntry, type DetailEntry, type View as NavView } from "../nav" @@ -290,8 +290,9 @@ function PanelHeader({ accessibilityRole="button" accessibilityLabel={t("a11y.back")} onPress={() => useNavStore.getState().back()} + hitSlop={8} {...focusRingProps} - style={styles.back} + style={({ pressed }) => [styles.back, webCursor(), pressed ? styles.backPressed : null]} > @@ -354,6 +355,10 @@ const useStyles = makeThemedStyles((t) => ({ alignItems: "center", justifyContent: "center", }, + backPressed: { + opacity: 0.6, + backgroundColor: t.colors.bgAlt, + }, title: { ...detailTitleStyle(18, t), flex: 1 }, fill: { flex: 1 }, panelContent: { flex: 1 }, diff --git a/packages/ui/src/shell/SearchHeader.native.tsx b/packages/ui/src/shell/SearchHeader.native.tsx index e0ffb2dc..0260d3e6 100644 --- a/packages/ui/src/shell/SearchHeader.native.tsx +++ b/packages/ui/src/shell/SearchHeader.native.tsx @@ -121,7 +121,7 @@ export function SearchHeader(props: SearchHeaderProps) { accessibilityRole="button" accessibilityLabel={t("a11y.sign_in")} hitSlop={8} - style={styles.signIn} + style={({ pressed }) => [styles.signIn, pressed ? styles.buttonPressed : null]} > @@ -131,6 +131,7 @@ export function SearchHeader(props: SearchHeaderProps) { accessibilityRole="button" accessibilityLabel={t("a11y.open_profile")} hitSlop={8} + style={({ pressed }) => (pressed ? styles.buttonPressed : null)} > diff --git a/packages/ui/src/shell/SearchHeader.styles.ts b/packages/ui/src/shell/SearchHeader.styles.ts index 3fe1282c..99a5b369 100644 --- a/packages/ui/src/shell/SearchHeader.styles.ts +++ b/packages/ui/src/shell/SearchHeader.styles.ts @@ -56,4 +56,10 @@ export const useSearchHeaderStyles = makeThemedStyles((t) => ({ justifyContent: "center", backgroundColor: t.glass.sheet.input, }, + buttonHovered: { + opacity: 0.85, + }, + buttonPressed: { + opacity: 0.6, + }, })) diff --git a/packages/ui/src/shell/SearchHeader.web.tsx b/packages/ui/src/shell/SearchHeader.web.tsx index 2a63570d..bef905f6 100644 --- a/packages/ui/src/shell/SearchHeader.web.tsx +++ b/packages/ui/src/shell/SearchHeader.web.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react" import { View, Pressable, TextInput } from "react-native" -import { space, useTheme } from "../theme" +import { focusRingProps, space, useTheme, webCursor, webHover, webTransition } from "../theme" import { Icon, iconMap } from "../typography" import { Avatar } from "../primitives" import { useT } from "../i18n" @@ -61,9 +61,16 @@ function DockedSearchBar(props: SearchHeaderProps) { accessibilityRole="button" accessibilityLabel={t("a11y.home")} hitSlop={8} - style={styles.homeCircle} + {...focusRingProps} + style={(state) => [ + styles.homeCircle, + webCursor(), + webTransition, + webHover(state) ? styles.buttonHovered : null, + state.pressed ? styles.buttonPressed : null, + ]} > - + ) : null} [ + styles.clear, + webCursor(), + webTransition, + webHover(state) ? styles.buttonHovered : null, + state.pressed ? styles.buttonPressed : null, + ]} > @@ -108,7 +122,14 @@ export function SearchHeader(props: SearchHeaderProps) { accessibilityRole="button" accessibilityLabel={t("a11y.sign_in")} hitSlop={8} - style={styles.signIn} + {...focusRingProps} + style={(state) => [ + styles.signIn, + webCursor(), + webTransition, + webHover(state) ? styles.buttonHovered : null, + state.pressed ? styles.buttonPressed : null, + ]} > @@ -118,6 +139,13 @@ export function SearchHeader(props: SearchHeaderProps) { accessibilityRole="button" accessibilityLabel={t("a11y.open_profile")} hitSlop={8} + {...focusRingProps} + style={(state) => [ + webCursor(), + webTransition, + webHover(state) ? styles.buttonHovered : null, + state.pressed ? styles.buttonPressed : null, + ]} > diff --git a/packages/ui/src/shell/TabBar.shared.tsx b/packages/ui/src/shell/TabBar.shared.tsx index 8e0e23a6..bcaa570e 100644 --- a/packages/ui/src/shell/TabBar.shared.tsx +++ b/packages/ui/src/shell/TabBar.shared.tsx @@ -113,7 +113,7 @@ export function TabButton({ accessibilityRole="tab" accessibilityState={{ selected: active }} accessibilityLabel={label} - style={styles.tab} + style={({ pressed }) => [styles.tab, pressed ? styles.pressed : null]} > From 5252119fc353c03dc0b3aad7d9b0f4f1f6685c59 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:46:24 -0700 Subject: [PATCH 011/105] 44pt search field family across list bodies --- packages/ui/src/bodies/ConnectionsBody.tsx | 4 +++- packages/ui/src/bodies/EventsBody.tsx | 4 +++- packages/ui/src/bodies/ReportsBody.tsx | 4 +++- packages/ui/src/bodies/SearchBody.tsx | 13 ++++++------- .../ui/src/bodies/__tests__/searchField.test.ts | 8 +++++--- .../ui/src/bodies/__tests__/touchTargets.test.ts | 8 +++----- 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/bodies/ConnectionsBody.tsx b/packages/ui/src/bodies/ConnectionsBody.tsx index b82f9cf6..4afbe441 100644 --- a/packages/ui/src/bodies/ConnectionsBody.tsx +++ b/packages/ui/src/bodies/ConnectionsBody.tsx @@ -206,12 +206,14 @@ export function ConnectionsBody({ id, mode }: ConnectionsBodyProps) { ) } +const MIN_TOUCH_TARGET = 44 + const useStyles = makeThemedStyles((t) => ({ searchField: { flexDirection: "row", alignItems: "center", gap: 9, - height: 42, + minHeight: MIN_TOUCH_TARGET, marginTop: t.space["2"], marginBottom: t.space["2"], paddingHorizontal: 12, diff --git a/packages/ui/src/bodies/EventsBody.tsx b/packages/ui/src/bodies/EventsBody.tsx index 295059fd..7c3be363 100644 --- a/packages/ui/src/bodies/EventsBody.tsx +++ b/packages/ui/src/bodies/EventsBody.tsx @@ -349,6 +349,8 @@ function EventsHeader({ ) } +const MIN_TOUCH_TARGET = 44 + const useStyles = makeThemedStyles((t) => ({ list: { flex: 1, @@ -467,7 +469,7 @@ const useStyles = makeThemedStyles((t) => ({ flexDirection: "row", alignItems: "center", gap: 9, - height: 42, + minHeight: MIN_TOUCH_TARGET, marginTop: t.space["2"], marginBottom: t.space["2"], paddingHorizontal: 12, diff --git a/packages/ui/src/bodies/ReportsBody.tsx b/packages/ui/src/bodies/ReportsBody.tsx index ecc8e32d..d9c0fafc 100644 --- a/packages/ui/src/bodies/ReportsBody.tsx +++ b/packages/ui/src/bodies/ReportsBody.tsx @@ -210,12 +210,14 @@ function ReportsHeader({ expanded }: { expanded: boolean }) { ) } +const MIN_TOUCH_TARGET = 44 + const useStyles = makeThemedStyles((t) => ({ searchField: { flexDirection: "row", alignItems: "center", gap: 9, - height: 42, + minHeight: MIN_TOUCH_TARGET, marginTop: t.space["2"], marginBottom: t.space["2"], paddingHorizontal: 12, diff --git a/packages/ui/src/bodies/SearchBody.tsx b/packages/ui/src/bodies/SearchBody.tsx index 3f0a4d45..a36686a6 100644 --- a/packages/ui/src/bodies/SearchBody.tsx +++ b/packages/ui/src/bodies/SearchBody.tsx @@ -583,10 +583,9 @@ function SearchResting({ const MIN_TOUCH_TARGET = 44 const FIELD_CLEAR_SIZE = 22 -const FIELD_HEIGHT = 40 const FIELD_CLEAR_HIT_SLOP = { - top: (FIELD_HEIGHT - FIELD_CLEAR_SIZE) / 2, - bottom: (FIELD_HEIGHT - FIELD_CLEAR_SIZE) / 2, + top: (MIN_TOUCH_TARGET - FIELD_CLEAR_SIZE) / 2, + bottom: (MIN_TOUCH_TARGET - FIELD_CLEAR_SIZE) / 2, left: (MIN_TOUCH_TARGET - FIELD_CLEAR_SIZE) / 2, right: (MIN_TOUCH_TARGET - FIELD_CLEAR_SIZE) / 2, } @@ -612,10 +611,10 @@ const useStyles = makeThemedStyles((t) => ({ field: { flexDirection: "row", alignItems: "center", - gap: 10, - height: FIELD_HEIGHT, - paddingHorizontal: 14, - borderRadius: 12, + gap: 9, + minHeight: MIN_TOUCH_TARGET, + paddingHorizontal: 12, + borderRadius: t.radius.md, backgroundColor: t.colors.surface, ...(Platform.OS === "web" ? { borderWidth: 1, borderColor: wash(t.colors.borderStrong, 0.45, t) } diff --git a/packages/ui/src/bodies/__tests__/searchField.test.ts b/packages/ui/src/bodies/__tests__/searchField.test.ts index 43d091df..43456e52 100644 --- a/packages/ui/src/bodies/__tests__/searchField.test.ts +++ b/packages/ui/src/bodies/__tests__/searchField.test.ts @@ -121,9 +121,11 @@ describe("the entry points that focus it", () => { }) describe("the field's material", () => { - it("keeps the docked pill's design literals and takes no new i18n key", () => { - expect(bodyCode).toContain("const FIELD_HEIGHT = 40") - expect(bodyCode).toMatch(/gap: 10,\s*\n\s*height: FIELD_HEIGHT,\s*\n\s*paddingHorizontal: 14,\s*\n\s*borderRadius: 12/) + it("keeps the docked pill on the shared list-field recipe and takes no new i18n key", () => { + expect(bodyCode).toContain("const MIN_TOUCH_TARGET = 44") + expect(bodyCode).toMatch( + /gap: 9,\s*\n\s*minHeight: MIN_TOUCH_TARGET,\s*\n\s*paddingHorizontal: 12,\s*\n\s*borderRadius: t\.radius\.md/, + ) expect(bodyCode).toContain("backgroundColor: t.colors.surface") expect(bodyCode).toContain('searchModeFor("search", null).placeholder') expect(bodyCode).toContain('tSearch("a11y.clear")') diff --git a/packages/ui/src/bodies/__tests__/touchTargets.test.ts b/packages/ui/src/bodies/__tests__/touchTargets.test.ts index fda7674e..ed79e583 100644 --- a/packages/ui/src/bodies/__tests__/touchTargets.test.ts +++ b/packages/ui/src/bodies/__tests__/touchTargets.test.ts @@ -60,15 +60,13 @@ describe("ComposerThumbs: the remove button lives INSIDE the thumb it belongs to describe("SearchBody: the field clear chip and the link actions clear 44pt", () => { const SRC = read("../SearchBody.tsx") - it("caps the clear chip's slop at the pinned 40pt pill it lives in", () => { + it("grows the clear chip's slop to the shared 44pt target inside the 44pt field", () => { const size = num(SRC, "FIELD_CLEAR_SIZE") - const field = num(SRC, "FIELD_HEIGHT") - expect(SRC).toContain("height: FIELD_HEIGHT") + expect(SRC).toContain("minHeight: MIN_TOUCH_TARGET") expect(SRC).toContain("hitSlop={FIELD_CLEAR_HIT_SLOP}") const vertical = grown(SRC, "FIELD_CLEAR_HIT_SLOP", "top", "bottom", size) const horizontal = grown(SRC, "FIELD_CLEAR_HIT_SLOP", "left", "right", size) - expect(vertical, "the slop must not escape the pill").toBeLessThanOrEqual(field) - expect(vertical).toBe(field) + expect(vertical).toBe(MIN_TOUCH_TARGET) expect(horizontal).toBe(MIN_TOUCH_TARGET) }) From c425c9bfbc5440aa93f315faeaa22e6a266e00e4 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:46:24 -0700 Subject: [PATCH 012/105] load-more pills consolidated onto sectionStyles --- packages/ui/src/bodies/PersonDetailBody.tsx | 29 ++++++++----------- packages/ui/src/bodies/PostDetailBody.tsx | 11 ++++--- packages/ui/src/bodies/SavedPostsBody.tsx | 11 ++++--- .../__tests__/postScrollOwnership.test.ts | 6 ++-- .../bodies/profile/ProfileEventsSection.tsx | 17 ++++------- 5 files changed, 34 insertions(+), 40 deletions(-) diff --git a/packages/ui/src/bodies/PersonDetailBody.tsx b/packages/ui/src/bodies/PersonDetailBody.tsx index cdb905c5..a04164af 100644 --- a/packages/ui/src/bodies/PersonDetailBody.tsx +++ b/packages/ui/src/bodies/PersonDetailBody.tsx @@ -52,6 +52,7 @@ import { PROFILE_TIMELINE_BLEED, ProfileTimelineLane } from "./profile/ProfileTi import { ServiceHoursSection } from "./profile/ServiceHoursSection" import { PROFILE_DEFAULT_TAB, buildProfileTabsModel, type ProfileTabId } from "./profileTabsModel" import { splitProfileEvents } from "./profile/profileEventSplit" +import { useSectionStyles } from "./profile/sectionStyles" function MiniEventRow({ event, @@ -115,6 +116,7 @@ function PersonScroll({ children }: { children: React.ReactNode }) { export function PersonDetailBody({ id, onBack }: { id: string; onBack?: () => void }) { const styles = useStyles() + const sectionStyles = useSectionStyles() const th = useTheme() const { t } = useT("profile-person") const { t: tNav } = useT("nav") @@ -479,14 +481,17 @@ export function PersonDetailBody({ id, onBack }: { id: string; onBack?: () => vo {postsQuery.hasNextPage ? ( [ + sectionStyles.loadMore, + pressed ? sectionStyles.loadMorePressed : null, + ]} onPress={onLoadMorePosts} disabled={postsQuery.isFetchingNextPage} accessibilityRole="button" accessibilityState={{ disabled: postsQuery.isFetchingNextPage, busy: postsQuery.isFetchingNextPage }} accessibilityLabel={t("posts.load_more_a11y")} > - + {postsQuery.isFetchingNextPage ? t("posts.loading_more") : t("posts.load_more")} @@ -555,9 +560,12 @@ export function PersonDetailBody({ id, onBack }: { id: string; onBack?: () => vo }} accessibilityLabel={t("events.load_more_a11y")} {...focusRingProps} - style={styles.loadMore} + style={({ pressed }) => [ + sectionStyles.loadMore, + pressed ? sectionStyles.loadMorePressed : null, + ]} > - + {pastEvents.isLoadingMore ? t("events.loading_more") : pastEvents.isRetry @@ -960,19 +968,6 @@ const useStyles = makeThemedStyles((t) => ({ marginHorizontal: 5, }, - loadMore: { - alignSelf: "center", - marginTop: t.space["3"], - paddingVertical: t.space["2"], - paddingHorizontal: t.space["5"], - borderRadius: t.radius.pill, - backgroundColor: t.colors.bgAlt, - }, - loadMoreText: { - fontFamily: t.fontFamily.bodyBold, - fontSize: 13, - color: t.colors.textMuted, - }, postsLane: { marginBottom: t.space["5"], }, diff --git a/packages/ui/src/bodies/PostDetailBody.tsx b/packages/ui/src/bodies/PostDetailBody.tsx index 9b440513..96296f9c 100644 --- a/packages/ui/src/bodies/PostDetailBody.tsx +++ b/packages/ui/src/bodies/PostDetailBody.tsx @@ -8,9 +8,11 @@ import { useNavStore } from "../nav" import { useScrollHost } from "../shell/ScrollHost" import { PostCard } from "./PostCard" import { ProfileTimelineLane } from "./profile/ProfileTimelineLane" +import { useSectionStyles } from "./profile/sectionStyles" export function PostDetailBody({ id }: { id: string }) { const styles = useStyles() + const sectionStyles = useSectionStyles() const { t } = useT("home-feed") const { ScrollView } = useScrollHost() const query = usePost(id) @@ -39,10 +41,13 @@ export function PostDetailBody({ id }: { id: string }) { [ + sectionStyles.loadMore, + pressed ? sectionStyles.loadMorePressed : null, + ]} onPress={() => useNavStore.getState().push({ kind: "post-thread", id })} > - {t("thread.view_conversation")} + {t("thread.view_conversation")} ) @@ -52,6 +57,4 @@ const useStyles = makeThemedStyles((t) => ({ root: { flex: 1, backgroundColor: t.colors.bg }, content: { flexGrow: 1, paddingBottom: t.space["4"] }, state: { padding: t.space["6"], textAlign: "center", color: t.colors.textSubtle }, - threadButton: { alignSelf: "center", marginTop: t.space["4"], paddingHorizontal: t.space["5"], paddingVertical: t.space["2"], borderRadius: t.radius.pill, backgroundColor: t.colors.bgAlt }, - threadText: { color: t.colors.accentText, fontFamily: t.fontFamily.bodyBold }, })) diff --git a/packages/ui/src/bodies/SavedPostsBody.tsx b/packages/ui/src/bodies/SavedPostsBody.tsx index e234e171..fd813f87 100644 --- a/packages/ui/src/bodies/SavedPostsBody.tsx +++ b/packages/ui/src/bodies/SavedPostsBody.tsx @@ -8,9 +8,11 @@ import { useT } from "../i18n" import { useScrollHost } from "../shell/ScrollHost" import { PostCard } from "./PostCard" import { ProfileTimelineLane } from "./profile/ProfileTimelineLane" +import { useSectionStyles } from "./profile/sectionStyles" export function SavedPostsBody() { const styles = useStyles() + const sectionStyles = useSectionStyles() const { t } = useT("home-feed") const { FlatList } = useScrollHost() const query = useSaves() @@ -43,10 +45,13 @@ export function SavedPostsBody() { accessibilityState={{ disabled: isFetchingNextPage }} disabled={isFetchingNextPage} {...focusRingProps} - style={styles.more} + style={({ pressed }) => [ + sectionStyles.loadMore, + pressed ? sectionStyles.loadMorePressed : null, + ]} onPress={loadMore} > - + {isFetchingNextPage ? t("thread.loading") : t("saved.load_more")} @@ -73,6 +78,4 @@ const useStyles = makeThemedStyles((t) => ({ root: { flex: 1, backgroundColor: t.colors.bg }, content: { flexGrow: 1, paddingBottom: t.space["4"] }, state: { paddingVertical: t.space["6"], paddingHorizontal: t.space["4"], textAlign: "center", color: t.colors.textSubtle }, - more: { alignSelf: "center", marginTop: t.space["4"], paddingHorizontal: t.space["5"], paddingVertical: t.space["2"], borderRadius: t.radius.pill, backgroundColor: t.colors.bgAlt }, - moreText: { color: t.colors.accentText, fontFamily: t.fontFamily.bodyBold }, })) diff --git a/packages/ui/src/bodies/__tests__/postScrollOwnership.test.ts b/packages/ui/src/bodies/__tests__/postScrollOwnership.test.ts index 6e048a48..4351739c 100644 --- a/packages/ui/src/bodies/__tests__/postScrollOwnership.test.ts +++ b/packages/ui/src/bodies/__tests__/postScrollOwnership.test.ts @@ -38,8 +38,8 @@ describe("the post surfaces own their scroll", () => { const close = source.lastIndexOf("") expect(open, "PostDetailBody renders no ScrollView").toBeGreaterThan(-1) expect(close, "PostDetailBody closes no ScrollView").toBeGreaterThan(open) - const at = source.indexOf("styles.threadButton") - expect(at, "PostDetailBody renders no styles.threadButton").toBeGreaterThan(-1) + const at = source.indexOf("sectionStyles.loadMore") + expect(at, "PostDetailBody renders no sectionStyles.loadMore").toBeGreaterThan(-1) expect(at, "PostDetailBody: the tail sits outside the scroll container").toBeLessThan(close) expect(at, "PostDetailBody: the tail sits above the scroll container").toBeGreaterThan(open) }) @@ -47,7 +47,7 @@ describe("the post surfaces own their scroll", () => { it("hands SavedPostsBody's pager to the list as its footer", () => { const source = BODIES["SavedPostsBody.tsx"].source expect(source).toMatch(/const footer = hasNextPage \?/) - expect(source).toMatch(/style=\{styles\.more\}/) + expect(source).toMatch(/sectionStyles\.loadMore/) expect(source).toMatch(/ListFooterComponent=\{footer\}/) }) diff --git a/packages/ui/src/bodies/profile/ProfileEventsSection.tsx b/packages/ui/src/bodies/profile/ProfileEventsSection.tsx index 58e73f42..6a53cc2c 100644 --- a/packages/ui/src/bodies/profile/ProfileEventsSection.tsx +++ b/packages/ui/src/bodies/profile/ProfileEventsSection.tsx @@ -219,9 +219,12 @@ export function ProfileEventsSection({ accessibilityState={{ disabled: more.isLoadingMore, busy: more.isLoadingMore }} accessibilityLabel={t("events.load_more_a11y")} {...focusRingProps} - style={styles.more} + style={({ pressed }) => [ + sectionStyles.loadMore, + pressed ? sectionStyles.loadMorePressed : null, + ]} > - + {more.isLoadingMore ? t("events.loading_more") : more.isRetry @@ -237,16 +240,6 @@ export function ProfileEventsSection({ } const useStyles = makeThemedStyles((t) => ({ - more: { - alignSelf: "flex-start", - paddingVertical: t.space["1"], - borderRadius: t.radius.sm, - }, - moreText: { - fontFamily: t.fontFamily.bodyBold, - fontSize: 13, - color: t.colors.accentText, - }, seg: { flexDirection: "row", gap: 4, From a0b443b1f06c08239a056067b446c805972536f4 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:46:24 -0700 Subject: [PATCH 013/105] text clamps and token substitutions in ui --- packages/ui/src/bodies/AffiliationRow.tsx | 4 +++- packages/ui/src/bodies/EventHoursBlock.tsx | 2 +- packages/ui/src/bodies/ProfileBody.tsx | 4 +++- packages/ui/src/map/LocationPicker.web.tsx | 2 +- packages/ui/src/primitives/ModalCardSheet.tsx | 2 +- packages/ui/src/primitives/SecondaryButton.tsx | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/bodies/AffiliationRow.tsx b/packages/ui/src/bodies/AffiliationRow.tsx index 5e888cbb..4f86013f 100644 --- a/packages/ui/src/bodies/AffiliationRow.tsx +++ b/packages/ui/src/bodies/AffiliationRow.tsx @@ -42,7 +42,9 @@ export function AffiliationRow({ decorative /> - {t("affiliation.label")} + + {t("affiliation.label")} + {organization.name} diff --git a/packages/ui/src/bodies/EventHoursBlock.tsx b/packages/ui/src/bodies/EventHoursBlock.tsx index f2cfa3a1..8ca62206 100644 --- a/packages/ui/src/bodies/EventHoursBlock.tsx +++ b/packages/ui/src/bodies/EventHoursBlock.tsx @@ -259,7 +259,7 @@ function AttendeeReceipt({ style={({ pressed }) => [styles.credited, pressed ? styles.pressed : null]} > - + {t(range ? "receipt.credited_shift" : "receipt.credited", vars)} diff --git a/packages/ui/src/bodies/ProfileBody.tsx b/packages/ui/src/bodies/ProfileBody.tsx index 140be951..a70e8dcb 100644 --- a/packages/ui/src/bodies/ProfileBody.tsx +++ b/packages/ui/src/bodies/ProfileBody.tsx @@ -39,7 +39,9 @@ function DashboardRow({ onOpen }: { onOpen: () => void }) { - {t("dashboard.title")} + + {t("dashboard.title")} + {t("dashboard.sub")} diff --git a/packages/ui/src/map/LocationPicker.web.tsx b/packages/ui/src/map/LocationPicker.web.tsx index dae25cf5..3bc1c3b9 100644 --- a/packages/ui/src/map/LocationPicker.web.tsx +++ b/packages/ui/src/map/LocationPicker.web.tsx @@ -292,7 +292,7 @@ function makeStyles(t: Theme): Record { overlayDot: { width: 12, height: 12, - borderRadius: 999, + borderRadius: t.radius.pill, background: t.colors.brand.bloom, border: `2px solid ${t.colors.onAccent}`, boxShadow: shadowSchemes[t.scheme].pin, diff --git a/packages/ui/src/primitives/ModalCardSheet.tsx b/packages/ui/src/primitives/ModalCardSheet.tsx index 103b20c2..711b7ce1 100644 --- a/packages/ui/src/primitives/ModalCardSheet.tsx +++ b/packages/ui/src/primitives/ModalCardSheet.tsx @@ -156,7 +156,7 @@ export function ModalCardSheet({ ) : ( )} - + {title} {fullBleed ? ( diff --git a/packages/ui/src/primitives/SecondaryButton.tsx b/packages/ui/src/primitives/SecondaryButton.tsx index 658cad75..15230b49 100644 --- a/packages/ui/src/primitives/SecondaryButton.tsx +++ b/packages/ui/src/primitives/SecondaryButton.tsx @@ -73,7 +73,7 @@ export function SecondaryButton({ > {label} - {TrailingIconCmp ? : null} + {TrailingIconCmp ? : null} ) } From 6d13d1158f0f2edbcbef2b6f76482fa173657247 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:46:24 -0700 Subject: [PATCH 014/105] mobile overlay header ink and camera affordances --- .../components/report/ReportViewfinder.tsx | 25 ++++++++++++++++--- .../src/components/scan/TicketScanner.tsx | 2 +- .../src/components/ui/ScreenHeader.tsx | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/community-mobile/src/components/report/ReportViewfinder.tsx b/apps/community-mobile/src/components/report/ReportViewfinder.tsx index 70f8b385..64ade3f7 100644 --- a/apps/community-mobile/src/components/report/ReportViewfinder.tsx +++ b/apps/community-mobile/src/components/report/ReportViewfinder.tsx @@ -464,12 +464,28 @@ export function ReportViewfinder({ - !recording && setMode("photo")} hitSlop={8}> + setMode("photo")} + disabled={recording} + accessibilityRole="button" + accessibilityLabel={t("mode.photo")} + accessibilityState={{ selected: mode === "photo", disabled: recording }} + hitSlop={8} + style={({ pressed }) => (pressed && !recording ? styles.pressed : null)} + > {t("mode.photo")} - !recording && setMode("video")} hitSlop={8}> + setMode("video")} + disabled={recording} + accessibilityRole="button" + accessibilityLabel={t("mode.video")} + accessibilityState={{ selected: mode === "video", disabled: recording }} + hitSlop={8} + style={({ pressed }) => (pressed && !recording ? styles.pressed : null)} + > {t("mode.video")} @@ -482,7 +498,8 @@ export function ReportViewfinder({ disabled={busy || recording} accessibilityRole="button" accessibilityLabel={t("gate.choose_library")} - style={styles.sideBtn} + hitSlop={8} + style={({ pressed }) => [styles.sideBtn, pressed && !(busy || recording) ? styles.pressed : null]} > @@ -586,7 +603,7 @@ const cameraStyles = StyleSheet.create({ right: 60, bottom: 60, borderWidth: 1, - borderColor: "rgba(255,255,255,0.18)", + borderColor: stage.colors.lightboxControl, borderStyle: "dashed", borderRadius: 8, }, diff --git a/apps/community-mobile/src/components/scan/TicketScanner.tsx b/apps/community-mobile/src/components/scan/TicketScanner.tsx index 1100f685..7ecaed39 100644 --- a/apps/community-mobile/src/components/scan/TicketScanner.tsx +++ b/apps/community-mobile/src/components/scan/TicketScanner.tsx @@ -175,7 +175,7 @@ const scannerStyles = StyleSheet.create({ right: 48, bottom: "22%", borderWidth: 2, - borderColor: stage.colors.neutral.card, + borderColor: stage.colors.onScrim, borderRadius: radius.lg, }, }) diff --git a/apps/community-mobile/src/components/ui/ScreenHeader.tsx b/apps/community-mobile/src/components/ui/ScreenHeader.tsx index 1059b854..b1239d86 100644 --- a/apps/community-mobile/src/components/ui/ScreenHeader.tsx +++ b/apps/community-mobile/src/components/ui/ScreenHeader.tsx @@ -34,7 +34,7 @@ export function ScreenHeader({ else router.replace("/") } - const glyphColor = overlay ? th.colors.neutral.card : th.colors.text + const glyphColor = overlay ? th.colors.onScrim : th.colors.text return ( Date: Tue, 15 Sep 2026 23:46:24 -0700 Subject: [PATCH 015/105] mobile auth affordances and dangerInk error rows --- apps/community-mobile/app/_layout.tsx | 9 ++++++- apps/community-mobile/app/auth/otp.tsx | 19 ++++++++++--- .../src/components/AuthOptions.tsx | 4 +-- .../src/components/FirstRunGate.tsx | 27 +++++++++++-------- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/apps/community-mobile/app/_layout.tsx b/apps/community-mobile/app/_layout.tsx index 038f6feb..2c03bedf 100644 --- a/apps/community-mobile/app/_layout.tsx +++ b/apps/community-mobile/app/_layout.tsx @@ -626,7 +626,11 @@ export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) { {copy.title} {copy.body} {__DEV__ ? {String(error?.message ?? error)} : null} - + [crash.action, pressed ? crash.actionPressed : null]} + > {copy.action} @@ -684,6 +688,9 @@ function crashStyles(t: Theme) { borderRadius: t.radius.pill, backgroundColor: t.colors.brand.bloom, }, + actionPressed: { + opacity: 0.85, + }, actionLabel: { fontSize: t.fontSize["16"], fontWeight: "600", diff --git a/apps/community-mobile/app/auth/otp.tsx b/apps/community-mobile/app/auth/otp.tsx index 2abf889a..b85d004a 100644 --- a/apps/community-mobile/app/auth/otp.tsx +++ b/apps/community-mobile/app/auth/otp.tsx @@ -169,7 +169,7 @@ export default function OtpScreen() { > - + @@ -226,8 +226,8 @@ export default function OtpScreen() { {error ? ( - - + + {error} @@ -254,7 +254,15 @@ export default function OtpScreen() { {t("resend.prompt")} - 0 || resending} hitSlop={8}> + 0 || resending} + accessibilityRole="button" + accessibilityLabel={t("resend.action")} + accessibilityState={{ disabled: cooldown > 0 || resending }} + hitSlop={8} + style={({ pressed }) => (pressed && !(cooldown > 0 || resending) ? styles.resendPressed : null)} + > 0 ? th.colors.textSubtle : th.colors.brand.bloom} @@ -349,4 +357,7 @@ const useStyles = makeThemedStyles((t) => ({ resendText: { fontFamily: t.fontFamily.bodySemiBold, }, + resendPressed: { + opacity: 0.6, + }, })) diff --git a/apps/community-mobile/src/components/AuthOptions.tsx b/apps/community-mobile/src/components/AuthOptions.tsx index 5635c267..a742a613 100644 --- a/apps/community-mobile/src/components/AuthOptions.tsx +++ b/apps/community-mobile/src/components/AuthOptions.tsx @@ -214,8 +214,8 @@ export function AuthOptions({ {error ? ( - - + + {error} diff --git a/apps/community-mobile/src/components/FirstRunGate.tsx b/apps/community-mobile/src/components/FirstRunGate.tsx index 507e9938..cc52bd09 100644 --- a/apps/community-mobile/src/components/FirstRunGate.tsx +++ b/apps/community-mobile/src/components/FirstRunGate.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react" -import { View, StyleSheet, ActivityIndicator } from "react-native" +import { View, StyleSheet, ActivityIndicator, Pressable } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import { Ionicons } from "@expo/vector-icons" import { isValidHandle } from "@civfix/shared" @@ -161,8 +161,8 @@ function FirstRunForm() { {error ? ( - - + + {error} @@ -176,14 +176,17 @@ function FirstRunForm() { void onSubmit()} /> - void signOut()} + accessibilityRole="button" + accessibilityLabel={t("signout.action")} + hitSlop={8} + style={({ pressed }) => [styles.signOut, pressed ? styles.signOutPressed : null]} > - {t("signout.prompt")} {t("signout.action")} - + + {t("signout.prompt")} {t("signout.action")} + + @@ -216,7 +219,7 @@ function HandleHint({ content = t("handle.checking") } else if (available) { content = t("handle.available", { handle }) - color = th.colors.moss["700"] + color = th.colors.successInk } else if (taken) { content = t("handle.taken", { handle }) color = th.colors.brand.bloom @@ -260,5 +263,7 @@ const useStyles = makeThemedStyles((t) => ({ borderTopColor: t.colors.border, backgroundColor: t.colors.bg, }, - signOut: { textAlign: "center", marginTop: t.space["3"] }, + signOut: { alignSelf: "center", marginTop: t.space["3"] }, + signOutPressed: { opacity: 0.6 }, + signOutText: { textAlign: "center" }, })) From 37b50c27d65d1e14c1111a6d7c1c839ce4fb794c Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:46:34 -0700 Subject: [PATCH 016/105] web dark-mode token fixes: ring collision, hue ramps through scheme vars, ink var typos --- apps/community-web/src/app/e/signup.css | 2 +- .../components/promo/app-download-banner.tsx | 6 +- .../src/styles/design-css-tokens.test.ts | 68 ++++++++++- apps/community-web/src/styles/design.css | 112 +++++++++--------- apps/community-web/tailwind.config.ts | 55 +++++++-- 5 files changed, 178 insertions(+), 65 deletions(-) diff --git a/apps/community-web/src/app/e/signup.css b/apps/community-web/src/app/e/signup.css index e1d9c5d3..3912907a 100644 --- a/apps/community-web/src/app/e/signup.css +++ b/apps/community-web/src/app/e/signup.css @@ -384,7 +384,7 @@ .signup-page textarea:focus-visible, .signup-page a:focus-visible { outline: none; - box-shadow: var(--ring); + box-shadow: var(--focus-ring); } .signup-state { diff --git a/apps/community-web/src/components/promo/app-download-banner.tsx b/apps/community-web/src/components/promo/app-download-banner.tsx index 2d409970..6ca1789f 100644 --- a/apps/community-web/src/components/promo/app-download-banner.tsx +++ b/apps/community-web/src/components/promo/app-download-banner.tsx @@ -73,7 +73,7 @@ export function AppDownloadBanner() { // status bar) but keeps the banner clear of the notch in any edge-to-edge browser chrome. padding: "calc(8px + env(safe-area-inset-top, 0px)) 12px 8px", background: "var(--card, #fff)", - borderBottom: "1px solid var(--ink5, rgba(0,0,0,0.08))", + borderBottom: "1px solid var(--ink-5)", boxShadow: "0 1px 3px rgba(0,0,0,0.06)", }} > @@ -91,7 +91,7 @@ export function AppDownloadBanner() { border: "none", borderRadius: 12, background: "transparent", - color: "var(--ink3, #6b6b6b)", + color: "var(--ink-3)", fontSize: 17, lineHeight: 1, cursor: "pointer", @@ -119,7 +119,7 @@ export function AppDownloadBanner() { fontFamily: "var(--font-body, inherit)", fontSize: 12, lineHeight: 1.3, - color: "var(--ink3, #6b6b6b)", + color: "var(--ink-3)", }} > {t("app_promo.banner_subtitle")} diff --git a/apps/community-web/src/styles/design-css-tokens.test.ts b/apps/community-web/src/styles/design-css-tokens.test.ts index 23d2ea9e..30fbbebf 100644 --- a/apps/community-web/src/styles/design-css-tokens.test.ts +++ b/apps/community-web/src/styles/design-css-tokens.test.ts @@ -48,6 +48,61 @@ describe("design.css neutrals mirror @civfix/shared tokens", () => { }) }) +const HUES = [ + ["bloom", ["50", "100", "300", "500", "600", "700"]], + ["moss", ["50", "100", "300", "500", "600", "700"]], + ["sun", ["50", "100", "300", "500", "600", "700"]], + ["sky", ["50", "100", "300", "500", "600", "700"]], + ["lilac", ["50", "500", "600", "700"]], +] as const + +function tokenValue(source: Record, key: string): string { + const value = source[key] + expect(value, key).toBeDefined() + return (value ?? "").toUpperCase() +} + +describe("design.css hue ramps mirror @civfix/shared tokens", () => { + for (const scheme of ["light", "dark"] as const) { + it(`${scheme} hue ramps match colorSchemes`, () => { + const block = blockFor(scheme) + for (const [hue, steps] of HUES) { + const ramp = colorSchemes[scheme][hue] as Record + for (const step of steps) { + const cssName = step === "500" ? hue : `${hue}-${step}` + expect(readVar(block, cssName), cssName).toBe(tokenValue(ramp, step)) + } + } + }) + } + + it(`category vars match colorSchemes in both schemes`, () => { + for (const scheme of ["light", "dark"] as const) { + const block = blockFor(scheme) + const category = colorSchemes[scheme].category as Record + const resolveVar = (raw: string): string => { + const ref = /^VAR\(--([A-Z0-9-]+)\)$/.exec(raw)?.[1] + if (!ref) return raw + return readVar(block, ref.toLowerCase()) + } + for (const name of [ + "trash", + "recycling", + "graffiti", + "hazard", + "encampment", + "water", + "other", + ]) { + expect(resolveVar(readVar(block, `cat-${name}`)), name).toBe(tokenValue(category, name)) + } + expect(resolveVar(readVar(block, "cat-cleanup"))).toBe( + colorSchemes[scheme].cleanup.toUpperCase(), + ) + } + }) +}) + describe("tailwind neutral colours stay legal without an opacity modifier", () => { const colors = (config.theme?.extend?.colors ?? {}) as Record @@ -70,7 +125,18 @@ describe("tailwind neutral colours stay legal without an opacity modifier", () = [["ink", "5"], "--ink-5"], ] as const - for (const [path, variable] of NEUTRAL_UTILITIES) { + const HUE_UTILITIES = [ + [["bloom", "500"], "--bloom"], + [["bloom", "50"], "--bloom-50"], + [["moss", "100"], "--moss-100"], + [["sun", "700"], "--sun-700"], + [["sky", "300"], "--sky-300"], + [["lilac", "600"], "--lilac-600"], + [["cat", "hazard"], "--cat-hazard"], + [["cleanup"], "--cat-cleanup"], + ] as const + + for (const [path, variable] of [...NEUTRAL_UTILITIES, ...HUE_UTILITIES]) { it(`${path.join(".")} resolves to a bare var(${variable})`, () => { expect(resolve(path)({})).toBe(`var(${variable})`) }) diff --git a/apps/community-web/src/styles/design.css b/apps/community-web/src/styles/design.css index 85ddc504..71caedbe 100644 --- a/apps/community-web/src/styles/design.css +++ b/apps/community-web/src/styles/design.css @@ -17,39 +17,40 @@ /* ==================================================================== COLOR - Brand (verbatim from colors_and_type.css) ==================================================================== */ - --bloom-50: #FFEDE9; - --bloom-100: #FFD5CD; - --bloom-300: #FFA89A; - --bloom: #FF7A6B; - --bloom-600: #E55F50; - --bloom-700: #B8463A; - - --moss-50: #E8F3E8; - --moss-100: #CDE5CD; - --moss-300: #9BCF9B; - --moss: #6FB36F; - --moss-600: #4F9551; - --moss-700: #347338; - - --sun-50: #FFF6DA; - --sun-100: #FFE9A4; - --sun-300: #FFD668; - --sun: #FFCB47; - --sun-600: #E5AE1C; - --sun-700: #A77B0A; - - --sky-50: #E6F2FB; - --sky-100: #C9E3F4; - --sky-300: #A0CDE9; - --sky: #6FB1DC; - --sky-600: #3F8DC0; - --sky-700: #2A6996; - - --lilac-50: #F0EBFB; + --bloom-50: #FDEAE7; + --bloom-100: #FAD0CB; + --bloom-300: #F5A199; + --bloom: #F0685C; + --bloom-600: #E4574A; + --bloom-700: #C74537; + + --moss-50: #EAF3E8; + --moss-100: #CFE5CB; + --moss-300: #98C790; + --moss: #63A45A; + --moss-600: #4C8A47; + --moss-700: #2F7D46; + + --sun-50: #F8EED3; + --sun-100: #EDDCAC; + --sun-300: #E7C155; + --sun: #D9A21B; + --sun-600: #B98A14; + --sun-700: #81610E; + + --sky-50: #E9F1FA; + --sky-100: #CFE0F1; + --sky-300: #A5C6E6; + --sky: #74A9D8; + --sky-600: #4E86BE; + --sky-700: #356291; + + --lilac-50: #F0EAFA; --lilac-100: #DED2F5; --lilac-300: #BFA9EC; - --lilac: #9C82DE; - --lilac-600: #7657C2; + --lilac: #9B7ED9; + --lilac-600: #7A5CC0; + --lilac-700: #7457B6; /* ==================================================================== COLOR - Neutrals (warm) @@ -102,12 +103,14 @@ --danger: var(--bloom-600); /* Map / category colors - used for pin types */ - --cat-trash: #8A8378; + --cat-trash: #776C60; --cat-recycling: var(--moss); --cat-graffiti: var(--lilac); - --cat-hazard: var(--bloom); - --cat-cleanup: var(--sun-600); + --cat-hazard: var(--bloom-600); + --cat-encampment: #3E9E8E; + --cat-cleanup: var(--sun); --cat-water: var(--sky); + --cat-other: #8D8577; /* ==================================================================== TYPOGRAPHY - scale + metrics (families come from next/font) @@ -178,10 +181,10 @@ --sheen-top: inset 0 1px 0 rgba(255,255,255,0.6); - --shadow-pin: 0 6px 10px -2px rgba(255,122,107,0.45), + --shadow-pin: 0 6px 10px -2px rgba(240,104,92,0.45), 0 2px 4px rgba(26,23,20,0.20); - --ring: 0 0 0 3px rgba(255,122,107,0.30); + --focus-ring: 0 0 0 3px rgba(240,104,92,0.30); /* ==================================================================== MOTION @@ -274,7 +277,7 @@ .cf-skip-link:focus { top: 8px; outline: none; - box-shadow: var(--ring), var(--shadow-2); + box-shadow: var(--focus-ring), var(--shadow-2); } /* ---- Map ---------------------------------------------------------------- */ @@ -1095,7 +1098,7 @@ .btn.primary { background: var(--bloom); color: var(--fg-on-color); - box-shadow: 0 4px 10px -2px rgba(255,122,107,0.4), var(--sheen-top); + box-shadow: 0 4px 10px -2px rgba(240,104,92,0.4), var(--sheen-top); } .btn.primary:hover { background: var(--bloom-600); transform: translateY(-1px); } .btn.primary:active { transform: scale(0.97); } @@ -1148,7 +1151,7 @@ textarea.input { transition: border-color 120ms, box-shadow 120ms; resize: none; } -.input:focus { border-color: var(--bloom); box-shadow: var(--ring); } +.input:focus { border-color: var(--bloom); box-shadow: var(--focus-ring); } .input::placeholder { color: var(--ink-3); } .input-label { display: block; @@ -1327,7 +1330,7 @@ textarea.input { } .cf-loc-search-input:focus { border-color: var(--bloom); - box-shadow: var(--ring); + box-shadow: var(--focus-ring); } .cf-loc-search-input::placeholder { color: var(--ink-3); @@ -1619,7 +1622,7 @@ textarea.input { box-shadow: var(--shadow-1); } .event-tab.on .n { color: var(--bloom); } -.event-tab:focus-visible { outline: none; box-shadow: var(--ring); } +.event-tab:focus-visible { outline: none; box-shadow: var(--focus-ring); } .event-list { display: flex; flex-direction: column; gap: 8px; } .event-row { @@ -1638,7 +1641,7 @@ textarea.input { transition: transform 120ms var(--ease-out), box-shadow 120ms; } .event-row:hover { transform: translateY(-1px); box-shadow: var(--shadow-2); } -.event-row:focus-visible { outline: none; box-shadow: var(--ring); } +.event-row:focus-visible { outline: none; box-shadow: var(--focus-ring); } .event-row.past { cursor: default; opacity: 0.92; } .event-row.past:hover { transform: none; box-shadow: var(--shadow-1); } .event-row-main { flex: 1; min-width: 0; } @@ -1887,7 +1890,7 @@ textarea.input { color: var(--ink-3); transition: border-color 120ms, box-shadow 120ms; } -.people-search:focus-within { border-color: var(--bloom); box-shadow: var(--ring); } +.people-search:focus-within { border-color: var(--bloom); box-shadow: var(--focus-ring); } .people-search svg { flex-shrink: 0; } .people-search input { flex: 1; @@ -2000,7 +2003,7 @@ textarea.input { transition: background 120ms, border-color 120ms; } .event-mini:hover { background: var(--sun-50); border-color: var(--sun-300); } -.event-mini:focus-visible { outline: none; box-shadow: var(--ring); } +.event-mini:focus-visible { outline: none; box-shadow: var(--focus-ring); } .event-mini-badge { flex-shrink: 0; width: 38px; height: 40px; @@ -2112,7 +2115,7 @@ textarea.input { transition: background 120ms; } .cf-nav-row:hover { background: var(--paper-2); } -.cf-nav-row:focus-visible { outline: none; box-shadow: var(--ring); } +.cf-nav-row:focus-visible { outline: none; box-shadow: var(--focus-ring); } .cf-nav-icon { width: 32px; height: 32px; border-radius: 10px; @@ -2172,7 +2175,7 @@ textarea.input { transition: background 120ms; } .cf-activity-row:hover { background: var(--paper-2); } -.cf-activity-row:focus-visible { outline: none; box-shadow: var(--ring); } +.cf-activity-row:focus-visible { outline: none; box-shadow: var(--focus-ring); } .cf-activity-row.unread { background: var(--bloom-50); } .cf-activity-row.unread:hover { background: var(--bloom-50); } .cf-activity-icon { @@ -2259,7 +2262,7 @@ textarea.input { border-color: var(--ink); color: var(--fg-on-inverse); } -.cf-inbox-filter button:focus-visible { outline: none; box-shadow: var(--ring); } +.cf-inbox-filter button:focus-visible { outline: none; box-shadow: var(--focus-ring); } .cf-inbox-list { display: flex; flex-direction: column; } .cf-inbox-empty { @@ -2287,7 +2290,7 @@ textarea.input { transition: background 120ms; } .thread-row:hover { background: var(--paper-2); } -.thread-row:focus-visible { outline: none; box-shadow: var(--ring); } +.thread-row:focus-visible { outline: none; box-shadow: var(--focus-ring); } .thread-row .cf-thread-main { flex: 1; min-width: 0; } .thread-row .who { font-weight: 700; @@ -2554,7 +2557,7 @@ textarea.input { .msg-composer textarea:focus, .msg-composer input:focus { border-color: var(--bloom); - box-shadow: var(--ring); + box-shadow: var(--focus-ring); } .msg-composer textarea:disabled, .msg-composer input:disabled { opacity: 0.5; cursor: not-allowed; } @@ -2663,7 +2666,7 @@ textarea.input { } .cf-newmsg-row:hover { background: var(--paper-2); } .cf-newmsg-row.on, .cf-newmsg-row.on:hover { background: var(--bloom-50); } -.cf-newmsg-row:focus-visible { outline: none; box-shadow: var(--ring); } +.cf-newmsg-row:focus-visible { outline: none; box-shadow: var(--focus-ring); } .cf-newmsg-meta { flex: 1; min-width: 0; } .cf-newmsg-name { font-size: 14px; font-weight: 700; color: var(--ink); } .cf-newmsg-handle { font-size: 11px; color: var(--ink-3); } @@ -2815,7 +2818,7 @@ textarea.input { } .otp-cell:focus { border-color: var(--bloom); - box-shadow: var(--ring); + box-shadow: var(--focus-ring); } .cf-auth-resend { text-align: center; @@ -2963,7 +2966,7 @@ textarea.input { --sun-300: #B08A28; --sun: #E3B138; --sun-600: #F0C455; - --sun-700: #F5D27A; + --sun-700: #F5D480; --sky-50: #1B2733; --sky-100: #243646; @@ -2977,6 +2980,7 @@ textarea.input { --lilac-300: #7B63B0; --lilac: #AE95E2; --lilac-600: #C3B0EB; + --lilac-700: #D5C7F1; --paper: #17130E; --paper-2: #211C15; @@ -2997,8 +3001,10 @@ textarea.input { --cat-recycling: #78B56E; --cat-graffiti: #B79FE6; --cat-hazard: #F4796C; + --cat-encampment: #5EBBAA; --cat-cleanup: #E3B138; --cat-water: #8BB9E2; + --cat-other: #A39B8D; --glass: rgba(42,36,28,0.96); --glass-hover: #352E24; @@ -3031,7 +3037,7 @@ textarea.input { --shadow-pin: 0 6px 10px -2px rgba(244,121,108,0.45), 0 2px 4px rgba(0,0,0,0.50); - --ring: 0 0 0 3px rgba(244,121,108,0.40); + --focus-ring: 0 0 0 3px rgba(244,121,108,0.40); } /* The one fill that is a vendor mark rather than a token: Apple's sign-in pill is black-on-light and diff --git a/apps/community-web/tailwind.config.ts b/apps/community-web/tailwind.config.ts index 55d99f42..a352f98b 100644 --- a/apps/community-web/tailwind.config.ts +++ b/apps/community-web/tailwind.config.ts @@ -68,11 +68,44 @@ const config: Config = { foreground: "hsl(var(--card-foreground))", }, - bloom: color.bloom, - moss: color.moss, - sun: color.sun, - sky: color.sky, - lilac: color.lilac, + bloom: { + 50: schemeVar("--bloom-50"), + 100: schemeVar("--bloom-100"), + 300: schemeVar("--bloom-300"), + 500: schemeVar("--bloom"), + 600: schemeVar("--bloom-600"), + 700: schemeVar("--bloom-700"), + }, + moss: { + 50: schemeVar("--moss-50"), + 100: schemeVar("--moss-100"), + 300: schemeVar("--moss-300"), + 500: schemeVar("--moss"), + 600: schemeVar("--moss-600"), + 700: schemeVar("--moss-700"), + }, + sun: { + 50: schemeVar("--sun-50"), + 100: schemeVar("--sun-100"), + 300: schemeVar("--sun-300"), + 500: schemeVar("--sun"), + 600: schemeVar("--sun-600"), + 700: schemeVar("--sun-700"), + }, + sky: { + 50: schemeVar("--sky-50"), + 100: schemeVar("--sky-100"), + 300: schemeVar("--sky-300"), + 500: schemeVar("--sky"), + 600: schemeVar("--sky-600"), + 700: schemeVar("--sky-700"), + }, + lilac: { + 50: schemeVar("--lilac-50"), + 500: schemeVar("--lilac"), + 600: schemeVar("--lilac-600"), + 700: schemeVar("--lilac-700"), + }, brand: color.brand, @@ -88,9 +121,17 @@ const config: Config = { 5: schemeVar("--ink-5"), }, - cat: color.category, + cat: { + trash: schemeVar("--cat-trash"), + recycling: schemeVar("--cat-recycling"), + graffiti: schemeVar("--cat-graffiti"), + hazard: schemeVar("--cat-hazard"), + encampment: schemeVar("--cat-encampment"), + water: schemeVar("--cat-water"), + other: schemeVar("--cat-other"), + }, - cleanup: color.cleanup, + cleanup: schemeVar("--cat-cleanup"), console: { surface: "var(--console-surface)", From b897fd34a631802ca635d661bf9be35cdc503f7e Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:46:34 -0700 Subject: [PATCH 017/105] guest cancel view localized in all four locales --- .../src/features/guest/guest-cancel-view.tsx | 53 +++++++++---------- .../src/i18n/locales/de/web-guest-cancel.json | 28 ++++++++++ .../src/i18n/locales/en/web-guest-cancel.json | 28 ++++++++++ .../src/i18n/locales/es/web-guest-cancel.json | 28 ++++++++++ .../src/i18n/locales/ko/web-guest-cancel.json | 28 ++++++++++ packages/ui/src/i18n/resources.ts | 9 ++++ 6 files changed, 145 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/i18n/locales/de/web-guest-cancel.json create mode 100644 packages/ui/src/i18n/locales/en/web-guest-cancel.json create mode 100644 packages/ui/src/i18n/locales/es/web-guest-cancel.json create mode 100644 packages/ui/src/i18n/locales/ko/web-guest-cancel.json diff --git a/apps/community-web/src/features/guest/guest-cancel-view.tsx b/apps/community-web/src/features/guest/guest-cancel-view.tsx index b67ebe43..040e1411 100644 --- a/apps/community-web/src/features/guest/guest-cancel-view.tsx +++ b/apps/community-web/src/features/guest/guest-cancel-view.tsx @@ -4,6 +4,8 @@ import * as React from "react" import { useRouter, useSearchParams } from "next/navigation" import { CalendarX2, CheckCircle2, Loader2, SearchX } from "lucide-react" +import { useT } from "@civfix/ui/i18n" + import { DetailShell } from "@/components/detail-shell" import { EmptyState } from "@/components/ui/empty-state" import { Button } from "@/components/ui/button" @@ -14,6 +16,7 @@ import { readGuestManageToken } from "@/features/guest/guest-cancel-token" type Phase = "confirm" | "cancelling" | "done" | "error" export function GuestCancelView() { + const { t } = useT("web-guest-cancel") const router = useRouter() const searchParams = useSearchParams() const token = readGuestManageToken(searchParams.get("token")) @@ -36,21 +39,21 @@ export function GuestCancelView() { scrubTokenFromUrl() } catch (err) { if (requestSeq.current !== seq) return - setError(cancelErrorMessage(err)) + setError(cancelErrorMessage(err, t)) setPhase("error") } - }, [token]) + }, [token, t]) if (!token && phase === "confirm") { return (