Skip to main content
These conventions are enforced by CodeRabbit on every PR and by Claude Code locally via the /verify skill. They are the guardrails that keep the codebase consistent across the backend, iOS app, widgets, and infrastructure.
The single most important architectural rule in the project.
iOS is a dumb view layer. ALL data processing, formatting, and business logic MUST live on the backend. The backend sends pre-formatted, ready-to-render values; iOS simply binds them to SwiftUI views.

Violations (flag these in any iOS code)

  • String formatting or number formatting of data (e.g., building "87/6" from separate runs/wickets values, computing run rates, formatting overs)
  • Conditional logic based on match state (e.g., checking innings number to decide what text to show, computing progress from overs)
  • Hardcoded display strings that should come from the backend (e.g., match titles, toss descriptions, status messages)
  • Data transformations or computations on backend-provided values
  • Parsing backend strings to extract sub-values (e.g., splitting overs "14.2" to compute ball number)

Allowed client-side logic (pure view concerns)

  • Color/theming decisions (mapping a sentiment string to a Color)
  • Simple array filtering on a boolean flag (e.g., isOnStrike)
  • Emoji flag maps (Live Activities cannot load network images)
  • SwiftUI layout, animation, and styling code
  • Local UI state (sheet presentation, scroll position, toggle state)

Source of truth

  • Zod schemas are the source of truth for types. Update schemas first, then code.
  • Use z.infer<typeof Schema> for TypeScript types — never duplicate type definitions.
  • buildDisplayState.ts is the orchestrator that computes all display values.
  • APNs content state must mirror iOS LiveActivityModels.swift exactly.

Code comments

Do not add comments that merely restate what the code already says. A comment should explain WHY, not WHAT. If the code is clear enough to read without the comment, the comment is noise.
Bad examples: // Get all devices for this user, // Return the result, // Check if null

DynamoDB anti-patterns

Never use ScanCommand in store files. Full table scans are a cost and latency smell that masks a missing GSI or wrong key schema.
Every scan should either:
  1. Be replaced with a QueryCommand on the existing key schema, OR
  2. Justify itself with a GSI TODO comment explaining the planned index and why it hasn’t been added yet
Existing scans with TODO comments are tracked tech debt — do not add new ones without justification.

Copy text convention

All user-facing text (notification titles, event descriptions, display labels, placeholders) MUST live in *.messages.ts files — not inline in logic files.
Does NOT apply to: log messages, developer errors, schema keys, test fixtures.

Apple/SwiftUI best practices

Never force unwrap (!) — use guard let, if let, or nil coalescing.
  • Prefer .foregroundStyle() over deprecated .foregroundColor()
  • Prefer .task {} over .onAppear { Task {} } for async work
  • Use @ViewBuilder for functions returning conditional views
  • Prefer let over var — mutability only when required
  • Flag print() statements — use os.Logger or remove before merge

Access control

  • Use private for properties/methods not needed outside the type
  • Exception: a top-level type that is the sole primary declaration in its own file should be internal (default), never privateprivate at file scope makes it invisible to the rest of the target

View rules

  • Views should be small and focused — extract sub-views for complex layouts
  • Avoid heavy computation in body — use computed properties or cache
  • No I/O operations in views (should be in Services)
  • Use Color("colorName") for asset catalog colors or Theme constants

Model rules

  • Models are Codable structs that mirror backend JSON exactly
  • Conform to Codable, Hashable, and Sendable
  • Use let for all properties — models are immutable data containers
  • No business logic or computed display values in models
  • Both Sightscreen and SightscreenWidget targets share identical model files — flag any divergence

Theme rules

  • Use enum with static let for namespaced constants
  • Flag hardcoded color/spacing values in other files that should use Theme constants

Paging pattern

Use ScrollView(.horizontal) + .scrollTargetBehavior(.paging) for paged views. Never use TabView(.page) — it has a known Apple bug with misaligned pages when content sizes differ. See ADR-010.
Every service MUST follow the Protocol + @Observable + @Environment pattern. The following are architectural violations:
  • A service defined as a base class for subclassing (use a protocol instead)
  • ObservableObject conformance or @Published properties (use @Observable)
  • @EnvironmentObject usage anywhere (use @Environment(\.serviceKey))
  • @StateObject for services (use @State in App root, @Environment in views)
  • Views accepting a service via init parameter instead of reading from environment

Required shape for every service

  1. Protocol: @MainActor protocol FooService: AnyObject, Observable — the interface
  2. Real implementation: @Observable @MainActor final class RealFooService: FooService — production impl
  3. Fake implementation: @Observable @MainActor final class FakeFooService: FooService — preview/test stub
  4. EnvironmentKey: defaultValue set to FakeFooService() so previews work with zero boilerplate
  5. EnvironmentValues extension: exposing var fooService: any FooService

File organization

  • Protocol file contains: protocol + EnvironmentKey + EnvironmentValues extension
  • Real impl lives in its own file (e.g., CognitoAuthService.swift)
  • Fake impl lives under Services/Fake/ (e.g., Fake/FakeAuthService.swift)

Consumption

  • Views consume via @Environment(\.fooService) var fooService
  • App root injects real impl via .environment(\.fooService, realFooService)
  • Previews get the fake default automatically — no explicit environment wiring needed
  • When a service holds a reference to another service, type it as any FooService (existential), never a concrete class

Additional rules

  • Services handle networking, persistence, and system integrations
  • Services must NOT contain display formatting logic — that belongs on the backend
  • Use async functions, never blocking calls
  • Handle errors gracefully and propagate via throws
  • No SwiftUI imports in services (exception: EnvironmentKey/EnvironmentValues declarations and ScenePhase references)

ActivityAttributes vs ContentState

ActivityAttributes holds STATIC data (set once at request time, never changes). ContentState holds DYNAMIC data (updated via push). Putting mutable match data in ActivityAttributes instead of ContentState is a structural bug — it can never be updated after creation.

Push token lifecycle

Pass pushType: .token to Activity.request(). Omitting this locks the activity to local-only updates for its entire lifetime — this is irreversible.
  • Never read .pushToken synchronously after creation — it will be nil. Must observe activity.pushTokenUpdates (AsyncSequence) with for await to get the initial token and handle system-initiated token rotations.
  • When the system rotates a push token, send the new token to the backend and invalidate the old one immediately.

All 6 presentations are mandatory

Every ActivityConfiguration MUST implement:
  1. compactLeading — must deep-link to the same destination as compact trailing
  2. compactTrailing — must deep-link to the same destination as compact leading
  3. minimal — must show live data (score, overs), never just a static logo
  4. expanded — full Dynamic Island expanded view
  5. Lock Screen closure
  6. Watch/CarPlay via .supplementalActivityFamilies([.small])
Without .supplementalActivityFamilies([.small]), Watch and CarPlay get an auto-generated low-quality fallback from compact leading + trailing.

Dynamic Island layout

  • Background is ALWAYS black and not customizable — use bold foreground colors for brand identity
  • Use ContainerRelativeShape for inner rounded rectangles (concentric corner radii)
  • Do not add padding between content and the TrueDepth camera in compact views
  • Use .numericText content transition for counting numbers (scores, overs)
  • Animation max duration is 2 seconds (system-enforced)

Lock Screen layout

  • Use 14pt standard margins; no hardcoded fixed frames
  • Use activityBackgroundTint(_:) for custom background colors
  • Use activitySystemActionForegroundColor(_:) for the dismiss button — verify in both light and dark mode
  • Check isLuminanceReduced for Always-On displays — no animations play on Always-On displays

StandBy mode

  • StandBy scales the Lock Screen presentation 2x — all image assets must be high-resolution enough
  • The system extends the background color to fill the entire screen in StandBy
  • Graphic elements drawn to edges will be clipped — use dividers or containing shapes at boundaries
  • Verify contrast under StandBy Night Mode (automatic red tint in low light)

Watch and CarPlay

  • Use @Environment(\.activityFamily) and switch on .small to provide the Watch/CarPlay layout
CarPlay DEACTIVATES all interactive elements (Button/Toggle). Do NOT include interactive controls in .small activity family layouts.
  • Apple Watch: use at most ONE interactive control — multiple controls are unusable at Smart Stack sizes
  • Tapping a Watch Live Activity navigates straight to the Watch app — no expanded view exists

Lifecycle management

  • Live Activities can ONLY be requested when the app is in the foreground, after a discrete user action
  • Must handle all four activity states: .active, .stale, .ended, .dismissed via activityStateUpdates
  • When .dismissed, stop tracking and clean up — failing to observe dismissal leads to zombie state
  • Provide a meaningful final ContentState when calling end() — show the final match result
  • Set a custom dismissalPolicy when ending — 15-30 minutes after completion for cricket
  • Check ActivityAuthorizationInfo.frequentPushesEnabled once after activity start and send to the backend
  • System enforces a max 8-hour duration — implement restart logic for longer cricket matches
  • NSSupportsLiveActivitiesFrequentUpdates must be YES in Info.plist
  • Alert title and body display ONLY on a paired Apple Watch, not on iPhone

Multi-activity behavior

  • When multiple Live Activities are active, the system shows the minimal presentation
  • Use relevanceScore to influence which activity the system prioritizes

Payload rules

content-state payload must be under 4KB. Exceeding this causes silent update failure on the device.
Backend MUST use camelCase keys matching the Swift ContentState property names exactly. iOS decodes with the default JSONDecoder — snake_case or any custom encoding causes silent decode failure with no error surfaced to the user.

Required headers and fields

  • apns-push-type must be liveactivity
  • apns-topic must be <bundle-id>.push-type.liveactivity (not the bare bundle ID)
  • Must use token-based APNs connections — certificate-based connections do not support the liveactivity push type
  • Every update payload MUST include timestamp (Unix epoch seconds), event ("update" or "end"), and content-state
  • If timestamp is older than the last processed update, the system silently discards it

Priority

  • Default to apns-priority: 5 (low — delivered opportunistically, no rate limit)
  • Use 10 (high — immediate delivery) only for genuinely time-sensitive moments like wickets or score changes
  • Over-using priority 10 exhausts the device budget and triggers throttling

No double notifications

Do NOT send a regular push notification alongside a Live Activity alert update for the same event. The Live Activity alert already lights the screen and plays a sound — doubling up causes users to disable Live Activities entirely.

Ending via push

  • Set event to "end" and include dismissal-date (Unix epoch) to control how long the ended activity stays on the Lock Screen
  • Omitting dismissal-date lets the system decide (up to 4 hours) — for cricket, 15-30 minutes is appropriate

Optional fields

  • stale-date (Unix epoch): if set, iOS marks the activity as stale after this time without a newer update. Only use if the widget checks context.isStale.
  • relevance-score (number): controls ordering when multiple activities are active. Set higher values for the match the user is most likely watching.
  • Push tokens are per-activity and rotate. When the iOS app sends a new token, invalidate the old one immediately in the backend token store.

File organization (one view per file)

Each SwiftUI view struct MUST live in its own file. Flag any file containing multiple top-level struct ... : View definitions — extract them.
  • SightscreenWidgetLiveActivity.swift is the widget entry point ONLY — contains Widget conformance and ActivityConfiguration builder
  • All view structs it references live in separate files under LiveActivity/

Directory layout

SightscreenWidget/
  LiveActivity/
    LockScreen/          — Lock Screen view and sub-types
    DynamicIsland/       — one file per DI region view
    Shared/              — reusable helpers, shared view components
  Preview Content/       — sample data extensions for #Preview macros

Styling

  • Use WidgetColors constants (e.g., Color.ssAmber, Color.ssFooterGradient1) instead of inline hex colors
  • Widget views receive ALL display data via LiveActivityDisplayState from APNs push updates — never compute or derive display values
Live Activities cannot load network images — emoji flag maps are acceptable.

Access control for extracted views

  • Top-level types in their own file MUST use internal access (Swift default — no keyword). Do NOT mark them private.
  • Properties and methods that are implementation details within a view should still be private.

Preview conventions

  • Each view file SHOULD include its own #Preview macros at the bottom
  • Preview sample data lives in Preview Content/LiveActivityPreviewData.swift, NOT inline in view files
  • Sample data properties use internal access so all view files can reference them
  • Schemas define the contract between backend and iOS
  • Any schema change requires a matching update in iOS models
  • Use .nullable() for optional API fields, .optional() for include-dependent fields
  • Add inline comments for non-obvious fields (units, ranges, format examples)

Version safety net

If MatchItemSchema, LeagueGroupSchema, or MatchesResponseSchema fields change (added, removed, renamed, type changed), verify MATCH_SCHEMA_VERSION bump in config.ts.
If isLiveStatus, isCompletedStatus, formatScoreDisplay, formatOversDisplay, or getLiveNote return values change in matchDisplay.ts, also verify MATCH_SCHEMA_VERSION bump if the response shape is affected.
The Sightscreen voice: informed cricket fan, not commentator.

Tone

  • Write like a fan watching with mates, not a broadcaster performing
  • Understated and dry over loud and hype — “in a hurry” not “ON FIRE”
  • Let rare moments (hat-trick, century) earn their excitement; routine events stay calm
  • No adjective inflation: avoid “magnificent”, “brilliant”, “incredible”, “explosive”
  • If the moment is big, the data says it — the copy doesn’t need to

Punctuation

  • Reserve ! for genuinely rare events (hat-tricks, centuries, consecutive sixes)
  • Flag any line with two or more !
  • Use em dash --- consistently as the separator between description and stats
  • No code-style formatting in user-facing strings (`, ~, etc.)

Brevity and clarity

  • Copy should be scannable at lock-screen glance speed
  • Avoid redundancy between title and description — don’t repeat what’s already obvious
  • No commentary-box cliches: “brings up”, “reaches a milestone”, “dispatches to the boundary”
  • Prefer real cricket vernacular over generic sports hype (“spell” not “on fire”, “departs” not “is out”)

Safety defaults

Scripts MUST be dry-run by default. Real changes require an explicit --apply flag. Accidental runs without --apply must be safe (no side effects).
Every script MUST start with set -euo pipefail.

Required behavior

  • Must support --sandbox flag to target sandbox tables (prefix cricket-sandbox-) vs production (prefix cricket-)
  • Must check AWS credentials before any operations (aws sts get-caller-identity)
  • Must be idempotent — if a table already exists, skip creation gracefully
  • Use aws dynamodb wait table-exists after creating tables
  • Use PAY_PER_REQUEST billing mode (no capacity planning needed)
  • Print summary at the end with table names and required .env additions
  • File naming: migrate-NNN-description.sh (sequential numbering)

GSI documentation

When adding a GSI, document why it’s needed in a comment. When using ScanCommand in the corresponding code, document why Scan is acceptable (e.g., small table size, infrequent access, cached result).
If a PR introduces a significant architectural change (new infrastructure pattern, protocol change, dependency strategy, data model redesign, etc.), it should be documented as an Architecture Decision Record in docs/architecture/adr/.If the change is urgent and an ADR is not feasible, at minimum create a GitHub issue documenting the decision and link it in the PR.See the full ADR catalog for all recorded decisions.