apple: match web design — dark-mode contrast fix + cyan accent#17
Closed
JacobStephens2 wants to merge 94 commits into
Closed
apple: match web design — dark-mode contrast fix + cyan accent#17JacobStephens2 wants to merge 94 commits into
JacobStephens2 wants to merge 94 commits into
Conversation
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>
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.
dcb8fd8 to
c91416d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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`):
Distribution
Verification