Skip to content

apple: match web design — dark-mode contrast fix + cyan accent#17

Closed
JacobStephens2 wants to merge 94 commits into
mainfrom
feat/apple-web-look-contrast
Closed

apple: match web design — dark-mode contrast fix + cyan accent#17
JacobStephens2 wants to merge 94 commits into
mainfrom
feat/apple-web-look-contrast

Conversation

@JacobStephens2

Copy link
Copy Markdown
Owner

Why

The iOS Cascade screen was missing `.preferredColorScheme(.dark)`, so it rendered in light mode: black/`.secondary` labels on the always-dark backdrop nearly vanished (section captions like SYNC ACROSS DEVICES / LIFETIME LISTENING were unreadable). It also used the system-blue `.tint` instead of the web app's cyan accent.

What

Align both Apple shells with the web design tokens (`apps/web/src/styles.css`):

  • New shared `CascadeTheme` palette (ink / ink-dim / ink-faint, cyan accent `#6ec9e2`, danger, paper) mirroring the web `:root` tokens.
  • iOS pinned to dark mode (macOS already was).
  • Cyan brand accent everywhere via `.tint` (was system blue) — slider, toggle, chips, links, mute.
  • Captions use legible ink-dim / ink-faint instead of muddy `.secondary`.
  • Play button reshaped to the web's translucent disc + glowing accent ring + light glyph (was a solid blue disc).
  • Brand wordmark cyan/uppercase like the web mark.

Distribution

  • Bump to build 2, declare `ITSAppUsesNonExemptEncryption=false` (stops TestFlight re-asking export compliance).
  • Wire `CFBundleShortVersionString`/`CFBundleVersion` to the build settings in every target — XcodeGen was emitting literal `1.0 / 1`, so version bumps never reached the build (every upload would have collided on build number 1).
  • Add `ios-export.plist` for the archive→export→altool pipeline.

Verification

  • iOS + macOS both build clean; verified the new look by screenshotting the running iOS simulator and macOS app — captions crisp, reads like the web app.
  • TestFlight build 2 (`0.2.0 (2)`) uploaded (Delivery UUID `083069cb-dd95-4566-8304-41466cf97c99`).

JacobStephens2 and others added 30 commits May 28, 2026 03:31
Cargo workspace, .gitignore, README, and the architecture brief / Clave
tech stack docs that motivate the design. Source audio (mp3, ogg)
included; the 144MB wav is excluded via .gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The headless Rust crate that owns playback intent, volume, sleep + pomodoro
timers, and persisted settings. Time is supplied by the platform via
Command::Tick — the core never reads SystemTime. The dispatch API returns
Update { snapshot, effects }, with effects describing what the platform
should do (StartPlayback / PausePlayback / SetPlatformVolume / PersistSettings).

15 unit tests covering reducer behavior, timer countdown, volume clamping,
settings round-trip, and platform error handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single class CascadeCore with new() / restore(json) / snapshot() /
dispatch(commandJson). Every value crosses the boundary as JSON,
which keeps the API coarse-grained and forces the same shape we'd
need for UniFFI later on Android.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vite config wires vite-plugin-wasm (so cascade-wasm imports just work),
top-level-await (for the WASM init), and vite-plugin-pwa with a Workbox
precache large enough to hold the ~9MB OGG. Manifest is set up for
standalone display with maskable icons. Cargo.lock committed so the
WASM build is reproducible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
useCascade() owns the cascade-core WASM instance and the audio engine,
wires platform Effect handling (StartPlayback / PausePlayback /
SetPlatformVolume / PersistSettings), runs a 250ms tick loop only when
a timer is active, and persists settings JSON to localStorage under
"cascade.settings.v1".

WebAudioEngine loads the OGG into an AudioBuffer once, then loops via
AudioBufferSourceNode.loop = true with linearRampToValueAtTime fades
to avoid clicks on play/pause. Perceptual volume uses a square-law
curve so the slider midpoint sounds half as loud.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ackdrop

Pomodoro presets at 30 min / 1 hr / 8 hr plus a custom-minutes input,
sleep timer at 15/30/60. The waterfall backdrop is pure CSS — three
drifting blurred radial gradients tinted by the active timer's progress,
animation paused while paused. Reduced-motion users opt out.

Adds the OGG audio file in public/sounds and PNG icons generated from
public/icon.svg for the PWA manifest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
deploy/cascade.stephens.page.apache.conf documents the production
Apache config (SPA fallback, wasm + webmanifest mime types, long-cache
for fingerprinted assets, no-cache for index/service-worker). The
actual live copy lives at /etc/apache2/sites-available; certbot manages
the matching -le-ssl.conf.

deploy/deploy.sh rebuilds the WASM + Vite bundle into apps/web/dist
which Apache already serves.

.gitignore now also excludes *.tsbuildinfo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`#[serde(rename_all = "camelCase")]` on an enum only renames the variant
names, not the fields inside struct-like variants. That meant
`Effect::SetPlatformVolume { volume_percent }` serialized as
`{"type":"setPlatformVolume","volume_percent":25}` while the JS shell
read `effect.volumePercent` → undefined → `audio.setVolume(NaN)` → no-op.
Same shape mismatch hit `Command::Tick { elapsed_ms }` from the JS side.

Adding `rename_all_fields = "camelCase"` makes the inner fields follow
the same convention as the variant tags. Lock-down tests guard the wire
shape so this can't silently regress again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops serde-wasm-bindgen in favor of returning plain JSON strings that
the JS side parses with `JSON.parse`. Two reasons:

- Deterministic serde semantics. With serde-wasm-bindgen, tagged enum
  variants and renamed fields didn't always survive the JS object
  conversion the way `serde_json` serializes them, which made debugging
  shape mismatches a guessing game.
- Matches the architecture brief's "JSON across the boundary"
  recommendation — the same wire shape can be reused unchanged on the
  Android side once UniFFI lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`useCascade` now JSON.parses the snapshot and update strings the WASM
boundary returns. `WebAudioEngine.rampGain` and `percentToGain` defend
against non-finite inputs by clamping to safe values — Web Audio throws
a SyntaxError if `linearRampToValueAtTime` ever sees NaN, and even one
bad call leaves the gain stuck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After certbot --apache, the HTTPS vhost lives in a separate -le-ssl.conf
that didn't pick up the SPA rewrite rules or MIME types from the HTTP
file, so JS/WASM/manifest requests returned index.html. The reference
copy now mirrors both vhosts so a clean redeploy doesn't lose any of
that config.

Also moves the SPA fallback's RewriteCond from REQUEST_FILENAME (which
is unset at vhost scope before url->file mapping) to
DOCUMENT_ROOT + REQUEST_URI, so asset requests are recognized as real
files and bypass the index.html fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the headless Rust core with UniFFI proc-macros so the Android
shell gets a `CascadeBridge` JNI class with `dispatch(command_json)` →
`update_json`. Same JSON wire shape as the WASM bridge — both shells
parse the same Command / Effect / Snapshot tagged enums.

Errors come back as a typed Kotlin `CascadeException`; the variant
fields are named `reason` rather than `message` so the generated
sealed class doesn't shadow `kotlin.Exception.message`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A second native shell around `cascade-core`:

- `MainActivity` + `CascadeScreen` (Compose) mirror the web app: big
  play button, volume slider with the same perceptual square-law curve,
  pomodoro / sleep timer chips.
- `CascadePlaybackService` is a `MediaSessionService` hosting an
  ExoPlayer with the bundled waterfall.ogg on `REPEAT_MODE_ONE`, so
  playback survives backgrounding and shows up on the lock screen.
- `CascadeBridgeHolder` owns the UniFFI handle and exposes the
  current `Snapshot` as a `StateFlow`. `PlaybackController` collects
  `Effect`s from the holder and translates them into `MediaController`
  commands.
- Settings persist via Jetpack DataStore, keyed off the same JSON blob
  the Rust core emits via `Effect.PersistSettings`.

The Gradle build wires `cargo ndk` → `uniffi-bindgen` → Kotlin compile
as a `preBuild` chain, so `./gradlew assembleDebug` rebuilds the Rust
library, regenerates the Kotlin bindings, and assembles the APK in one
shot.

To avoid duplicating the 8.6 MB waterfall.ogg, the Android module reads
its assets from the web shell's `public/` directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The slider's track uses a `linear-gradient` stop positioned by the
`--pct` custom property, but the React component never set it, so the
gradient stayed pinned at the 60% fallback while the knob moved. Pass
it through as an inline style so the highlight tracks the value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A third native shell around cascade-core, alongside web and Android.

- SwiftUI app with three scenes: a main window for the focused use case,
  a MenuBarExtra for the 95%-case "click drop, hit play, pick a duration"
  path, and the standard Settings scene (launch-at-login + settings.json
  revealer).
- `CoreBridge` wraps the UniFFI handle in idiomatic Swift: typed
  `Snapshot`/`Effect`/`Command` DTOs round-trip the same JSON wire shape
  every other platform speaks.
- `AppStore` is the @observable single source of truth — owns the
  bridge, the audio engine, persistence, the tick timer, the power
  assertion, the App Nap guard, and the Now Playing publisher.
- `AudioEngine` decodes the waterfall asset once into a PCM buffer and
  uses `AVAudioPlayerNode.scheduleBuffer(.loops)` for sample-accurate
  gapless looping — sidesteps MP3/AAC encoder padding entirely. Volume
  curve matches the web shell's square-law perceptual ramp.
- `SettingsStore` writes Application Support/Cascade/settings.json,
  same JSON blob the web (localStorage) and Android (DataStore) clients
  already round-trip.
- `PowerAssertion` (IOPMAssertion) keeps the Mac awake during long
  sessions; `AppNapGuard` (ProcessInfo.beginActivity) keeps the timer
  ticks honest when backgrounded.
- `NowPlayingController` publishes to MPNowPlayingInfoCenter so the
  menu-bar widget, Control Center, and AirPods buttons drive playback.

Built on the user's Mac via `scripts/build.sh`: ffmpeg converts the
OGG to M4A, cargo cross-compiles cascade-uniffi for arm64 + x86_64,
lipo combines them, uniffi-bindgen regenerates the Swift code,
xcodegen rebuilds the Xcode project. Generated Swift bindings are
committed so the scaffold is buildable without uniffi-bindgen installed
on the Mac.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Goes from fresh clone → running app: required Homebrew packages, what
build.sh does, the Xcode signing step, day-to-day iteration matrix, and
a troubleshooting section for the failure modes I expect on first build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UniFFI doesn't ship a first-party C# generator and uniffi-bindgen-cs is a
third-party tool with rougher edges than its Kotlin/Swift siblings. Rather
than depend on it, the Windows shell loads cascade_uniffi.dll via
[DllImport] and calls a hand-rolled coarse C surface:

  cascade_new / cascade_restore_or_new -> *mut CoreHandle
  cascade_snapshot / cascade_dispatch  -> *mut c_char  (JSON, caller frees)
  cascade_free_string / cascade_free_handle

All strings cross as UTF-8 CStrings — same JSON wire shape as the UniFFI
bridges. The Rust side guards against null pointers and unparseable JSON.

A round-trip integration test exercises new -> snapshot -> dispatch(play)
-> free, so the C ABI can't silently regress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A fourth native shell around cascade-core.

- `Cascade.csproj` targets `net8.0-windows10.0.19041`, unpackaged WinUI 3
  via Windows App SDK 1.6, x64 + arm64. CommunityToolkit.Mvvm provides
  the source-generator MVVM (`[ObservableProperty]`, `[RelayCommand]`).
- `CoreBridge` is a P/Invoke wrapper over the C ABI exported by
  cascade-uniffi.dll. UTF-8 strings cross the boundary manually so the
  default Marshal Ansi/Unicode coding can't corrupt JSON.
- `AppViewModel` is the MVVM root: owns the bridge, the audio engine,
  the SMTC publisher, the persisted settings store, the tick scheduler,
  and the power controller. Same dispatch -> {snapshot, effects} shape
  as every other platform.
- `AudioEngine` wraps Windows.Media.Playback.MediaPlayer +
  MediaPlaybackList(AutoRepeatEnabled = true) — the documented Windows
  gapless-loop pattern. Volume uses the same square-law curve as the web
  shell.
- `SmtcController` hooks System Media Transport Controls so media keys,
  the Windows 11 volume flyout, and AirPods/headphones drive playback.
- `PowerController` uses SetThreadExecutionState(CONTINUOUS|SYSTEM_REQUIRED)
  during active sessions — mirrors macOS IOPMAssertion + Android wake
  lock.
- `SettingsStore` writes %LOCALAPPDATA%\Cascade\settings.json — same
  JSON blob the web (localStorage), Android (DataStore), and macOS
  (Application Support) shells round-trip.
- `Package.appxmanifest` declares the StartupTask extension for
  launch-at-login (toggled via Settings page in a follow-up); zero
  internetClient capability, mirroring the macOS sandbox stance.

Built on the user's Windows machine via `scripts/build.ps1`: ffmpeg
converts the OGG to MP3, cargo cross-compiles cascade-uniffi for
x86_64-pc-windows-msvc + aarch64-pc-windows-msvc, and the DLLs land in
Cascade/Native/{x64,arm64}/ where Cascade.csproj's `Content` includes
pick them up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The web/Android brief has been renamed to reflect what it actually covers
since the macOS, Windows, and iOS architecture council reviews now each
live in their own doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures apps/macos/ → apps/apple/ with three subdirectories:

  CascadeShared/  — every line of non-UI code, compiled into both targets
  CascadeMac/     — macOS-only chrome (MenuBarExtra, Settings scene)
  CascadeiOS/     — iOS-only chrome (full-screen view, UIBackgroundModes)

Shared files that needed iOS adaptation are now conditional:

- `AudioEngine.configureSessionForPlayback()` calls
  `AVAudioSession.sharedInstance().setCategory(.playback, ...)` on iOS;
  no-op on macOS where there's no audio-session concept.
- `PowerAssertion` flips between `IOPMAssertion(PreventUserIdleSystemSleep)`
  on macOS and `UIApplication.isIdleTimerDisabled = true` on iOS.
- `AppNapGuard` is a no-op on iOS — `UIBackgroundModes: audio` already
  keeps the runtime alive while playback is active. The class still
  exists so `AppStore` doesn't have to `#if` around its callsites.

The iOS target adds:

- `CascadeiOSApp` with up-front `AVAudioSession.playback` setup.
- `CascadeScreen` — iPhone-friendly layout reusing the same `AppStore`
  + dispatch + snapshot contract as macOS.
- `Info.plist` declaring `UIBackgroundModes: audio` (required for
  lock-screen audio) and `MPNowPlayingInfoPropertyMediaType` (required
  for Control Center to surface us).

`project.yml` now defines both `CascadeMac` and `CascadeiOS` targets,
each pulling from `CascadeShared/` plus its own platform-only sources.
`build-rust.sh` takes an optional `macos` / `ios` / `all` argument and
writes per-platform static libs into `build/rust/{macos,ios-device,
ios-sim}/`. The Xcode project consumes them via per-SDK
`OTHER_LDFLAGS`.

`build-asset.sh` decodes the OGG once and copies the resulting M4A into
both targets' `Resources/` so they bundle bit-identical audio.

`docs/running-macos.md` is updated to point at the new layout;
`docs/running-ios.md` is the iOS-specific runbook (setup additions,
free-signing 7-day install, lock-screen / Now Playing expectations,
troubleshooting).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous "Build cascade-ios" commit claimed these landed but the
edits were applied after `git mv` and never staged. They're the
platform-conditional bodies the iOS target needs:

- `AudioEngine.configureSessionForPlayback()` activates
  `AVAudioSession.playback` on iOS; no-op on macOS.
- `PowerAssertion` flips between `IOPMAssertion` (macOS) and
  `UIApplication.isIdleTimerDisabled` (iOS).
- `AppNapGuard.begin/end` are no-ops on iOS — there's no App Nap and
  `UIBackgroundModes: audio` already keeps the runtime alive.

Plus the build scripts now cross-compile every Apple triple
(`macos | ios | all`), produce per-platform static libs under
`build/rust/{macos,ios-device,ios-sim}/`, and run the OGG → M4A
conversion into both target Resources/ dirs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A fifth native shell — but a deliberately thin one. Per the architecture
council's Mode A consensus, the watch is a wrist remote: it sends
commands to the iPhone and renders whatever snapshot comes back. No
Rust core, no audio engine, no timer math on the watch.

Wire shape lives in `CascadeShared/Watch/WatchProtocol.swift` so the
iPhone and watch compile against identical Codable types:

  - `WatchToPhoneCommand`: requestSnapshot, togglePlayback, play, pause,
    setVolume, startSession(preset), cancelTimer
  - `WatchSessionPreset`: minutes30, minutes60, hours8
  - `PhoneSnapshotForWatch`: isPlaying, volumePercent, pre-formatted
    statusLine + timerRemainingLabel, timerProgress, version field

iPhone side (`CascadeiOS/Connectivity/`):

- `PhoneConnectivityService` activates `WCSession`, handles incoming
  messages, replies with a fresh snapshot inline, and also writes the
  same snapshot to `updateApplicationContext` so the watch has
  something to render on cold start.
- `WatchSnapshotMapper` translates a full `Snapshot` into the compact
  watch payload with a pre-built `statusLine`.
- `CascadeiOSApp` wires the service to the live `AppStore` on first
  appearance: commands route through `store.dispatch`; every snapshot
  update (set via the new `AppStore.onSnapshotChanged` hook) is pushed
  back to the watch.

Watch side (`CascadeWatch/`):

- `WatchConnectivityClient` (`@Observable`, `WCSessionDelegate`) holds
  the latest snapshot, caches it in `UserDefaults` for cold-start
  render, falls back from live `sendMessageData` to
  `transferUserInfo` when the iPhone is unreachable.
- `WatchRootView` uses watchOS 10's vertical-page TabView: Player
  (status + big play/pause + Digital Crown volume), Session
  (presets + cancel), Status (reachability + refresh).
- `WatchHaptics` wraps `WKInterfaceDevice` so the views don't import
  WatchKit directly. Every tap fires `.click` so the wrist confirms.

Embedded in the iOS app via xcodegen's `dependencies: - target:
CascadeWatch / embed: true` — the iOS target's Build Phases pick up
the watch bundle so `xcodebuild` packages it correctly. The watch
target only sees `CascadeShared/Watch/`, not the full shared tree,
because the rest depends on IOKit / AVFoundation headers that aren't
available on watchOS.

Mode B (standalone watch audio) is intentionally deferred — see the
"What's NOT here yet" section in `docs/running-watchos.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`<PublishProfile>win-$(Platform).pubxml</PublishProfile>` pointed at a
.pubxml that was never committed, which makes `dotnet publish` fail
trying to load a nonexistent profile. Publish args are passed explicitly
on the command line / from CI instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WinUI 3 has no cross-compile path, so this GitHub Actions workflow is
the reproducible way to build the Windows target without a local Windows
box — and it's the first real compile test for that shell, which has
never been built.

Pipeline (x64; arm64 wired but commented):
  1. cargo build --release --target x86_64-pc-windows-msvc -p cascade-uniffi
  2. stage cascade_uniffi.dll into Cascade/Native/x64/
  3. choco install ffmpeg; run build-asset.ps1 (OGG -> MP3)
  4. dotnet restore + build (the hard compile gate)
  5. dotnet publish --self-contained -> uploaded artifact

Triggers on pushes/PRs touching apps/windows, crates, the audio asset,
or the workflow itself, plus manual dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom panel previously only started a focus session. It now has its
own section with a Focus/Sleep mode toggle: pick a mode, enter minutes
(1–1440), and the form dispatches startPomodoro or startSleepTimer
accordingly. Submit-button label and validation follow the selected mode.

Verified on the live site: custom Sleep at 7 min started a counting-down
sleep timer and persisted defaultSleepMinutes=7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new Custom section under the presets: two FilterChips choose the mode
(Focus session vs Sleep timer), an OutlinedTextField takes minutes
(digits only, clamped 1–1440), and the Start button dispatches
startPomodoro or startSleepTimer accordingly. APK rebuilt clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI (the first-ever compile of this target) caught it: `Lock` is a .NET 9
type and the project targets net8.0-windows10.0.19041, so CoreBridge
failed to compile. Swapped to a plain `object` monitor lock. Everything
upstream of the C# compile (Rust MSVC cross-build, DLL staging, ffmpeg
asset, NuGet restore) passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
macOS: the existing custom field (focus-only) gains a segmented
Focus/Sleep Picker; the Start button dispatches startPomodoro or
startSleepTimer based on the selection, clamped 1–1440.

iOS: new Custom section with a segmented Focus/Sleep Picker and a
Stepper (1–1440, 5-min steps) so the duration can be set one-handed
without the numeric keyboard. Start button routes to the matching core
command.

Scaffold-only (Apple targets can't be compiled on the Linux build host);
the core commands they call are already exercised by the verified web
build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new CUSTOM section: Focus/Sleep RadioButtons + a NumberBox (1–1440,
inline spin buttons) + a Start button. The code-behind reads the value
and selected mode and calls AppViewModel.StartCustom, which dispatches
startSleepTimer or startPomodoro. Added a grid row and shifted the error
InfoBar down accordingly.

Will be validated by the Windows CI workflow once the queued build runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The thin-remote protocol only carried fixed presets, so add
`WatchToPhoneCommand.startCustom(minutes:sleep:)`. The iPhone
PhoneConnectivityService handles it by clamping to 1–1440 and
dispatching startSleepTimer or startPomodoro.

Watch UI (WatchSessionView) gains a Custom block: a Stepper driven by
the Digital Crown (1–1440, 5-min steps, no keyboard on the wrist), a
"Sleep timer" toggle, and a Start button that sends startCustom. The
iPhone runs the timer and the countdown flows back in the snapshot as
usual.

Scaffold-only (Apple targets aren't compilable on the Linux host); the
Codable shape is shared by the iPhone and watch so both sides stay in
sync by construction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JacobStephens2 and others added 28 commits May 31, 2026 14:25
Link the Android card to Cascade-0.1.0.apk on the new v0.1.0-android GitHub
release (universal APK, Android 8.0+). Note the self-signed install step.
Drop Android from the "in progress" list — only iOS remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The self-hosted /downloads/*.exe was never uploaded; the actual Windows
build ships as Cascade-v0.1.0-win-x64.zip on the v0.1.0-win release. Link
there instead (self-contained zip, extract + run win-x64\Cascade.exe), so
all three native cards now point at real, resolving downloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

The app ships unpackaged (WindowsPackageType=None), so the
ms-appx:///Assets/waterfall.mp3 URI has no package identity to resolve
against and MediaPlayer silently fails to open the asset — the window
shows but no sound plays. Load the asset by its real path next to the
executable via AppContext.BaseDirectory, which resolves correctly in both
packaged and unpackaged builds. Also add a MediaFailed handler so future
open failures surface in Debug output instead of failing silently.

Co-authored-by: Jacob Stephens <jstephens@vagabondtours.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cascade had no app icon (default placeholder everywhere). Add an AppIcon
asset catalog to all three Apple targets using the existing brand artwork
(the web app's waterfall icon). macOS uses the rounded-corner rendering;
iOS/watch use a square, opaque (no-alpha) variant since the OS masks them.
Wired via ASSETCATALOG_COMPILER_APPICON_NAME in project.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add app icons (macOS/iOS/watchOS)
Generate a multi-resolution app.ico (16-256px) from the web shell's brand
icon and wire it in two places the unpackaged app needs:
- <ApplicationIcon> embeds it in Cascade.exe (Explorer / taskbar / Alt-Tab).
- MainWindow sets it via AppWindow.SetIcon for the live window title bar.

Previously the app showed the generic default icon.

Co-authored-by: Jacob Stephens <jstephens@vagabondtours.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The footer showed "The Falls v3.1 · looped" and the loader said "Loading the
falls…" — the only spots still using the old working name. The core snapshot
title and every shell's media metadata already say "Cascade", so this just
aligns the visible web UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…IaC (#7)

* core: add listening-time tracking as a grow-only counter

Accrue audible listening on the existing Tick path inside the reducer.
The device total is one G-Counter slot: grow-only, merged server-side by
max-per-device and sum-across-devices, so concurrent listening on two
devices adds rather than overwrites.

Design follows the model-council synthesis:
- Gate accrual on a new `audio_confirmed_playing` flag (set by
  PlatformPlaybackStarted, cleared on pause/error/stop), not on intent,
  so an autoplay-blocked shell counts nothing until audio truly starts.
- Clamp each tick's delta (MAX_TICK_ACCRUAL_MS = 5s) so a sleep/wake gap
  or clock jump can't inflate the counter — the only attack surface a
  G-Counter has is the input delta.
- Tracking is on by default (opt-out) via SetListeningTracking.
- Persist a separate `cascade.listening.v1` blob via Effect::PersistListening,
  independent of settings; restore via Command::RestoreListening, which
  never lowers a live counter (max-merge guards partial writes).
- ApplySyncedTotal moves the display baseline only and never decrements
  the device slot; ResetListeningData zeros it (shell rotates device_id).
- No sync effect is emitted from core — shells observe unsyncedMs and own
  their own cadence, keeping the core free of network policy.

Snapshot gains a `listening` view (tracking flag, device + displayed
totals, unsyncedMs, formatted label). No FFI signature changes — the new
serde fields flow through the existing JSON wire shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: rewrite privacy policy for optional account-based sync

The old copy promised "no accounts" and "no servers that receive data
about you," which the listening-time feature contradicts. Rewrite to the
accurate posture:

- Nothing is stored on a server unless the user creates an account.
- Listening time is tracked on-device by default (on by default, opt-out),
  and is a single cumulative number — never a timeline. State plainly that
  no dates, times, or per-session entries are recorded, so when/how someone
  listened cannot be reconstructed. This is the data-minimization
  defensibility argument for default-on tracking.
- Describe what an optional account holds: email (magic-link sign-in, no
  password) plus the aggregate listening total, and nothing else.
- Add sign-out / delete-data / delete-account guidance.

Per the synthesis, this copy is a launch gate and must deploy in the same
release as the feature itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: track and display lifetime listening time

Wire the listening feature into the web shell (local, no account yet):

- Mirror the new core Commands/Effect/Snapshot in types.ts.
- Persist the `cascade.listening.v1` blob on the PersistListening effect and
  restore it once on boot via RestoreListening — a separate slot from
  settings and session, owned opaquely by the core.
- Widen the tick loop: it previously ran only while a timer was active, so
  plain playback accrued nothing. It now also ticks while audio is playing,
  at a coarse 1s cadence (vs 250ms for timers) to keep it cheap. The shell
  already dispatches PlatformPlaybackStarted after audio.start(), so accrual
  is correctly gated on confirmed playback.
- Add a ListeningStats panel: lifetime total + an on/off tracking toggle
  (SetListeningTracking).

Verified: 12 assertions against the node-target wasm build (accrual gating,
5s clamp, mute, restore, sync baseline, camelCase wire shape) and a headless
Chrome render showing the readout and toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* server: add the listening-time sync service (deploy-later)

A standalone Rust/Axum + SQLx + Postgres service (its own cargo workspace,
detached from the core so its heavy deps stay out of the shell lockfile).
Implements the synthesis's backend recommendation:

- Magic-link auth: POST /auth/request emails a one-time link (always 200, so
  it never leaks who has an account); POST /auth/verify consumes it atomically
  (single-use via UPDATE ... WHERE used=FALSE RETURNING) and mints an opaque
  server-side session token. Opaque, not JWT, so logout/delete revoke instantly.
  Tokens are stored only as SHA-256 hashes.
- G-Counter aggregation: PUT /listening upserts a per-(user,device) slot with
  GREATEST(existing, incoming) and returns SUM across the user's devices;
  GET /listening reads the aggregate. Concurrent devices add, never overwrite.
- Data minimization by construction: schema holds an email and one integer per
  device — no session log, no per-listen timestamps. Total is knowable, timing
  is not.
- DELETE /listening clears counters (client rotates device_id to close the
  G-Counter resurrection loophole); DELETE /account cascades to sessions +
  counters and instantly invalidates the session.
- SMTP delivery via lettre, with a log-only fallback when SMTP isn't configured.
- Runtime queries (no compile-time DB needed); migrations run on boot.

Verified: 3 unit tests + a full end-to-end run against a throwaway Postgres
(magic-link single-use, session auth, GREATEST merge keeping the higher slot,
SUM, delete-data, delete-account session revocation, 401 without a token).

NOT deployed yet — must go live in the same release as the shells + privacy
page. Provisioning steps (DB, subdomain, certbot, systemd, SMTP) in README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: add account sign-in and listening-time sync

Complete the web vertical against cascade-sync-server:

- api.ts: typed client for the sync endpoints; gated on VITE_SYNC_API so the
  feature cleanly disappears when no backend is configured (local tracking
  still works).
- useSync.ts: owns the optional account and the sync loop. The core stays pure
  — this hook reads snapshot.listening (deviceTotalMs / unsyncedMs), decides
  cadence (immediate on sign-in, after 30s of unsynced time, and a keepalive
  flush on pagehide/visibility-hidden), and folds the server aggregate back in
  via applySyncedTotal. Completes magic-link sign-in from a ?token= URL and
  strips it. A 401 drops the session locally without losing local tracking.
- Device id is stored per-device and ROTATED on delete-data/delete-account, so
  a stale offline write can't resurrect a deleted G-Counter slot.
- AccountControls: email sign-in form, signed-in state, and manage/delete
  actions with confirmation.

Sync cadence lives entirely in the shell (no sync effect from core), per the
synthesis. Verified: a real headless-Chrome magic-link sign-in against a local
server (CORS + verify + signed-in render), with the DB confirming the user,
session, consumed single-use token, and the browser's initial sync PUT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* android: track and display lifetime listening time

Wire listening into the Android shell (local; sync is a follow-up):

- Mirror the new Commands/Effect/ListeningSnapshot in Dto.kt (the JSON wire
  shape — UniFFI passes it through unchanged, no binding regen needed).
- Persist the listening blob in DataStore under its own key, separate from
  settings; restore once on startup via RestoreListening (CascadeBridgeHolder),
  and write it on the PersistListening effect.
- Critically, wire confirmed playback: PlaybackController now attaches a
  Player.Listener and dispatches PlatformPlaybackStarted/Paused/Error from real
  Media3 state. Without this the accrual gate (which keys on confirmed audio,
  not intent) would never fire on Android.
- Widen the tick loop: it ran only while a timer was active, so plain playback
  accrued nothing. It now also ticks while playing, at a coarse 1s cadence.
- Add a ListeningStats row (lifetime total + tracking Switch).

Verified: :app:assembleDebug builds the Rust uniffi lib + Kotlin clean. Not yet
run on a device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* android: account sign-in and listening-time sync

Add the optional account + sync loop to the Android shell:

- SyncApi: dependency-free HttpURLConnection client for the sync endpoints.
- AccountStore: DataStore for session token + email + a stable device id, with
  rotation on delete (so a stale offline write can't resurrect a deleted slot).
- SyncManager: owns account state and cadence (immediate on sign-in, after 30s
  of unsynced time, and a flush on onStop), folding the server aggregate back
  into the core via ApplySyncedTotal. A 401 drops the session locally without
  losing local tracking. Cadence lives in the shell, not the core.
- Magic-link deep link: an autoVerify intent-filter for
  https://cascade.stephens.page/auth; MainActivity extracts ?token= on
  create/new-intent and completes sign-in. (assetlinks.json on the host is a
  deploy step for chooser-free opening.)
- AccountControls UI: email sign-in, signed-in state, manage/delete actions.

Sync target is SYNC_API_BASE (the planned sync subdomain); the feature is
opt-in so no network call happens until a user signs in. Verified:
:app:assembleDebug builds clean. Not yet run on a device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* windows: track and display lifetime listening time

Wire listening into the WinUI shell (local; account sync is a follow-up):

- Mirror the new Commands/Effect/ListeningSnapshot in Dto.cs (JSON passes
  through the C ABI unchanged).
- Persist the listening blob to %LOCALAPPDATA%\Cascade\listening.json, separate
  from settings; restore once at startup via RestoreListening, and write it on
  the PersistListening effect.
- Widen the tick loop: it ran only while a timer was active, so plain playback
  accrued nothing. It now also ticks while playing, at a coarse 1s cadence
  (TickScheduler takes an interval; AppViewModel restarts it when the cadence
  changes). Confirmed-playback is already reported (PlatformPlaybackStarted
  after StartPlayback), so accrual is correctly gated.
- Add a lifetime readout + a Tracking on/off toggle to MainWindow.

Compile-verified only via the Windows CI workflow (cannot build on the Linux
dev host). Not run on Windows hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* apple: track and display lifetime listening time (macOS + iOS)

Wire listening into the shared Apple core and the macOS + iOS UIs (local;
account sync is a follow-up):

- Mirror the new Commands/Effect/ListeningSnapshot in the shared Dto.swift
  (JSON passes through UniFFI unchanged).
- Persist the listening blob to Application Support/Cascade/listening.json,
  separate from settings; restore once at bootstrap via RestoreListening, and
  write it on the PersistListening effect.
- Widen the shared tick loop: it ran only while a timer was active, so plain
  playback accrued nothing. It now also ticks while playing, at a coarse 1s
  cadence (startTicking takes an interval; apply() restarts on cadence change).
  Confirmed-playback is already reported, so accrual is correctly gated.
- Add a lifetime readout + tracking Toggle to the macOS main window and the
  iOS screen.

watchOS needs no change: it's a thin remote that renders a mapped subset
snapshot and owns no slot — exactly the recommended design (no double-counting
between wrist and phone).

Compile-verified only via the Apple CI workflow (no macOS/Xcode on the Linux
dev host). Not run on Apple hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* windows: silence MVVMTK0045 (field [ObservableProperty] deprecation)

CommunityToolkit.Mvvm 8.4 flags [ObservableProperty] on fields (snapshot,
errorMessage) as deprecated in favor of partial properties, but those need
C# 13 / the .NET 9 SDK and CI builds with the .NET 8 SDK (C# 12), where they
don't compile. Suppress the forward-compat warning until this target moves to
.NET 9; the field syntax is correct and functional today.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* server: add a Prometheus /metrics endpoint

Instrument cascade-sync-server with the idiomatic metrics +
metrics-exporter-prometheus stack:

- A request-counting middleware records cascade_sync_http_requests_total and
  cascade_sync_http_request_duration_seconds, labelled by method, matched route
  template (not raw path, so cardinality stays bounded), and status.
- GET /metrics renders the Prometheus exposition. It's registered after the
  counting layer so 15s scrapes don't dominate the counters, and it carries
  only operational metrics — no PII, consistent with the data-minimization
  posture (an attacker scraping it learns request rates, never who has an
  account).
- Exposed on the localhost bind only; the Apache vhost must not proxy it.

Verified locally: per-route counters and the latency histogram render, and
/metrics does not count itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* server: codify the sync-service deploy as Terraform + Ansible

Replace the by-hand deploy with Infrastructure as Code for the listening-time
sync service — real, load-bearing infra rebuilt from text and reviewable in a PR.

Terraform (server/deploy/terraform):
- Creates the DNS A record for sync.cascade.stephens.page and references the
  existing droplet read-only. Deliberately additive: an apply cannot recreate
  or destroy load-bearing infra. Documents the `terraform import` path for
  bringing the existing droplet/volume/firewall under management incrementally.

Ansible (server/deploy/ansible), role `cascade_sync`, idempotent:
- Creates a dedicated cascade_sync Postgres role + database.
- Installs a systemd unit that runs the binary as an unprivileged user with
  filesystem/syscall hardening (ProtectSystem=strict, empty CapabilityBoundingSet,
  MemoryDenyWriteExecute, …) — the runtime half of the threat model.
- Installs an Apache reverse-proxy vhost that refuses to expose /metrics.
- Obtains TLS via certbot --apache (idempotent).
- Renders the env file; DB password + SMTP creds come from an ansible-vault
  file, the DO token from the environment. Nothing secret is committed.

Validated: all Ansible YAML parses, Jinja/HCL braces balance. Full
`terraform validate` / `ansible-playbook --syntax-check` should run on a host
with the toolchains installed. A sanitized copy is destined for the
infrastructure-patterns repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* server+web: STARTTLS for port 587, enable sync in prod web build

Two fixes found during the live deploy:

- mail.rs: select the SMTP transport by port. relay() does implicit TLS (port
  465); 587/submission needs STARTTLS. Using relay() on 587 produced
  "received corrupt message of type InvalidContentType" against Resend. Now
  465 -> relay(), everything else -> starttls_relay(). Verified: magic links
  send through smtp.resend.com:587 in production.
- apps/web/.env.production: set VITE_SYNC_API to the deployed sync subdomain so
  `vite build` ships the account/sync feature in the production bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* server: target the cascade_sync Ansible role at Ubuntu/apache2

The role was authored against Rocky/httpd, but the real fleet (and the live
hand-deploy) is Ubuntu + Apache 2.4. Make the artifact truthful and runnable
on the actual host:

- defaults: apache2 service, /etc/apache2/sites-available confdir, and an
  apache_use_a2ensite toggle (set false + httpd confdir for a Rocky host).
- tasks: enable proxy + proxy_http via apache2_module, and a2ensite the vhost
  (guarded by the sites-enabled symlink so it's idempotent).

This mirrors the steps actually used to deploy sync.cascade.stephens.page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* server: observability-as-code for the sync service (Prometheus + Grafana)

Monitoring-as-code consuming the live /metrics endpoint:

- prometheus/cascade-sync.scrape.yml — scrape the app on localhost plus an
  external blackbox HTTPS probe of /health (catches the Apache/TLS/DNS layer
  the app can't self-report).
- prometheus/cascade-sync.rules.yml — alerts: service down, external probe
  failing, TLS cert expiring <7d, 5xx rate >5%, mean latency >1s.
- grafana/cascade-sync-dashboard.json — import-ready dashboard: up, probe,
  cert days remaining, request rate by route, status mix, error ratio, mean
  latency.

Replaces ad-hoc health-curling with the standard stack. JSON/YAML validated;
provisioning via an Ansible observability role is documented for when the
Prometheus/Grafana stack is stood up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: approval-gated deploy pipeline for the sync server

A workflow_dispatch pipeline that builds the cascade-sync-server release
binary (cargo test + build), uploads it, then deploys via the Ansible role
behind a protected `production` environment with a required reviewer — the
GitOps-flavored counterpart to the hand-rolled web deploy script.

Manual-only so a deploy is always deliberate; concurrency-guarded so two
deploys can't overlap. Secrets (SSH key/known_hosts, deploy host, vault
password) live on the environment. Documented in the workflow header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* server: threat model for cascade-sync-server

Document what the service protects, against whom, and the residual risk —
mirrors the muxboard threat-model artifact. Centers on data-minimization-by-
construction (the schema can't express a listening timeline) as the control
that bounds every other threat, then walks T1–T12 (enumeration, hashed tokens,
single-use magic links, opaque revocable sessions, CSRF/SQLi/CORS, deletion
resurrection, metrics disclosure, systemd hardening, secrets, counter
inflation) and names the real gaps: no rate limiting (R1), localStorage tokens
(R2), no audit log (R3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: ADR for the listening-time CRDT architecture

An architecture decision record covering the six linked decisions behind
cross-platform listening time: pure-core accrual gated on confirmed playback,
a G-Counter CRDT (max-per-device / sum-across-devices, reject LWW), data-
minimization-by-construction (no timeline), sync cadence in the shells,
opaque magic-link auth, and device_id rotation on delete. Includes
alternatives considered and validation. Destined to mirror into
infrastructure-patterns as a senior architect-thinking artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: HN-grade blog post draft on the listening-time design

Narrative writeup — "Tracking Listening Time Across Six Platforms Without
Building a Surveillance Tool": the one-core/six-shells shape, confirmed-
playback gating, the G-Counter (why latest-total is wrong), data-minimization
by construction (the schema can't store a timeline), opaque magic-link auth +
device_id rotation on delete, and cadence-in-shells.

Staged here as publish-ready HTML matching the blog.stephens.page template
(title/center/footer classes, OG tags). NOT pushed to the live blog — drop it
into blog.stephens.page/posts/ + add the index entry when ready to publish.
Elevates the Cascade "buried gem" per the advancement plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* windows: account sign-in and listening-time sync

Complete the Windows sync vertical (compile-verified via CI):

- SyncApi: typed System.Net.Http client for the sync endpoints.
- AccountStore: session token + email + a stable device id under LocalAppData,
  with rotation on delete (so a stale write can't resurrect a deleted slot).
- AppViewModel: account state + relay commands (request link, complete sign-in,
  sign out, delete data/account) and a SyncAsync loop folding the server
  aggregate back via ApplySyncedTotal. Cadence in the shell: immediate on
  sign-in + once 30s of unsynced time accrues; a 401 drops the session locally.
- MainWindow: a "sync across devices" panel (email + paste-the-link sign-in,
  signed-in state, manage/delete).

Desktop protocol-activation (open the app from the https link) and DPAPI/
Credential Locker token storage are the on-device finishing — the scaffold
uses a paste-the-link flow and a LocalAppData file. Not run on Windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* apple: account sign-in and listening-time sync (macOS + iOS)

Complete the Apple sync vertical in the shared layer (compile-verified via CI):

- SyncApi: async URLSession client for the sync endpoints.
- AccountStore: session token + email + a stable device id in UserDefaults,
  rotated on delete (closes the G-Counter resurrection loophole).
- AppStore: account state + async methods (signIn, completeSignIn, signOut,
  deleteListeningData, deleteAccount) and a sync() that folds the server
  aggregate back via ApplySyncedTotal. Cadence in the shell: immediate on
  sign-in + once 30s of unsynced time accrues; a 401 drops the session.
- AccountControlsView: shared SwiftUI panel (email + paste-the-link sign-in,
  signed-in state, manage/delete), added to the macOS + iOS screens.
- .onOpenURL routes a magic-link into handleOpenURL for when Universal Links
  are configured.

New files live under CascadeShared/Sync (compiled into Mac + iOS by xcodegen;
the watch target only includes CascadeShared/Watch, so it stays a thin remote).
Keychain token storage + Universal Links are the on-device finishing. Not run
on Apple hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First Android release that includes the optional account: magic-link sign-in
and cross-device listening-time sync (G-Counter), plus on-device tracking.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: Android build + release workflows

Fills the Android CI gap (Windows/Apple each had a build workflow; Android had
none) and automates the APK release that was previously built and uploaded by
hand.

- android.yml: compile check (cargo-ndk Rust core for all ABIs + Kotlin) on
  push to main / PR / dispatch — mirrors windows.yml and apple.yml.
- release-android.yml: on a v*-android tag, builds the release APK and publishes
  it to a GitHub Release. Debug-signed (sideload), so no signing secrets needed.
  The publish is a separate job behind `environment: release`, so a required
  reviewer can gate every release behind a named human approval — same pattern
  as deploy-sync.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: install cargo-ndk via cargo install (reliable across runners)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1) (#10)

* android: fix sign-in (INTERNET permission) + scrollable layout; v0.2.1

Two fixes from on-device testing of 0.2.0:

- Add the INTERNET permission. Cascade was an offline app (bundled audio), so
  it was never declared — and the account/sync feature is the first code that
  makes an HTTP call, so every request threw ("Couldn't send the sign-in link").
- Make the main screen vertically scrollable and drop the weight-based spacer
  (incompatible with scrolling). Content was overflowing the viewport with no
  scroll, clipping the custom-timer input field into a stray box overlapping the
  sleep-timer chips. Also moved the sign-in/account box below the timer controls.

Bumps to 0.2.1 / versionCode 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* android: paste-the-link sign-in fallback; v0.2.2

Magic links won't reliably reopen a sideloaded, debug-signed APK (no stable
signing key to pin Android App Links to), so add the desktop-style fallback:
"Email me a link" requests it, then paste the link back into the app to finish.
SyncManager.completeSignInFromLink extracts the token from the pasted URL (or a
raw token) and runs the existing verify flow. Two-step UI with weight-based
fields so the buttons don't overflow.

Bumps to 0.2.2 / versionCode 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The macOS app set `com.apple.security.network.client: false` ("this app
never reaches the network") — correct for the original white-noise-only
build. The listening-time account/sync feature (the magic-link sign-in +
G-Counter sync to cascade-sync-server) was added afterwards but the
sandbox entitlement was never re-enabled, so under the App Sandbox every
outbound URLSession call fails silently and the UI just shows
"Couldn't send the sign-in link."

Flip the entitlement to true (and regenerate Cascade.entitlements). This
is the macOS counterpart of the Android INTERNET-permission fix (#10).

Verified end-to-end on macOS after the change: magic-link request,
verify/sign-in, cross-device sync pull, delete-data, and delete-account
all work against the live sync server.

Co-authored-by: admin <admin@MacBook-Air-7.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a magic-link handoff so a Windows-initiated sign-in can complete in the
desktop app instead of only the web app.

Windows app:
- Self-register the cascade:// URI scheme via WinAppSDK
  ActivationRegistrationManager (delivers a real Protocol activation; a
  hand-written HKCU command only yields a Launch activation).
- Custom Program.Main: single-instance with activation redirection, so a
  cascade://auth?token=... handoff signs into the already-running app rather
  than spawning a second window.
- Route the activating URI to the existing sign-in path
  (AppViewModel.SignInWithLinkAsync) and bring the window to front.

Server:
- /auth/request accepts an optional `platform`; "windows" appends
  &app=windows to the emailed link so the web /auth page can hand the
  single-use token to the desktop app instead of consuming it.

Web:
- When the link carries app=windows, hand the token to cascade:// (with a
  visible fallback link) instead of verifying it in the browser.

Also fix a pre-existing bug where the signed-in/out view didn't update on
runtime account changes: replace the x:Bind function-binding visibility
(which didn't re-evaluate on Account changes) with notified Visibility
properties, and marshal the 401 sign-out path onto the UI thread.

Co-authored-by: Jacob Stephens <jstephens@vagabondtours.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The account magic-link email links to https://cascade.stephens.page/auth?token=…,
which today opens the website. Register that URL as a Universal Link so it opens
Cascade straight into completeSignIn() instead — no hand-copied token.

The Swift side was already wired (onOpenURL → handleOpenURL → completeSignIn);
this adds the OS-level registration that was missing:

- associated-domains entitlement (applinks:cascade.stephens.page) on the macOS
  and iOS targets, declared in project.yml's entitlements.properties so
  XcodeGen regenerates rather than clobbers them (cf. #1).
- the AASA file at apps/web/public/.well-known/apple-app-site-association
  scoped to the /auth?token=… path and the app's appID.
- a /auth browser fallback page that surfaces the token to paste for devices
  without the app installed.
- docs/universal-link-signin.md runbook.

Requires the paid Apple Developer Program: the associated-domains capability
can't be signed under free personal-team signing (a signed build fails with
"entitlements that require signing with a development certificate"). CI is
unaffected — apple.yml compiles with CODE_SIGNING_ALLOWED=NO (verified).

Builds on the network.client entitlement fix (account sync needs the network).

Co-authored-by: admin <admin@MacBook-Air-7.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #13's static /auth page shows the magic-link token with a Copy button and
relies on Apple Universal Links to open the native app automatically. Windows
has no Universal Link for an unpackaged app, so the link minted with
&app=windows (server, #14) only ever showed the copy-the-code fallback.

When app=windows is present, redirect to cascade://auth?token=... so the
desktop app launches and signs in, with an "Open Cascade" button as the
fallback if the browser blocks the programmatic protocol navigation. The
copy-the-code path remains for when the app isn't installed.

Co-authored-by: Jacob Stephens <jstephens@vagabondtours.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A rendered overview of the one-core/six-shells design, embedded in the README Architecture section and linked from Documentation. SVG is the source; PNG for reliable rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A static page (matching the app's dark theme) presenting the one-core/six-shells design with the rendered diagram. Lives in public/ so it survives builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A discreet Architecture link beside the footer mark, styled to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The free personal team (G38J85UN6P) was retired when the paid Apple
Developer Program membership (team LHY8W725A8) activated. associated-
domains can now be signed with a real development cert, so:

- AASA appIDs -> LHY8W725A8.page.stephens.cascade (must match the
  signed app's team-prefixed application-identifier or Apple rejects
  the Universal Link association).
- project.yml DEVELOPMENT_TEAM -> LHY8W725A8 so signed/automatic builds
  resolve a profile under the paid team.
- universal-link-signin.md runbook updated to the new team ID.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apple's AASA fetcher requires Content-Type: application/json on
/.well-known/apple-app-site-association. The vhost typed .webmanifest
etc. but left the extensionless AASA untyped, so it was served with no
Content-Type — which can make Apple's CDN ignore the file and break
Universal Link validation. Add a <Files> ForceType to both vhosts.
Already applied to the live server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The base settings force PRODUCT_NAME: Cascade on every target, so the
iOS app and the embedded watch app emit identically-named products
(Cascade.swiftmodule / Cascade.app / .app.dSYM). A per-platform `build`
keeps them in separate Release-iphoneos / Release-watchos dirs, but
`xcodebuild archive` collects everything into one path and fails with
"Multiple commands produce ...". This only surfaced once we archived
for TestFlight. Give the watch its own PRODUCT_NAME: CascadeWatch
(visible name stays "Cascade" via CFBundleDisplayName).

Also bump MARKETING_VERSION to 0.2.0 to match the account-sign-in +
listening-sync feature line (parity with Android 0.2.x).
Mirror apps/web/src/styles.css `:root` so the native UI reads as the same
product. The old dark scheme used a lighter midnight (#0B1A24) and a slightly
different accent; switch to the web's near-black bg (#050b10/#02060a), exact
accent (#6ec9e2), and danger (#ff8a72). Material's ColorScheme has no slot for
the dim/faint ink ramp or the translucent "paper" surfaces, so expose those as
a CascadeColors token object, the way the web uses CSS custom properties.

The web app is dark-only (color-scheme: dark), so drop the light scheme and the
system light/dark branch and always apply the Cascade dark palette — the brand
then reads identically on web and Android.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Translate the web UI's visual language to Compose so the two clients look like
one product:

- Backdrop: a top-centered radial accent glow over bg-deep that brightens while
  playing, replacing the flat vertical gradient (mirrors the web's
  radial-gradient at 50% 0%).
- Header: the "CASCADE" mark is now uppercased, letter-tracked, accent-colored
  with a soft glow; the subtitle is dim tracked text.
- Play control: the signature element. Was a solid accent disc; now a dark disc
  inside glowing accent rings — a dashed outer ring that rotates while playing,
  an inner glow, a thin accent rim, and a drawn play/pause glyph (Canvas, so no
  new icon dependency).
- Chips: pill-shaped ghost chips (transparent + paper border, accent border/fill
  when active) replace Material FilterChips, matching .chip--ghost.
- Section headings/captions: tiny uppercase heavily-tracked faint labels.
- Listening: wrapped in a bordered translucent "paper" card with a large
  light-weight readout, like .listening.
- Volume: round pill mute button and an accent thin-track slider.
- Footer: the web's "CASCADE · LOOPED" + Architecture link (opens the web page).

Content is constrained to a 440dp centered column to echo the web's 480px
shell. Behavior and all callbacks are unchanged. Verified: :app:compileDebugKotlin
builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
enableEdgeToEdge() with no arguments derives the system-bar style from the
device's light/dark setting. On a light-mode phone that gave the dark-only
Cascade UI a stark white navigation bar and dark, low-contrast status-bar icons
over the near-black background. Pass SystemBarStyle.dark(TRANSPARENT) for both
bars so they're transparent with light icons regardless of the system theme.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v0.2.3 release was cut from a branch (dfd74c5) that never merged to main,
so main regressed to debug signing, version 0.2.2, and no App Links file. Bring
that work onto the release lineage so this build is release-signed (updates
0.2.3 in place) and main is no longer behind its own releases:

- build.gradle: a release signingConfig sourced from env (ANDROID_KEYSTORE_*),
  falling back to debug locally when the keystore isn't present.
- release-android.yml: decode the keystore from ANDROID_KEYSTORE_BASE64 and pass
  the store/alias/key secrets into assembleRelease (fails loudly if unset).
- apps/web/public/.well-known/assetlinks.json: the App Links fingerprint, now in
  the web public tree so a web rebuild stops dropping it.
- versionCode 6 / versionName 0.2.4.

Carries the web-style restyle (this branch's prior commits) into the release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The magic-link email only printed the URL, so it wasn't obvious that desktop/
mobile apps accept the pasted link (or that you can copy just the token). Spell
out all three paths — click the link, paste the whole link into the app, or
paste just the code — and print the bare token on its own line so it copies
cleanly without the URL (which some mail clients linkify in copy-unfriendly
ways). The web, Android, and Apple apps all accept a full link or a bare token.

send_login_link now takes the token alongside the link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The iOS screen lacked .preferredColorScheme(.dark), so it rendered in
light mode: black/.secondary labels on the always-dark backdrop nearly
vanished (section captions unreadable). Pin iOS to dark mode and align
both Apple shells with the web design tokens (apps/web/src/styles.css):

- New shared CascadeTheme palette (ink / ink-dim / ink-faint, cyan
  accent #6ec9e2, danger, paper) mirroring the web :root tokens.
- Cyan brand accent everywhere via .tint (was system blue).
- Captions use legible ink-dim/ink-faint instead of muddy .secondary.
- Play button reshaped to the web's translucent disc + glowing accent
  ring + light glyph (was a solid blue disc).
- Brand wordmark cyan/uppercase like the web mark.

Distribution:
- Bump to build 2 and declare ITSAppUsesNonExemptEncryption=false so
  TestFlight uploads stop re-asking export compliance.
- Wire CFBundleShortVersionString/CFBundleVersion to MARKETING_VERSION/
  CURRENT_PROJECT_VERSION in every target's Info.plist — XcodeGen was
  writing literal '1.0 / 1', so version bumps never reached the build
  (every TestFlight upload would have collided on build number 1).
- Add ios-export.plist (app-store-connect, automatic signing) for the
  archive→export→altool TestFlight pipeline.
@JacobStephens2 JacobStephens2 force-pushed the feat/apple-web-look-contrast branch from dcb8fd8 to c91416d Compare June 24, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant