Commits
`eas submit --profile sandbox` resolved the production identity: submit
profiles have no `env` block and `eas submit` has no --environment flag, so
GLIDE_APP_VARIANT never reached app.config.js and EAS looked up credentials
for com.h14h.glide. It surfaced as "credentials cannot be set up in
--non-interactive mode" — a wrong-app error that reads like a missing-
credentials one. scripts/ship-sandbox.sh exports the variant and wraps both
halves.
It is a shell script rather than a package.json entry on purpose: the
`scripts` field of package.json is a fingerprint input, so adding one there
would change every runtime version and cut installed builds off from OTAs.
The script also repairs a footgun it takes a failed build to diagnose. A local
`expo run:android` makes AGP strip the legacy `package=` attribute from
masked-view's AndroidManifest.xml in place; bun hardlinks node_modules out of
its global cache, so the edit poisons the cache, spreads to every worktree, and
survives a clean reinstall. masked-view is autolinked via expo-router and so is
a fingerprint input — builds then die in CONFIGURE_EXPO_UPDATES naming no file.
The repair writes through the existing inode so the cache is fixed too.
Also correct the setup checklist: Play submission is verified working; iOS is
blocked only on an App Store Connect API key, which needs one interactive run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The splash still carried the Expo template: a blue #208AEF field with the
Expo chevron, and configured for Android only, so iOS launched on a bare
blue screen. Replace the mark with the Glide arrow (rendered from the app
icon's own glide-arrow.svg) and set it at the top level so both platforms
get it, on the brand purple with a deeper dark-mode variant.
Padding is baked into the PNG so Android 12's circular splash mask never
clips the arrow. The iOS-only fade keeps the purple splash from hard-cutting
into the cream board.
This is a native input: it changes the fingerprint, so it ships as a build,
not an OTA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PopButton computed `blocked = disabled || loading`. With `disabled={false}`
and no `loading` prop, that yields `undefined` rather than `false`.
`blocked` feeds `accessibilityState={{ disabled: blocked }}`, and RN's
BaseViewManager.setViewState only touches the native enabled flag when the
key is present:
if (accessibilityState.hasKey("disabled")) {
view.setEnabled(!accessibilityState.getBoolean("disabled"));
}
An undefined value is dropped in prop serialization, so hasKey is false and
setEnabled(true) never runs. The view stayed enabled=false from its initial
mount, and a disabled Android View discards touches before they reach JS, so
onPress never fired.
Most visible on the new task sheet: "Add" mounts disabled (empty field), so
after typing it looked enabled but only the keyboard's enter key worked --
onSubmitEditing fires on the TextInput and bypasses the button. iOS has no
equivalent disabled-view touch behaviour and was unaffected.
Fixed at the PopButton seam so every call site is covered. Verified on an
Android emulator: the a11y tree goes from [clickable,disabled] to
[clickable], and repeated add cycles work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
expo-widgets ships no working Android widget in any published version —
verified against the pinned 56.0.22 and the latest 57.0.6. The Glance widget
renders a hardcoded Text(widgetName), Android's WidgetsModule declares no
Widget object or updateTimeline, and the JS side resolves to a no-op stub, so
there is no renderer and no JS -> native data path.
Records the files and versions checked so this isn't re-investigated, flags
which files signal that upstream has landed (the changelog doesn't announce
it), and warns off enableAndroid: true, which only makes the placeholder
installable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
`eas submit --profile sandbox` resolved the production identity: submit
profiles have no `env` block and `eas submit` has no --environment flag, so
GLIDE_APP_VARIANT never reached app.config.js and EAS looked up credentials
for com.h14h.glide. It surfaced as "credentials cannot be set up in
--non-interactive mode" — a wrong-app error that reads like a missing-
credentials one. scripts/ship-sandbox.sh exports the variant and wraps both
halves.
It is a shell script rather than a package.json entry on purpose: the
`scripts` field of package.json is a fingerprint input, so adding one there
would change every runtime version and cut installed builds off from OTAs.
The script also repairs a footgun it takes a failed build to diagnose. A local
`expo run:android` makes AGP strip the legacy `package=` attribute from
masked-view's AndroidManifest.xml in place; bun hardlinks node_modules out of
its global cache, so the edit poisons the cache, spreads to every worktree, and
survives a clean reinstall. masked-view is autolinked via expo-router and so is
a fingerprint input — builds then die in CONFIGURE_EXPO_UPDATES naming no file.
The repair writes through the existing inode so the cache is fixed too.
Also correct the setup checklist: Play submission is verified working; iOS is
blocked only on an App Store Connect API key, which needs one interactive run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The splash still carried the Expo template: a blue #208AEF field with the
Expo chevron, and configured for Android only, so iOS launched on a bare
blue screen. Replace the mark with the Glide arrow (rendered from the app
icon's own glide-arrow.svg) and set it at the top level so both platforms
get it, on the brand purple with a deeper dark-mode variant.
Padding is baked into the PNG so Android 12's circular splash mask never
clips the arrow. The iOS-only fade keeps the purple splash from hard-cutting
into the cream board.
This is a native input: it changes the fingerprint, so it ships as a build,
not an OTA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PopButton computed `blocked = disabled || loading`. With `disabled={false}`
and no `loading` prop, that yields `undefined` rather than `false`.
`blocked` feeds `accessibilityState={{ disabled: blocked }}`, and RN's
BaseViewManager.setViewState only touches the native enabled flag when the
key is present:
if (accessibilityState.hasKey("disabled")) {
view.setEnabled(!accessibilityState.getBoolean("disabled"));
}
An undefined value is dropped in prop serialization, so hasKey is false and
setEnabled(true) never runs. The view stayed enabled=false from its initial
mount, and a disabled Android View discards touches before they reach JS, so
onPress never fired.
Most visible on the new task sheet: "Add" mounts disabled (empty field), so
after typing it looked enabled but only the keyboard's enter key worked --
onSubmitEditing fires on the TextInput and bypasses the button. iOS has no
equivalent disabled-view touch behaviour and was unaffected.
Fixed at the PopButton seam so every call site is covered. Verified on an
Android emulator: the a11y tree goes from [clickable,disabled] to
[clickable], and repeated add cycles work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
expo-widgets ships no working Android widget in any published version —
verified against the pinned 56.0.22 and the latest 57.0.6. The Glance widget
renders a hardcoded Text(widgetName), Android's WidgetsModule declares no
Widget object or updateTimeline, and the JS side resolves to a no-op stub, so
there is no renderer and no JS -> native data path.
Records the files and versions checked so this isn't re-investigated, flags
which files signal that upstream has landed (the changelog doesn't announce
it), and warns off enableAndroid: true, which only makes the placeholder
installable.
Co-Authored-By: Claude Opus 5 (1M context) <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>