Commits
Deploys run by hand for now. Workflow triggers set to workflow_dispatch-only
(blueprint preserved); added an OTA-vs-build-vs-submit decision guide and
manual runbook to environments.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The client sync bootstrap called api.revenuecat.refresh unconditionally,
which in free-sync mode throws (no RevenueCat key) and aborts the pull/push
attach — and on success would reset an entitlement-less user to 'free' and
stop sync. Skip the refresh when EXPO_PUBLIC_FREE_SYNC=1; FreeSyncBridge
holds the client state at 'subscribed' and the server grants access via
SANDBOX_GRANT_ALL.
Also document that the sandbox Convex deployment needs CLERK_JWT_ISSUER_DOMAIN
(and deliberately no RevenueCat key).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sandbox iOS submit profile now targets the real Glide Beta ASC record
(Apple ID 6794097848), replacing the placeholder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EXPO_PUBLIC_FREE_SYNC=1 (preview env only; production builds refuse it)
turns the paywall into a plain sign-up/sign-in flow and maps signed-in
to subscribed via FreeSyncBridge. Server-side, SANDBOX_GRANT_ALL=1 on
the glide-sandbox deployment grants any authenticated identity
read-write sync before the RevenueCat cache is consulted — that env var
is the real gate and must never be set on production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two apps, two publishers: production (Glide, com.h14h.glide, future LLC
accounts) and sandbox (Glide Beta, com.henrynelsonfirth.glide, personal
accounts), driven by GLIDE_APP_VARIANT in app.config.js and per-profile
EAS environments. EAS Workflows: PR lint/typecheck, fingerprint-gated
sandbox deploy on every merge to main, and tag-driven production
releases. docs/release/environments.md is the anchor doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes to the drag-to-reorder mechanics:
Row frame measurements now merge on the UI thread via SharedValue.modify.
The previous JS-side read-modify-write of frames.value raced itself when
every row reported onLayout in one mount batch (JS writes apply to the UI
thread asynchronously, so each read saw a stale array and the writes
clobbered each other) — leaving the frames array sparse and the drag
unable to compute a target slot, i.e. reordering silently did nothing
after a cold launch.
The drop handoff is now commit-atomic: rows gate their drag transforms on
a session number that bumps in the same render that reorders the list, so
the re-rendered rows are born with identity transforms in the exact frame
the layout moves under them. Previously the shared values were zeroed
from JS before React's reorder commit landed, which flashed every row
back at its pre-drag position for a frame.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous implementation swapped the React array mid-drag: each swap
re-rendered the list under the active pan, whose worklet kept calling a
closure over the stale order — so the drag dead-ended after one slot —
and compensated the dragged row's offset with an approximate row height
that ignored the list gap, so the row visibly jumped at every swap.
Now nothing re-renders while a drag is live. The dragged row tracks the
finger 1:1 through a shared value, clamped to the list's bounds; siblings
dodge via animated styles driven by shared drag state using measured row
frames; and the reorder commits exactly once on release, after the row
has settled pixel-perfectly into its destination slot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RevenueCatIdentityLink called Purchases.logOut() unconditionally
whenever Clerk reported no signed-in user, but that's the default
state for nearly every user in this local-first app — RevenueCat is
already anonymous, so the call was invalid and logged a warning on
every load. Guard it behind Purchases.isAnonymous().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opening Plan now demotes stale Ready tasks back for a fresh review pass
(with a recency-based sort so recently-touched tasks sink to the back
of the deck), Next Up gains manual drag-to-reorder, and the Now card
gains a Defer action that pushes the task to the back of the queue with
a lightweight friction counter. Adds order/statusChangedAt/deferCount
fields end-to-end (schema, sync engine, local store) so the new state
syncs identically for local-only and Convex-backed accounts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The confirm step previously had only a Close button, so a signed-in user
who reached it couldn't return to change their plan. Give it a back arrow
(→ plan step) matching the auth step, and intercept the Android hardware
back so it steps within the flow (auth / confirm → plan) instead of
dismissing the whole paywall.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Continue (plan step) and Subscribe (confirm step) now share a bottom-pinned
action footer, so they land at the same position and size — consistent
touch target, no dead space under Subscribe. Value content fills the area
above: the plan step scrolls; the confirm step gains a "what you get"
recap so it doesn't read as bare. Tighten the plan hero/benefit spacing so
both plan cards sit fully above the button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructure the plan step to the value-led order that converts: hero →
benefit highlights → price → CTA → trust, with subtle staggered entrance
motion. Keeps Glide's hard-shadow / rounded language and avoids dark
patterns (no fake urgency or countdowns).
- Hero headline + three benefit rows (instant sync, auto backup, continuity)
with accent icon tiles.
- Annual anchored against monthly: shows the per-month equivalent
($1.67/mo) and "2 months free" so the yearly plan reads as the better
value; annual stays the highlighted default.
- "Cancel anytime." trust line under the CTA on both the plan and payment
steps.
- Plan step scrolls; extract a shared IconButton; add bolt/icloud.fill/
sync symbols.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getSyncPackage now resolves a plan's package by standard Annual/Monthly
type, then by package type, then by a year/month hint in the product id,
and surfaces a diagnostic string when nothing matches — so an offering
whose packages use custom identifiers (e.g. glide_sync_yearly) still
resolves instead of failing with a generic error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Local-first stays free; a subscription gates Convex multi-device sync.
- Paywall flow (plan -> real Clerk auth -> payment) in paywall-screen.tsx
+ route src/app/paywall.tsx, registered in _layout.
- Header Sync entry / account button driven by real Clerk auth
(sync-status.tsx); export AccountButton for reuse.
- Grace / billing-retry notices on the Action screen and a local-only
caption on New Task, plus an empty-state placement fix.
- react-native-purchases wired via src/data/sync/revenuecat.tsx
(<RevenueCatBridge/>): env-driven key, Purchases.logIn(clerkUserId),
`sync` entitlement -> subscription state; Subscribe -> purchasePackage,
with a dev stub fallback (entitlement.ts).
- Docs: docs/engineering/subscriptions.md (+ README index, local-first link).
react-native-purchases is native, so this needs a fresh dev build; payment
and entitlement stay test-only until a RevenueCat Test Store key is set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The NOW card had both a layout (LinearTransition) animation and a transform
(the enter settle) on the same Animated.View, so Reanimated warned the
transform could be overwritten. Split them: an outer wrapper carries the
layout/enter/exit animations, an inner view carries the transform. styles.hero
has no width/margin, so the extra wrapper doesn't change layout.
Pre-existing; unrelated to the quick-action work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
QA on the EAS build surfaced three issues with the new app-icon quick
actions:
- Duplication: iOS listed each action twice because they were registered
both statically (plugin iosActions -> Info.plist) and via setItems at
runtime. Gate setItems to Android, which has no static path; iOS uses
the static plugin actions.
- Modal stacking: firing a quick action while a modal (e.g. New task) was
open pushed the target on top, so finishing Plan left the sheet behind.
Dismiss any open modal before the quick action navigates, so they act
as top-level either/or entries.
- Plan slides up full screen instead of fading, matching the capture sheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire up expo-quick-actions so long-pressing the app icon offers
"New task" and "Plan" shortcuts on iOS and Android, deep-linking to
the existing /new and /plan routes.
- Configure the plugin in app.json: static iOS actions (available
before first launch) plus Android adaptive shortcut icons.
- Register the actions at runtime on the home screen via setItems +
useQuickActionRouting (Android is runtime-only; the routing hook
must run inside the mounted navigator, not the root layout).
- Add distinct Android glyphs (plus, stacked cards) matching the iOS
SF Symbols.
Native config change — requires a new build, not an OTA update.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/engineering/eas-update.md: the build<->channel<->branch<->runtime-version
model, the publish command, what's OTA-able vs needs a rebuild, and how a device
receives an update. Leads with the constraint that bit us — the fingerprint policy
hashes config and .gitignore but not app JS, so a JS-only update must be published
from a clean tree or it silently won't match the installed build (the `*` on the
commit line is the tell). Indexed in the engineering README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The next-card transition glitched: after a decision the card flung off via the shared dragX, which the fling callback reset to 0 before the deck advanced — so the old card snapped back to centre for a frame before the next card came in. Decouple the outgoing card into its own fly-off overlay (separate transform) so resetting the deck's drag for the incoming card never disturbs it; the next card simply promotes up behind it. Pure JS — intended as the first EAS Update (OTA) payload to the preview build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add expo-updates with the fingerprint runtime-version policy and per-profile channels (development/preview/production), so JS-only client changes — most migration client work — ship over-the-air without an App Store round-trip. Takes effect from the next build (the binary must embed expo-updates). Runbook updated to mark OTA as configured.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the production client-release process the sortAt removal exposed: a narrow is gated on the prod client being updated AND adopted (not just a server deploy); under local-first a too-early narrow fails silently. Document EAS Update (OTA) vs store builds as the two release vehicles, the runtime-version gate, the live-client production sequence, and the recovery path (no data loss — dirty rows flush on the new client).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Engineering notes for the local-first data layer, the Convex sync design (ADR), and a zero-downtime schema-migration runbook (widen->migrate->narrow with the worked sortAt removal). Update the widget note and engineering index to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every feature now works offline with no account or network: a local MMKV-backed Zustand store is the source of truth behind a repository seam (src/data), and Convex is gone from the runtime path. Add an optional, account-scoped Convex sync engine (single-user/dogfood mode): an offline outbox of optimistic writes with idempotent upserts keyed by a client UUID and last-write-wins reconciliation, plus the push/pull backend (convex/sync.ts).
Replace the over-engineered sortAt ordering with stable createdAt/completedAt, and drop the sortAt column via a zero-downtime widen->migrate->narrow (@convex-dev/migrations).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously only dark mode was tinted (to tame its brightening); light
mode used the default neutral glass. Derive the glass tint from
palette.background in both modes (~55% alpha, e.g. #FFF6E98C / #15121C8C)
so each bar reads as a quiet, background-coloured pane and the two modes
stay in sync with the palette.
Verified in the simulator in both appearances.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "regular" glass material brightens what's behind it — subtle over
the light cream theme, but a distracting bright band over the dark
background. Tint the glass toward the background (#15121C) in dark mode
so it reads as a quiet dark frost; light mode already looked right and
stays untinted. Documented the scheme difference in the liquid-glass note.
Verified in the simulator in both appearances.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/engineering/ capturing the non-obvious implementation details
future contributors need before touching these areas:
- home-screen-widget.md — the expo-widgets architecture (serialised body
in a separate WidgetKit runtime), the live Convex→widget bridge, and
the gotchas (no null in the payload, 'use no memo', App Group signing,
@expo/ui rendering quirks).
- liquid-glass.md — the scroll-driven Liquid Glass header/footer, the
opacity-animation workaround, and the React Compiler `.set()` rule for
shared values in JS callbacks.
Linked from the docs index.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header and footer glass was always on, so the bars read as a band
even at the scroll boundaries where nothing sits behind them. Drive each
bar's glass from scroll position instead: fully transparent when there's
nothing underneath (the header at the top, the footer at the bottom),
fading in smoothly as cards slide behind it.
Implemented with Reanimated via the expo-glass-effect opacity workaround
— fading a wrapper's opacity directly on a GlassView stops the effect
rendering, so a wrapper view's opacity is animated while glassEffectStyle
toggles to 'none' below a threshold. The header keys off scroll offset,
the footer off distance-from-bottom (content vs viewport height). The
wordmark, badges and action buttons sit above the glass and never dim.
Verified in the simulator across a full ready list: header transparent
at the top and frosting in as you scroll; footer the reverse.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header and footer were opaque cream bars with the list clipped
between them, so cards hit a hard cut-off as they scrolled. Restructure
so the list scrolls full-height behind absolutely-positioned bars, and
back those bars with iOS 26 Liquid Glass (expo-glass-effect) — cards now
frost softly beneath the header and footer for a smooth transition.
Each bar's height is measured (onLayout) and fed back as the scroll
view's top/bottom content insets, so the first and last card sit clear
of the glass at rest. Where Liquid Glass isn't available the bar falls
back to a solid background-coloured bar, keeping the layout identical.
Verified in the simulator with a full ready list: cards frost under both
bars while scrolling; wordmark, badges and action buttons stay crisp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous change put the emoji beside the title everywhere, but the
horizontal layout was only wanted on the medium widget — to fix its
cramped vertical spacing. The in-app view and the roomy large widget
read better with the emoji stacked above the title.
Parameterise emptyCard with a `row` flag: the medium widget passes it
(emoji beside title); the large widget stays vertical. Revert the in-app
EmptyState to its vertical layout.
Verified in the simulator: in-app and large render the emoji above
"All clear"; medium renders it beside.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stacking the emoji above the title made the short medium widget's
empty card feel cramped — the tall card crowded the header. Put the
emoji beside the title instead (a centred row), so the card is a
compact banner that sits comfortably in the medium widget with room to
breathe. "Nothing ready" keeps its count stacked under the title to the
right of the icon.
Applied to the in-app EmptyState and the medium + large widgets alike
so the card stays consistent across every surface.
Verified in the simulator: in-app, medium and large all render the
emoji beside "All clear", centred.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the "Add something to glide through" tagline (needless), and make
the all-clear card render identically in the app and the widgets: a
compact, content-hugging card with a centred 🎉 + "All clear", centred
in the available space rather than filling it. This fixes the medium
widget's All-clear card reading as too tall (it was a full-height card
with sparse, left-aligned content) and aligns it with the in-app
EmptyState.
Switch the widget empty states from SF Symbols to the same emojis the
app uses (🎉 all-clear, 🗂️ nothing-ready) so the two surfaces match.
"Nothing ready" keeps its functional count; only the all-clear tagline
is removed.
Verified in the simulator across in-app, medium and large widgets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The client sync bootstrap called api.revenuecat.refresh unconditionally,
which in free-sync mode throws (no RevenueCat key) and aborts the pull/push
attach — and on success would reset an entitlement-less user to 'free' and
stop sync. Skip the refresh when EXPO_PUBLIC_FREE_SYNC=1; FreeSyncBridge
holds the client state at 'subscribed' and the server grants access via
SANDBOX_GRANT_ALL.
Also document that the sandbox Convex deployment needs CLERK_JWT_ISSUER_DOMAIN
(and deliberately no RevenueCat key).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EXPO_PUBLIC_FREE_SYNC=1 (preview env only; production builds refuse it)
turns the paywall into a plain sign-up/sign-in flow and maps signed-in
to subscribed via FreeSyncBridge. Server-side, SANDBOX_GRANT_ALL=1 on
the glide-sandbox deployment grants any authenticated identity
read-write sync before the RevenueCat cache is consulted — that env var
is the real gate and must never be set on production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two apps, two publishers: production (Glide, com.h14h.glide, future LLC
accounts) and sandbox (Glide Beta, com.henrynelsonfirth.glide, personal
accounts), driven by GLIDE_APP_VARIANT in app.config.js and per-profile
EAS environments. EAS Workflows: PR lint/typecheck, fingerprint-gated
sandbox deploy on every merge to main, and tag-driven production
releases. docs/release/environments.md is the anchor doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes to the drag-to-reorder mechanics:
Row frame measurements now merge on the UI thread via SharedValue.modify.
The previous JS-side read-modify-write of frames.value raced itself when
every row reported onLayout in one mount batch (JS writes apply to the UI
thread asynchronously, so each read saw a stale array and the writes
clobbered each other) — leaving the frames array sparse and the drag
unable to compute a target slot, i.e. reordering silently did nothing
after a cold launch.
The drop handoff is now commit-atomic: rows gate their drag transforms on
a session number that bumps in the same render that reorders the list, so
the re-rendered rows are born with identity transforms in the exact frame
the layout moves under them. Previously the shared values were zeroed
from JS before React's reorder commit landed, which flashed every row
back at its pre-drag position for a frame.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous implementation swapped the React array mid-drag: each swap
re-rendered the list under the active pan, whose worklet kept calling a
closure over the stale order — so the drag dead-ended after one slot —
and compensated the dragged row's offset with an approximate row height
that ignored the list gap, so the row visibly jumped at every swap.
Now nothing re-renders while a drag is live. The dragged row tracks the
finger 1:1 through a shared value, clamped to the list's bounds; siblings
dodge via animated styles driven by shared drag state using measured row
frames; and the reorder commits exactly once on release, after the row
has settled pixel-perfectly into its destination slot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RevenueCatIdentityLink called Purchases.logOut() unconditionally
whenever Clerk reported no signed-in user, but that's the default
state for nearly every user in this local-first app — RevenueCat is
already anonymous, so the call was invalid and logged a warning on
every load. Guard it behind Purchases.isAnonymous().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opening Plan now demotes stale Ready tasks back for a fresh review pass
(with a recency-based sort so recently-touched tasks sink to the back
of the deck), Next Up gains manual drag-to-reorder, and the Now card
gains a Defer action that pushes the task to the back of the queue with
a lightweight friction counter. Adds order/statusChangedAt/deferCount
fields end-to-end (schema, sync engine, local store) so the new state
syncs identically for local-only and Convex-backed accounts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The confirm step previously had only a Close button, so a signed-in user
who reached it couldn't return to change their plan. Give it a back arrow
(→ plan step) matching the auth step, and intercept the Android hardware
back so it steps within the flow (auth / confirm → plan) instead of
dismissing the whole paywall.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Continue (plan step) and Subscribe (confirm step) now share a bottom-pinned
action footer, so they land at the same position and size — consistent
touch target, no dead space under Subscribe. Value content fills the area
above: the plan step scrolls; the confirm step gains a "what you get"
recap so it doesn't read as bare. Tighten the plan hero/benefit spacing so
both plan cards sit fully above the button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructure the plan step to the value-led order that converts: hero →
benefit highlights → price → CTA → trust, with subtle staggered entrance
motion. Keeps Glide's hard-shadow / rounded language and avoids dark
patterns (no fake urgency or countdowns).
- Hero headline + three benefit rows (instant sync, auto backup, continuity)
with accent icon tiles.
- Annual anchored against monthly: shows the per-month equivalent
($1.67/mo) and "2 months free" so the yearly plan reads as the better
value; annual stays the highlighted default.
- "Cancel anytime." trust line under the CTA on both the plan and payment
steps.
- Plan step scrolls; extract a shared IconButton; add bolt/icloud.fill/
sync symbols.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getSyncPackage now resolves a plan's package by standard Annual/Monthly
type, then by package type, then by a year/month hint in the product id,
and surfaces a diagnostic string when nothing matches — so an offering
whose packages use custom identifiers (e.g. glide_sync_yearly) still
resolves instead of failing with a generic error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Local-first stays free; a subscription gates Convex multi-device sync.
- Paywall flow (plan -> real Clerk auth -> payment) in paywall-screen.tsx
+ route src/app/paywall.tsx, registered in _layout.
- Header Sync entry / account button driven by real Clerk auth
(sync-status.tsx); export AccountButton for reuse.
- Grace / billing-retry notices on the Action screen and a local-only
caption on New Task, plus an empty-state placement fix.
- react-native-purchases wired via src/data/sync/revenuecat.tsx
(<RevenueCatBridge/>): env-driven key, Purchases.logIn(clerkUserId),
`sync` entitlement -> subscription state; Subscribe -> purchasePackage,
with a dev stub fallback (entitlement.ts).
- Docs: docs/engineering/subscriptions.md (+ README index, local-first link).
react-native-purchases is native, so this needs a fresh dev build; payment
and entitlement stay test-only until a RevenueCat Test Store key is set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The NOW card had both a layout (LinearTransition) animation and a transform
(the enter settle) on the same Animated.View, so Reanimated warned the
transform could be overwritten. Split them: an outer wrapper carries the
layout/enter/exit animations, an inner view carries the transform. styles.hero
has no width/margin, so the extra wrapper doesn't change layout.
Pre-existing; unrelated to the quick-action work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
QA on the EAS build surfaced three issues with the new app-icon quick
actions:
- Duplication: iOS listed each action twice because they were registered
both statically (plugin iosActions -> Info.plist) and via setItems at
runtime. Gate setItems to Android, which has no static path; iOS uses
the static plugin actions.
- Modal stacking: firing a quick action while a modal (e.g. New task) was
open pushed the target on top, so finishing Plan left the sheet behind.
Dismiss any open modal before the quick action navigates, so they act
as top-level either/or entries.
- Plan slides up full screen instead of fading, matching the capture sheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire up expo-quick-actions so long-pressing the app icon offers
"New task" and "Plan" shortcuts on iOS and Android, deep-linking to
the existing /new and /plan routes.
- Configure the plugin in app.json: static iOS actions (available
before first launch) plus Android adaptive shortcut icons.
- Register the actions at runtime on the home screen via setItems +
useQuickActionRouting (Android is runtime-only; the routing hook
must run inside the mounted navigator, not the root layout).
- Add distinct Android glyphs (plus, stacked cards) matching the iOS
SF Symbols.
Native config change — requires a new build, not an OTA update.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/engineering/eas-update.md: the build<->channel<->branch<->runtime-version
model, the publish command, what's OTA-able vs needs a rebuild, and how a device
receives an update. Leads with the constraint that bit us — the fingerprint policy
hashes config and .gitignore but not app JS, so a JS-only update must be published
from a clean tree or it silently won't match the installed build (the `*` on the
commit line is the tell). Indexed in the engineering README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The next-card transition glitched: after a decision the card flung off via the shared dragX, which the fling callback reset to 0 before the deck advanced — so the old card snapped back to centre for a frame before the next card came in. Decouple the outgoing card into its own fly-off overlay (separate transform) so resetting the deck's drag for the incoming card never disturbs it; the next card simply promotes up behind it. Pure JS — intended as the first EAS Update (OTA) payload to the preview build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add expo-updates with the fingerprint runtime-version policy and per-profile channels (development/preview/production), so JS-only client changes — most migration client work — ship over-the-air without an App Store round-trip. Takes effect from the next build (the binary must embed expo-updates). Runbook updated to mark OTA as configured.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the production client-release process the sortAt removal exposed: a narrow is gated on the prod client being updated AND adopted (not just a server deploy); under local-first a too-early narrow fails silently. Document EAS Update (OTA) vs store builds as the two release vehicles, the runtime-version gate, the live-client production sequence, and the recovery path (no data loss — dirty rows flush on the new client).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every feature now works offline with no account or network: a local MMKV-backed Zustand store is the source of truth behind a repository seam (src/data), and Convex is gone from the runtime path. Add an optional, account-scoped Convex sync engine (single-user/dogfood mode): an offline outbox of optimistic writes with idempotent upserts keyed by a client UUID and last-write-wins reconciliation, plus the push/pull backend (convex/sync.ts).
Replace the over-engineered sortAt ordering with stable createdAt/completedAt, and drop the sortAt column via a zero-downtime widen->migrate->narrow (@convex-dev/migrations).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously only dark mode was tinted (to tame its brightening); light
mode used the default neutral glass. Derive the glass tint from
palette.background in both modes (~55% alpha, e.g. #FFF6E98C / #15121C8C)
so each bar reads as a quiet, background-coloured pane and the two modes
stay in sync with the palette.
Verified in the simulator in both appearances.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "regular" glass material brightens what's behind it — subtle over
the light cream theme, but a distracting bright band over the dark
background. Tint the glass toward the background (#15121C) in dark mode
so it reads as a quiet dark frost; light mode already looked right and
stays untinted. Documented the scheme difference in the liquid-glass note.
Verified in the simulator in both appearances.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/engineering/ capturing the non-obvious implementation details
future contributors need before touching these areas:
- home-screen-widget.md — the expo-widgets architecture (serialised body
in a separate WidgetKit runtime), the live Convex→widget bridge, and
the gotchas (no null in the payload, 'use no memo', App Group signing,
@expo/ui rendering quirks).
- liquid-glass.md — the scroll-driven Liquid Glass header/footer, the
opacity-animation workaround, and the React Compiler `.set()` rule for
shared values in JS callbacks.
Linked from the docs index.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header and footer glass was always on, so the bars read as a band
even at the scroll boundaries where nothing sits behind them. Drive each
bar's glass from scroll position instead: fully transparent when there's
nothing underneath (the header at the top, the footer at the bottom),
fading in smoothly as cards slide behind it.
Implemented with Reanimated via the expo-glass-effect opacity workaround
— fading a wrapper's opacity directly on a GlassView stops the effect
rendering, so a wrapper view's opacity is animated while glassEffectStyle
toggles to 'none' below a threshold. The header keys off scroll offset,
the footer off distance-from-bottom (content vs viewport height). The
wordmark, badges and action buttons sit above the glass and never dim.
Verified in the simulator across a full ready list: header transparent
at the top and frosting in as you scroll; footer the reverse.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header and footer were opaque cream bars with the list clipped
between them, so cards hit a hard cut-off as they scrolled. Restructure
so the list scrolls full-height behind absolutely-positioned bars, and
back those bars with iOS 26 Liquid Glass (expo-glass-effect) — cards now
frost softly beneath the header and footer for a smooth transition.
Each bar's height is measured (onLayout) and fed back as the scroll
view's top/bottom content insets, so the first and last card sit clear
of the glass at rest. Where Liquid Glass isn't available the bar falls
back to a solid background-coloured bar, keeping the layout identical.
Verified in the simulator with a full ready list: cards frost under both
bars while scrolling; wordmark, badges and action buttons stay crisp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous change put the emoji beside the title everywhere, but the
horizontal layout was only wanted on the medium widget — to fix its
cramped vertical spacing. The in-app view and the roomy large widget
read better with the emoji stacked above the title.
Parameterise emptyCard with a `row` flag: the medium widget passes it
(emoji beside title); the large widget stays vertical. Revert the in-app
EmptyState to its vertical layout.
Verified in the simulator: in-app and large render the emoji above
"All clear"; medium renders it beside.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stacking the emoji above the title made the short medium widget's
empty card feel cramped — the tall card crowded the header. Put the
emoji beside the title instead (a centred row), so the card is a
compact banner that sits comfortably in the medium widget with room to
breathe. "Nothing ready" keeps its count stacked under the title to the
right of the icon.
Applied to the in-app EmptyState and the medium + large widgets alike
so the card stays consistent across every surface.
Verified in the simulator: in-app, medium and large all render the
emoji beside "All clear", centred.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the "Add something to glide through" tagline (needless), and make
the all-clear card render identically in the app and the widgets: a
compact, content-hugging card with a centred 🎉 + "All clear", centred
in the available space rather than filling it. This fixes the medium
widget's All-clear card reading as too tall (it was a full-height card
with sparse, left-aligned content) and aligns it with the in-app
EmptyState.
Switch the widget empty states from SF Symbols to the same emojis the
app uses (🎉 all-clear, 🗂️ nothing-ready) so the two surfaces match.
"Nothing ready" keeps its functional count; only the all-clear tagline
is removed.
Verified in the simulator across in-app, medium and large widgets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>