Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cff431c
feat(ui): token foundation, SyncStatus registry, global accent & widg…
psimaker May 31, 2026
15cd407
feat(ui): shared component kit (StatusBadge, StatusRow, DetailRow, Ac…
psimaker May 31, 2026
210c0c8
refactor(ui): retint standalone views onto semantic status tokens
psimaker May 31, 2026
573a967
feat(ui): persistent sync-status header + hub retint (Phase 3a)
psimaker May 31, 2026
0c39ee0
feat(ui): TabView shell — split the overloaded hub into Sync + Device…
psimaker May 31, 2026
c09790b
refactor(ui): finish app-wide retint onto semantic status tokens (Pha…
psimaker May 31, 2026
a412f0f
feat(ui): actionable onboarding pager (Phase 4a)
psimaker May 31, 2026
ad943ad
feat(ui): dedicated Relay tab + canonical SubscribePlanPicker (Phase 4b)
psimaker May 31, 2026
b01e83d
feat(ui): conflict resolution affordance, honest rescan progress, sta…
psimaker May 31, 2026
e1cb82e
feat(ui): widget VoiceOver, L10n fallback, design-token lint guardrai…
psimaker May 31, 2026
7a74165
refactor(ui): rework Relay tab — distinct states, reframed pitch, Set…
psimaker May 31, 2026
3088d42
refactor(ui): move Relay privacy framing behind an info button
psimaker May 31, 2026
9c7f21e
fix(l10n): translate redesign strings to de/es/zh, unify relay termin…
psimaker May 31, 2026
ce19e74
fix(ui): resolve redesign review findings — honesty, a11y, dead code,…
psimaker May 31, 2026
6446c7f
fix(ui): relay privacy popover truncation + close the token-lint retu…
psimaker May 31, 2026
e24536f
fix(notify): keep the sidecar alive on inactive-subscription relay 4xx
psimaker May 31, 2026
383c1d5
fix(ui): show real Obsidian vaults on the home screen, not the sync f…
psimaker May 31, 2026
b5aea06
fix(l10n): correct translation errors and add missing status keys
psimaker May 31, 2026
146e589
chore: remove dead localization keys and unused Swift symbols
psimaker May 31, 2026
75ccb26
fix(l10n): shorten overflowing button and status labels (de/es/zh)
psimaker May 31, 2026
72d11c9
docs: document the UI redesign in the unreleased 1.5.0 changelog + RE…
psimaker May 31, 2026
b8467ff
fix(ui,l10n): resolve actionable CodeRabbit findings (skip 3 nitpicks)
psimaker May 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,26 @@ jobs:
working-directory: notify
run: go test ./... -count=1

design-lint:
name: Design Token Lint
runs-on: ubuntu-latest # pure bash/grep guardrail; no Xcode needed
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
Comment thread
psimaker marked this conversation as resolved.

- name: Check design tokens
# Fails if views reintroduce raw status colors instead of Theme.swift
# tokens — keeps the redesign's single source of truth from eroding.
run: ios/scripts/design-token-lint.sh

build:
name: Build & Test
runs-on: macos-26 # Xcode 26.2 default, iOS 26 SDK required for BGContinuedProcessingTask
timeout-minutes: 45 # guard against a hung simulator/xcodebuild burning 10x macOS minutes
needs:
- go-tests
- notify-tests
- design-lint
steps:
- uses: actions/checkout@v6

Expand Down
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,27 @@ All notable changes to VaultSync are documented here.

### Added

- **Guided Cloud Relay server setup** — Cloud Relay needs a small helper (`vaultsync-notify`) on your server, and the app now says so clearly. A new **Set Up Your Server** screen explains the step and offers a copyable one-line command (with the relay URL pre-filled); it appears right after you subscribe and from Settings → Cloud Relay. Localized in English, German, Spanish, and Simplified Chinese.
- **A complete visual redesign** — VaultSync moves to a coherent design system: a single brand accent (instead of stray system blue), a status palette that resolves correctly in light and dark mode, and a shared component kit used across the app and the home-screen widget.
- **Tabbed home screen** — the single overloaded screen is split into **Sync**, **Devices**, and **Cloud Relay** tabs, led by a persistent status header that states one glanceable truth ("All Synced" / "Syncing…" / "Needs Attention").
- **Onboarding that does it for you** — onboarding steps now launch the real task (choose your Obsidian folder, pair a device, scan a QR code) and turn green as you complete them, instead of describing setup in prose.
- **Guided Cloud Relay server setup** — Cloud Relay needs a small helper (`vaultsync-notify`) on your server, and the app now says so clearly. A new **Set Up Your Server** screen explains the step and offers a copyable one-line command (with the relay URL pre-filled); it appears right after you subscribe and from the Cloud Relay tab. Localized in English, German, Spanish, and Simplified Chinese.
- **Yearly Cloud Relay plan** — Cloud Relay is now available as a yearly subscription in addition to monthly, at a lower effective monthly price. Both prices are read from StoreKit and shown correctly per storefront.
- **In-context Cloud Relay offer** — After your first successful sync, VaultSync offers Cloud Relay in context (with a one-tap path into server setup), and the home screen shows an unobtrusive upgrade row for non-subscribers.

### Changed

- **Honest Cloud Relay status** — The setup checklist and Settings no longer call Cloud Relay "ready" just because you subscribed. They now reflect real delivery: *waiting for your server* until a wake-up actually arrives, then *delivering wake-ups*.
- **Cloud Relay has its own tab** — Cloud Relay moved out of Settings into a dedicated tab that brings the subscribe offer, server-helper setup, delivery status, diagnostics, and manage-subscription together. When you're not subscribed it leads with a focused, privacy-first pitch — a tiny wake-up on top of your already-free peer-to-peer sync, not cloud storage — and lists the monthly plan first with the yearly plan shown as savings.
- **Status is never color-only** — every sync state pairs an icon and a text label with its color, so it is clear for VoiceOver and color-blind users and reads identically on the home screen, the activity log, and the widget.
- **Clearer conflict resolution and honest progress** — Keep This / Keep Both / Keep Other are full-width buttons that always confirm before changing any files (previously "Keep Both" applied with none); the home screen lists your actual Obsidian vaults instead of the raw sync folder; and the vault rescan reflects the real scan state instead of a fixed timer.
- **Honest Cloud Relay status** — The setup checklist and the Cloud Relay tab no longer call Cloud Relay "ready" just because you subscribed. They now reflect real delivery: *waiting for your server* until a wake-up actually arrives, then *delivering wake-ups*.
- **Cloud Relay monthly price** — The monthly price was raised; the app always shows the live, storefront-correct price from StoreKit and never hard-codes an amount.
- **Verified subscriptions** — The relay now verifies the App Store signed transaction against Apple's certificate chain and enforces the subscription expiry server-side, so an expired or cancelled subscription stops receiving wake-ups.

### Fixed

- **Cloud Relay server helper no longer crash-loops** — when a subscription is inactive the relay replies with a 4xx; the `vaultsync-notify` helper treated that as fatal and, under `restart: unless-stopped`, restarted in a loop. It now logs the response and keeps running.
- **The widget can't show a false "all good"** — an unrecognised sync status now surfaces as *needs attention* instead of silently falling back to the green idle state, and the widget gained VoiceOver labels.
- **Localization** — the redesign's new strings are translated to German, Spanish, and Simplified Chinese with full key parity across all four languages, and existing translation errors and relay terminology drift were corrected.
- **iPhone and iPad both get wake-ups** — When an iPhone and iPad shared the same server, they could displace each other's push registration so only one received Cloud Relay wake-ups. Both are now kept, and tokens Apple reports as invalid are cleaned up automatically.

## [1.4.0] — 2026-05-30
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ VaultSync is **not** a hosted note-sync service and **not** a magic always-on da

## What’s New — v1.5.0

Cloud Relay now guides you through the one-time server helper it needs (right after you subscribe), adds a **yearly plan**, and shows honest delivery status instead of just “subscribed”. The relay verifies your subscription with Apple and enforces expiry, and an iPhone and iPad sharing one server both receive wake-ups. See [CHANGELOG.md](CHANGELOG.md) for details.
VaultSync gets a top-to-bottom **visual redesign**: a tabbed home screen (Sync · Devices · Cloud Relay) with a persistent, glanceable status header, a coherent design system that is correct in light and dark mode, status that never relies on color alone, and onboarding whose steps actually run the setup for you. **Cloud Relay** moves into its own tab with honest delivery status and a clearer, privacy-first pitch — and keeps its yearly plan and Apple-verified subscriptions, while its server helper no longer crash-loops on an inactive subscription. See [CHANGELOG.md](CHANGELOG.md) for details.

---

Expand Down
13 changes: 13 additions & 0 deletions docs/relay-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,19 @@ Wake-up signal from homeserver container. Sends silent push to all devices regis
- No file content, no folder names, no metadata — just a wake-up signal
- Rate limited server-side (separate from the client `DEBOUNCE_SECONDS`): roughly 1 push per Device ID per ~30s window

#### Error responses and how `vaultsync-notify` reacts

The trigger endpoint distinguishes a *subscription state* from a *misconfiguration*, and the sidecar reacts accordingly so a normal lapse never turns into a crash-restart loop:

| Status | Meaning | Sidecar behaviour |
|---|---|---|
| `400`/`401`/`402`/`403` | No active subscription for this Device ID — expired, cancelled, or not yet provisioned | **Recoverable.** Log and keep running; re-check on a slow cadence so delivery resumes automatically once the subscription is active again. |
| `404` | Endpoint missing — wrong `RELAY_URL` or a broken relay deployment | **Fatal.** Exit so the operator fixes the configuration (normally caught earlier by the startup `/health` check). |
| `429` | Server-side rate limit | Recoverable. Retry honouring `Retry-After`. |
| `5xx` / other | Transient relay/network fault | Recoverable. Retry with exponential backoff. |

The sidecar never exits on a subscription-state response; only a genuine misconfiguration (`404`) is fatal.

### GET /health

No authentication required.
Expand Down
2 changes: 2 additions & 0 deletions ios/VaultSync/App/VaultSyncApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ struct VaultSyncApp: App {
.onOpenURL { url in
handleIncomingURL(url)
}
.tint(.vaultAccent)
.animation(.easeInOut(duration: 0.3), value: hasCompletedOnboarding)
}
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
Expand Down
4 changes: 2 additions & 2 deletions ios/VaultSync/Models/SyncUserError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable {
category: .relayUnreachable,
title: L10n.tr("Relay Unreachable"),
message: L10n.tr("VaultSync could not reach the Cloud Relay service."),
remediation: L10n.tr("Check your internet connection and try the relay health check again in Settings."),
remediation: L10n.tr("Check your internet connection and try the relay health check again on the Relay tab."),
technicalDetails: rawMessage
)
}
Expand Down Expand Up @@ -80,7 +80,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable {
category: .auth,
title: L10n.tr("Authentication Error"),
message: L10n.tr("VaultSync could not verify this request."),
remediation: L10n.tr("Check your subscription status in Settings and retry. If this persists, restart VaultSync."),
remediation: L10n.tr("Check your subscription status on the Relay tab and retry. If this persists, restart VaultSync."),
technicalDetails: rawMessage
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.482",
"green" : "0.537",
"red" : "0.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.690",
"green" : "0.769",
"red" : "0.149"
}
},
"idiom" : "universal"
}
],
Expand Down
6 changes: 4 additions & 2 deletions ios/VaultSync/Resources/L10n.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import Foundation

enum L10n {
/// `value: key` means a missing translation falls back to the (English) key
/// text instead of rendering the bare key — matching the widget's helper.
static func tr(_ key: String) -> String {
NSLocalizedString(key, comment: "")
NSLocalizedString(key, tableName: nil, bundle: .main, value: key, comment: "")
}

static func fmt(_ key: String, _ args: CVarArg...) -> String {
String(format: NSLocalizedString(key, comment: ""), locale: Locale.current, arguments: args)
String(format: tr(key), locale: Locale.current, arguments: args)
}
}
193 changes: 187 additions & 6 deletions ios/VaultSync/Resources/Theme.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,191 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif

// MARK: - Design Tokens
//
// Single source of truth for the VaultSync visual language, compiled into BOTH
// the app and the widget target (see project.yml). Because it is shared with the
// widget extension it must NOT reference app-only symbols such as `L10n`; use
// `String(localized:)` for any user-facing text so each target resolves strings
// from its own bundle.
//
// Colors are built as dynamic Display-P3 `UIColor`s so light/dark (and the
// Increase-Contrast accessibility setting) resolve automatically — this retires
// the hand-rolled `colorScheme == .dark ? … : …` opacity math that used to live
// in the views.

#if canImport(UIKit)
/// A Display-P3 color that resolves light/dark and optional increased-contrast
/// variants from the active trait collection. Channels are 0–255 for legibility.
private func vaultColor(
light: (CGFloat, CGFloat, CGFloat),
dark: (CGFloat, CGFloat, CGFloat),
lightHC: (CGFloat, CGFloat, CGFloat)? = nil,
darkHC: (CGFloat, CGFloat, CGFloat)? = nil
) -> Color {
Color(uiColor: UIColor { traits in
let highContrast = traits.accessibilityContrast == .high
let channels: (CGFloat, CGFloat, CGFloat)
switch (traits.userInterfaceStyle, highContrast) {
case (.dark, true): channels = darkHC ?? dark
case (.dark, false): channels = dark
case (_, true): channels = lightHC ?? light
default: channels = light
}
return UIColor(
displayP3Red: channels.0 / 255,
green: channels.1 / 255,
blue: channels.2 / 255,
alpha: 1
)
})
}
#else
private func vaultColor(
light: (CGFloat, CGFloat, CGFloat),
dark: (CGFloat, CGFloat, CGFloat),
lightHC: (CGFloat, CGFloat, CGFloat)? = nil,
darkHC: (CGFloat, CGFloat, CGFloat)? = nil
) -> Color {
Color(red: light.0 / 255, green: light.1 / 255, blue: light.2 / 255)
}
#endif

// MARK: - Brand palette

/// Shared brand palette. Single source of truth so the app and the widget
/// render the same accent colors instead of redefining the RGB per file.
extension Color {
/// Brand teal — active / in-progress sync accent.
static let vaultTeal = Color(red: 0 / 255, green: 137 / 255, blue: 123 / 255)
/// Brand slate — muted/inactive accent.
static let vaultSlate = Color(red: 38 / 255, green: 50 / 255, blue: 56 / 255)
/// Primary interactive / affirmative-active brand accent. This is the single
/// app-wide tint (also mirrored in `AccentColor` so the asset-catalog global
/// accent matches). Used for links, selection, primary buttons, "syncing".
static let vaultAccent = vaultColor(
light: (0, 137, 123), // #00897B — the established brand teal, P3-tuned
dark: (38, 196, 176), // lifted so it stays vivid on a dark canvas
lightHC: (0, 110, 99),
darkHC: (74, 222, 202)
)

/// Brand teal — kept as the historical name so existing call sites keep
/// working, now dark-aware. Identical to `vaultAccent`.
static let vaultTeal = Color.vaultAccent

/// Deep neutral slate, used for muted fills/surfaces. Dark-aware so fills no
/// longer need per-call-site opacity math.
static let vaultSlate = vaultColor(
light: (38, 50, 56), // #263238
dark: (176, 190, 197) // #B0BEC5 — readable as a muted accent in dark
)
}

// MARK: - Semantic status palette
//
// Six pinned meanings, each ALWAYS paired with a symbol + text label by the
// `SyncStatus` registry so status is never conveyed by color alone.

extension Color {
/// Idle / all-synced / connected.
static let statusSuccess = vaultColor(light: (46, 158, 107), dark: (52, 199, 127))
/// Active transfer in progress (alias of the brand accent).
static let statusSyncing = Color.vaultAccent
/// Transient "starting/preparing" — a calm blue so it is never mistaken for
/// an error (today it is wrongly conflated with attention/orange).
static let statusStarting = vaultColor(light: (78, 124, 168), dark: (127, 168, 208))
/// Warning / action-needed (conflicts, pending shares, setup gaps).
static let statusAttention = vaultColor(light: (224, 146, 47), dark: (242, 169, 59))
/// Error / unreachable — reserved for genuine failures.
static let statusError = vaultColor(light: (210, 69, 59), dark: (232, 92, 82))
/// Informational / shared-with — replaces the off-brand system blue used for
/// "Shared With" checkmarks.
static let statusInfo = vaultColor(light: (78, 111, 181), dark: (110, 143, 216))
/// Paused / offline / inactive.
static let statusInactive = Color.secondary
}

// MARK: - Spacing & radius scale

/// 8pt soft grid. Replaces the 14-value padding literal soup.
enum VaultSpacing {
static let xs: CGFloat = 4
static let s: CGFloat = 8
static let m: CGFloat = 12
static let l: CGFloat = 16
static let xl: CGFloat = 24
}

/// Continuous corner radii. Replaces the 8/10/11/12/14/22/24/28 spread.
enum VaultRadius {
static let control: CGFloat = 12
static let card: CGFloat = 16
static let hero: CGFloat = 28
}

// MARK: - Sync status registry
//
// One canonical status type keyed by genuine sync state. Maps to a symbol, a
// semantic color, and a localized label. The widget decodes its stringly-typed
// snapshot through `fromWire(_:)` so an unknown value maps to `.attention`
// (NEVER silently to "all good"), closing the documented widget-lies bug.

enum SyncStatus: String, Sendable, CaseIterable {
case synced
case syncing
case starting
case attention
case error
case paused

/// Decode the app↔widget wire-format status string. Unknown → `.attention`.
static func fromWire(_ raw: String) -> SyncStatus {
switch raw.lowercased() {
case "idle", "synced", "ok": return .synced
case "syncing", "scanning": return .syncing
case "starting", "preparing": return .starting
case "attention", "warning", "warn": return .attention
case "error", "failed": return .error
case "paused", "inactive", "offline": return .paused
default: return .attention
}
}

/// Stable wire string for persisting into the shared snapshot.
var wireValue: String { rawValue }

var symbolName: String {
switch self {
case .synced: return "checkmark.circle.fill"
case .syncing: return "arrow.triangle.2.circlepath"
case .starting: return "hourglass"
case .attention: return "exclamationmark.triangle.fill"
case .error: return "xmark.octagon.fill"
case .paused: return "pause.circle.fill"
}
}

var tint: Color {
switch self {
case .synced: return .statusSuccess
case .syncing: return .statusSyncing
case .starting: return .statusStarting
case .attention: return .statusAttention
case .error: return .statusError
case .paused: return .statusInactive
}
}

/// Localized one-word/short label. Resolved from each target's own bundle.
var label: String {
switch self {
case .synced: return String(localized: "All Synced")
case .syncing: return String(localized: "Syncing")
case .starting: return String(localized: "Starting")
case .attention: return String(localized: "Needs Attention")
case .error: return String(localized: "Sync Error")
case .paused: return String(localized: "Paused")
}
}

/// True for states that should draw the user's attention (used for ordering
/// and for animating the symbol).
var isUrgent: Bool { self == .attention || self == .error }
}
4 changes: 0 additions & 4 deletions ios/VaultSync/Services/SubscriptionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,11 @@ final class SubscriptionManager {
static let monthlyProductID = "eu.vaultsync.app.relay.monthly"
static let yearlyProductID = "eu.vaultsync.app.relay.yearly"
static let relayProductIDs: Set<String> = [monthlyProductID, yearlyProductID]
/// Back-compat alias for call sites that need a single representative ID.
static let relayProductID = monthlyProductID

private(set) var isRelaySubscribed = false
private(set) var subscriptionExpiryDate: Date?
private(set) var monthlyProduct: Product?
private(set) var yearlyProduct: Product?
/// Primary product for legacy single-product call sites.
var availableProduct: Product? { monthlyProduct ?? yearlyProduct }
private(set) var purchaseInProgress = false
private(set) var isLoadingProduct = true
private(set) var errorMessage: String?
Expand Down
Loading
Loading