diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 1dd3254c..85e19bd2 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -28,4 +28,9 @@ harness output: the summary lines and any failures, not the whole log
## UI changes
-Before and after screenshots, light and dark, on every affected platform. UI uses `design/` tokens, SF Symbols, and the system font only.
+Before and after screenshots, light and dark, on every affected platform.
+
+- [ ] Tokens, SF Symbols and the system font only
+- [ ] Both themes checked
+- [ ] `scripts/verify-contrast.sh` and `scripts/verify-design.sh` pass
+- [ ] Screenshots recaptured if the UI changed (`scripts/capture-screenshots.sh`)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9aeafe22..9a6a0d83 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,7 +64,7 @@ jobs:
out swift '^(app|CrocKit)/|^\.swift-format$|^\.xcode-version$|^scripts/format-swift\.sh$|^\.github/workflows/(ci|reusable-swift-format)\.yml$'
out go '^crocmobile/|^\.github/workflows/(ci|reusable-go)\.yml$'
out build '^(app|CrocKit|crocmobile)/|^scripts/build-xcframework\.sh$|^\.xcode-version$|^\.github/workflows/ci\.yml$'
- out design '^design/|^scripts/(lib|verify-contrast)\.sh$|^\.github/workflows/ci\.yml$'
+ out design '^design/|^app/.*\.swift$|^app/CrocApp/Assets\.xcassets/|^web/landing/.*\.(css|html)$|^web/docs/src/css/|^scripts/(lib|verify-contrast|verify-design)\.sh$|^\.github/workflows/ci\.yml$'
out interop '^(crocmobile|CrocKit)/|^scripts/(lib|verify-interop)\.sh$|^scripts/croc-v10\.version$|^\.github/workflows/ci\.yml$'
} | tee -a "$GITHUB_OUTPUT"
@@ -83,7 +83,7 @@ jobs:
uses: ./.github/workflows/reusable-go.yml
contrast:
- name: contrast
+ name: design checks
needs: changes
@@ -100,6 +100,8 @@ jobs:
- run: scripts/verify-contrast.sh
+ - run: scripts/verify-design.sh
+
# The interop harness against a relay on localhost, never the public relays:
# those rate-limit a shared runner address and make the run flaky. The
# public-pool scenario stays a local run (docs/BUILDING.md).
diff --git a/.gitignore b/.gitignore
index f982067e..559d36bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,9 +35,10 @@ build/
/_site/
/dist/
-# Copied from design/tokens.css at build time
+# Copied from design/ and web/landing/ at build time
web/landing/tokens.css
web/docs/src/css/tokens.css
+web/docs/static/img/mascot.webp
# Local tooling
*.log
diff --git a/AGENTS.md b/AGENTS.md
index bafb6eae..d9960677 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,31 @@
Native SwiftUI GUI for the croc file-transfer CLI.
+## Design
+
+Anything a user sees (app views, the landing page, the docs site, release artwork, store copy, UI strings) follows `design/`. Start at `design/AGENTS.md`. Run `scripts/verify-contrast.sh` and `scripts/verify-design.sh` after touching any of it; CI runs both.
+
+## Skills
+
+Load these before the matching work, every time:
+
+| Work | Skills |
+| --- | --- |
+| Anything design-related, on any surface: `design/`, views, stylesheets, artwork, UI copy | `apple-design` |
+| SwiftUI views, app UI, Liquid Glass, animation | `swiftui-expert-skill`, `mobile-ios-design` |
+| App Store submission, listing copy, review notes, entitlements, privacy | `apple-appstore-reviewer` |
+
+If one is missing, install it (user-level, all from [skills.sh](https://skills.sh) sources) before starting, then reload skills:
+
+```bash
+npx skills add emilkowalski/skills --skill apple-design -g -y
+npx skills add avdlee/swiftui-agent-skill --skill swiftui-expert-skill -g -y
+npx skills add wshobson/agents --skill mobile-ios-design -g -y
+npx skills add github/awesome-copilot --skill apple-appstore-reviewer -g -y
+```
+
+Check what is installed with `npx skills ls -g`. When a skill's advice conflicts with this repo (`design/`, a directory's `AGENTS.md`), the repo wins.
+
## Editing AGENTS.md files
Every `AGENTS.md` in this repo (root or in sub-dirs) is written and updated through the `claude-md-management:claude-md-improver` skill, or the closest equivalent your agent has. Run it on the file you change: audit it against the real state of its directory, then apply targeted edits. Keep each file short, current and specific to its directory. Don't restate what the code or another doc already says.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1999c14a..827a0c2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,14 @@ extra sections where a release needs them, and the project follows
## [Unreleased]
+### Changed
+
+- Buttons are easier to read in light mode: text labels now use the darker green, and destructive labels are no longer drawn in red text.
+- The screen that asks you to accept a transfer shows a Receiving header, the encryption badge and the file list as rows. Its Cancel button is gone: Decline answers the other device, leaving the screen gives no answer, and Home shows a banner to return to a request that is still waiting.
+- Send as Link now says Sending and Receiving like every other transfer, and copying the code shows "Copied".
+- On iPhone, a light haptic marks a finished, failed or incoming transfer and a copied or scanned code. Cancelling or declining yourself stays silent.
+- Settings labels use title case, and "Auto-accept incoming files" is now "Auto-Accept Transfers".
+
## [1.0.0] - 2026-10-15
CrocApp 1.0 is on three channels: the iOS and iPadOS App Store, the Mac App Store, and a notarized disk image for Mac. The App Store listings are linked from crocapp.dev. The other device needs croc 11 or newer.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a1e925b3..e26fd033 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -92,6 +92,15 @@ govulncheck ./...
Anything visual, in the app, the web or elsewhere, follows [`design/AGENTS.md`](design/AGENTS.md). In short, fixed design principles (brandbook), like: design tokens instead of raw hex, px, pt, or durations; SF Symbols only; system font only; a light and a dark value for every surface.
+Two scripts enforce it, and CI runs both on any change to `design/`, app Swift or color sets, or web CSS. Run them yourself after touching a value:
+
+```bash
+scripts/verify-contrast.sh # every contrast ratio in design/colors.md, recomputed from tokens.css
+scripts/verify-design.sh # tokens.css = DesignTokens.swift = color sets, and no hex, px or raw system colors in app or web code
+```
+
+A failure means your change or `design/` is wrong. Fix one of them, never the check.
+
UI pull requests need **before and after screenshots in both light and dark**, and on both platforms if both are affected.
## AI-assisted contributions
diff --git a/README.md b/README.md
index d754b2c6..5640d3bb 100644
--- a/README.md
+++ b/README.md
@@ -8,15 +8,15 @@
-
-
-
+
+
+
## What it is
-CrocApp sends files, folders, and text between two devices anywhere in the world, encrypted end to end, using a short code phrase like `apple-house-car`. No account, no cloud upload, no shared network needed.
+CrocApp sends files, folders, and text between two devices anywhere in the world, end-to-end encrypted, using a short code phrase like `apple-house-car`. No account and no shared network needed.
It is a graphical front end for [croc](https://github.com/schollz/croc), the command-line file-transfer tool by [Zack Scholl](https://github.com/schollz). CrocApp embeds croc's Go library rather than shelling out to the binary, so **a code phrase from the croc CLI works in the app, and the reverse**.
@@ -38,7 +38,7 @@ The disk image is Developer ID signed, notarized, and updates itself through Spa
```mermaid
flowchart LR
S["Sender CrocApp or croc CLI"]
- R["Receiver CrocApp or croc CLI"]
+ R["Other device CrocApp or croc CLI"]
L(["Direct path same network"])
X(["Relay ciphertext only"])
@@ -51,9 +51,9 @@ flowchart LR
1. The sender picks files, a folder, or text.
2. CrocApp shows a code phrase. Say it, message it, or show it as a QR code.
-3. The receiver enters the phrase, previews the file list, and accepts.
+3. The other device enters the phrase, previews the file list, and accepts.
-Both sides run PAKE over the code phrase to derive a session key, so the phrase never crosses the network in the clear and the relay never learns the key. CrocApp tries the local network and the relay at once; on the same network the transfer goes direct.
+Both devices run PAKE over the code phrase to derive a session key, so the phrase never crosses the network in the clear and the relay never learns the key. CrocApp tries the local network and the relay at once; on the same network the transfer goes direct.
## Screenshots
@@ -87,12 +87,12 @@ Both sides run PAKE over the code phrase to derive a session key, so the phrase
## Security and privacy
-Transfers are end-to-end encrypted, the code phrase authenticates both sides, and the relay only ever sees ciphertext.
+Transfers are end-to-end encrypted, the code phrase authenticates both devices (PAKE), and the relay only ever sees ciphertext.
> [!WARNING]
> Neither croc nor CrocApp has had a formal third-party security audit.
-- No telemetry, analytics, ads, or accounts. The [privacy policy](https://crocapp.dev/privacy/) lists every server the app can contact.
+- No accounts, analytics, ads, or tracking. The [privacy policy](https://crocapp.dev/privacy/) lists every server the app can contact.
- Transfer history stays on the device.
- You can use your own croc relay instead of the default public ones.
- Report vulnerabilities privately through [GitHub Security Advisories](https://github.com/bakirgdev/CrocApp/security/advisories/new), never in a public issue. See [`SECURITY.md`](SECURITY.md).
@@ -112,9 +112,9 @@ Transfers are end-to-end encrypted, the code phrase authenticates both sides, an
| Show QR on send, scan QR on receive | `--qrcode` |
| Choose output folder; Files-visible by default on iOS | `--out` |
| Keep Both or Replace when a file already exists; resume with Replace | `--rename`, `--overwrite` |
-| Preview the incoming file list, then accept or decline | interactive prompt |
+| Preview the file list, then accept or decline | interactive prompt |
| Live progress, speed, cancel; Live Activity on iOS | |
-| LAN and relay raced automatically | croc default |
+| Local network and relay raced automatically | croc default |
| Local transfer history | |
@@ -129,7 +129,7 @@ Transfers are end-to-end encrypted, the code phrase authenticates both sides, an
| Disable compression | `--no-compress` |
| Zip a folder before sending | `--zip` |
| Exclude patterns, respect `.gitignore` | `--exclude --git` |
-| Auto-accept incoming, off by default | `--yes` |
+| Auto-accept transfers, off by default | `--yes` |
| Require confirmation on both sides | `--ask` |
| Connection: Automatic or Relay only | `--transport auto\|relay` |
@@ -142,7 +142,7 @@ Transfers are end-to-end encrypted, the code phrase authenticates both sides, an
|---|---|
| SOCKS5 proxy | `--socks5` |
| HTTP proxy | `--connect` |
-| Upload speed limit | `--throttleUpload` |
+| Send speed limit | `--throttleUpload` |
| Encryption curve | `--curve` |
| File hash algorithm | `--hash` |
| Sender IP, direct connect | `--ip` |
@@ -161,9 +161,9 @@ Transfers are end-to-end encrypted, the code phrase authenticates both sides, an
| Menu bar quick send and Open at login (macOS) |
| Receive links: `crocapp://`, `crocapp.dev/r/` and `getcroc.com/?code=` open Receive with the code filled in |
| Shortcuts and Siri actions: Send Files, Send Text, Receive |
-| Trust UI: end-to-end badge, "how it works", active-relay indicator |
+| Trust UI: end-to-end badge, **How It Works**, active-relay indicator |
| Share Link and a QR code that opens CrocApp or a page that explains how to receive |
-| Send as link: encrypted upload for a receiver who is not online, with expiry and revoke |
+| Send as Link: files encrypted on your device, for someone who is not online, with expiry and revoke |
| iPad File menu: Show Receive Folder in Files |
@@ -201,7 +201,7 @@ Free and MIT-licensed, and it stays that way. Donations are welcome but optional
Will there be a Windows, Linux, or Android version?
-Not plannet, yet. If you really want to se CrocApp be born on other platforms, you can show your interest via [issues](https://github.com/bakirgdev/CrocApp/issues)
+Not planned yet. If you want to see CrocApp on other platforms, show your interest in an [issue](https://github.com/bakirgdev/CrocApp/issues).
@@ -221,4 +221,4 @@ Starring the repo, reporting bugs, and telling others about CrocApp help just as
## Credits and license
- [croc](https://github.com/schollz/croc) by [Zack Scholl](https://github.com/schollz), the transfer engine that makes this possible. MIT.
-- CrocApp by [Bakir Gracic](https://github.com/bakirgdev). [MIT](LICENSE).
+- CrocApp by [Bakir Gracić](https://github.com/bakirgdev). [MIT](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
index c73a85bb..265bccee 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -52,9 +52,9 @@ Steps to check a release (notarization, build attestation, checksums, update sig
## What CrocApp claims
-Transfers are end-to-end encrypted, the code phrase authenticates both sides, and the relay only ever sees ciphertext. That is the whole claim.
+Transfers are end-to-end encrypted, the code phrase authenticates both devices (PAKE), and the relay only ever sees ciphertext. CrocApp makes no stronger claim.
> [!WARNING]
> There has been **no formal third-party security audit** of croc, and CrocApp does not claim one. Do not describe CrocApp as audited, certified, or zero-knowledge-verified.
-Supporting facts (no telemetry, no accounts, local history, your own relay, dependency scanning) are on the [security and privacy page](https://crocapp.dev/docs/security-and-privacy/).
+Supporting facts (no accounts, analytics, ads or tracking, history that stays on the device, your own relay, dependency scanning) are on the [security and privacy page](https://crocapp.dev/docs/security-and-privacy/).
diff --git a/app/CrocApp.xcodeproj/project.pbxproj b/app/CrocApp.xcodeproj/project.pbxproj
index c1be660e..8759c3da 100644
--- a/app/CrocApp.xcodeproj/project.pbxproj
+++ b/app/CrocApp.xcodeproj/project.pbxproj
@@ -558,7 +558,7 @@
"INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace[sdk=macosx*]" = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
- INFOPLIST_KEY_NSCameraUsageDescription = "CrocApp uses the camera to scan QR transfer codes.";
+ INFOPLIST_KEY_NSCameraUsageDescription = "CrocApp uses the camera to scan the QR code for a code phrase.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "CrocApp uses the local network to discover nearby devices for direct, faster transfers.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -614,7 +614,7 @@
"INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace[sdk=macosx*]" = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
- INFOPLIST_KEY_NSCameraUsageDescription = "CrocApp uses the camera to scan QR transfer codes.";
+ INFOPLIST_KEY_NSCameraUsageDescription = "CrocApp uses the camera to scan the QR code for a code phrase.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "CrocApp uses the local network to discover nearby devices for direct, faster transfers.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -718,7 +718,7 @@
INFOPLIST_FILE = "Config/CrocApp-Direct-Info.plist";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
- INFOPLIST_KEY_NSCameraUsageDescription = "CrocApp uses the camera to scan QR transfer codes.";
+ INFOPLIST_KEY_NSCameraUsageDescription = "CrocApp uses the camera to scan the QR code for a code phrase.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "CrocApp uses the local network to discover nearby devices for direct, faster transfers.";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.0;
@@ -756,7 +756,7 @@
INFOPLIST_FILE = "Config/CrocApp-Direct-Info.plist";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
- INFOPLIST_KEY_NSCameraUsageDescription = "CrocApp uses the camera to scan QR transfer codes.";
+ INFOPLIST_KEY_NSCameraUsageDescription = "CrocApp uses the camera to scan the QR code for a code phrase.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "CrocApp uses the local network to discover nearby devices for direct, faster transfers.";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.0;
diff --git a/app/CrocApp/Assets.xcassets/ColorAccentTint.colorset/Contents.json b/app/CrocApp/Assets.xcassets/ColorAccentTint.colorset/Contents.json
new file mode 100644
index 00000000..d644818d
--- /dev/null
+++ b/app/CrocApp/Assets.xcassets/ColorAccentTint.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.120",
+ "blue" : "0.416",
+ "green" : "0.620",
+ "red" : "0.118"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.160",
+ "blue" : "0.522",
+ "green" : "0.773",
+ "red" : "0.176"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/app/CrocApp/ContentView.swift b/app/CrocApp/ContentView.swift
index 7c553f4f..51399ebd 100644
--- a/app/CrocApp/ContentView.swift
+++ b/app/CrocApp/ContentView.swift
@@ -11,6 +11,9 @@ struct ContentView: View {
@State private var showOnboarding = false
#if os(macOS)
@State private var isDropTargeted = false
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ // components.md → DropZone: overlay glyph at 44, scaled with Dynamic Type.
+ @ScaledMetric(relativeTo: .largeTitle) private var dropGlyphSize: CGFloat = 44
#endif
var body: some View {
@@ -33,7 +36,14 @@ struct ContentView: View {
dropOverlay
}
}
+ // The overlay is inserted and removed, so its `.transition` needs an
+ // animation context on the container that stays.
+ .animation(
+ reduceMotion ? nil : Motion.easeOut(Motion.durationFast), value: isDropTargeted)
#endif
+ .phoneHaptic(
+ trigger: TransferHaptic(controller.phase, endedByUser: controller.endedByUser)
+ ) { _, new in new?.feedback }
.task {
if AutoVerify.forcesOnboarding || (!onboardingSeen && !AutoVerify.isHarnessRun) {
showOnboarding = true
@@ -118,26 +128,24 @@ struct ContentView: View {
}
#if os(macOS)
- // design/components.md → DropZone: macOS full-window "Drop to send"
- // overlay, scrim background, --radius-2xl (the one place that radius is
- // used) and a glass surface. Not itself a drop target — the window-level
- // dropDestination above owns hover tracking and the actual file filter.
+ // design/components.md → DropZone: macOS full-window overlay, scrim
+ // background and a glass card at --radius-2xl. Not itself a drop target:
+ // the window-level dropDestination above owns hover tracking and the
+ // actual file filter.
private var dropOverlay: some View {
ZStack {
Color.scrim
VStack(spacing: Spacing.space4) {
Image(systemName: "square.and.arrow.up")
- .font(.system(size: 44))
+ .font(.system(size: dropGlyphSize))
.foregroundStyle(Color.accentTextOnTint)
.accessibilityHidden(true)
- Text("Drop to send")
+ Text("Drop to Send")
.font(.title2.weight(.semibold))
.foregroundStyle(Color.accentTextOnTint)
}
.padding(Spacing.space9)
- .glassEffect(
- .regular, in: RoundedRectangle(cornerRadius: Radius.xxl, style: .continuous)
- )
+ .glassEffect(.regular, in: .rect(cornerRadius: Radius.xxl, style: .continuous))
}
.ignoresSafeArea()
.allowsHitTesting(false)
diff --git a/app/CrocApp/Models/TransferController.swift b/app/CrocApp/Models/TransferController.swift
index a3356cb8..d7de4dac 100644
--- a/app/CrocApp/Models/TransferController.swift
+++ b/app/CrocApp/Models/TransferController.swift
@@ -93,6 +93,10 @@ final class TransferController {
private var scopedURLs: [URL] = []
private var cancelRequested = false
private var declineRequested = false
+ /// The current transfer ended because the user tapped Cancel or Decline.
+ /// A background-expiry cancel does not count. Haptics read this so a
+ /// button press never fires the failure haptic.
+ var endedByUser: Bool { (cancelRequested && !backgroundExpired) || declineRequested }
private var receivedText: String?
private var outDir: URL?
private var lastProgressBytes: Int64 = 0
@@ -370,11 +374,9 @@ final class TransferController {
private func run(_ start: @escaping () async throws -> EngineTransfer) {
phase = .starting
let title =
- switch (direction, isStoredTransfer) {
- case (.send, false): "Sending with CrocApp"
- case (.send, true): "Uploading with CrocApp"
- case (.receive, false): "Receiving with CrocApp"
- case (.receive, true): "Downloading with CrocApp"
+ switch direction {
+ case .send: "Sending with CrocApp"
+ case .receive: "Receiving with CrocApp"
}
background.transferStarted(title: title) { [weak self] in
guard let self else { return }
@@ -542,7 +544,7 @@ final class TransferController {
// declineRequested cannot be set, and the cancel paths are
// excluded above -- what is left can only be this.
phase = .failed(
- "The sender asked to confirm this transfer manually, which auto-accept can't answer. Turn off auto-accept in Settings and try again, or ask the sender to resend without their confirmation option."
+ "The sender asked to confirm this transfer manually, which auto-accept can't answer. Turn off Auto-Accept Transfers in Settings and try again, or ask the sender to resend without their confirmation option."
)
} else {
var copy = Self.friendlyMessage(
@@ -650,9 +652,9 @@ final class TransferController {
private var backgroundExpiredCopy: String {
switch (isStoredTransfer, direction) {
case (false, _): "iOS paused the transfer in the background." + Self.resumeHint
- case (true, .send): "Upload interrupted: iOS stopped it in the background. Try again."
+ case (true, .send): "Sending was interrupted: iOS stopped it in the background. Try again."
case (true, .receive):
- "Download interrupted: iOS stopped it in the background. Open the link again."
+ "Receiving was interrupted: iOS stopped it in the background. Open the link again."
}
}
@@ -671,7 +673,7 @@ final class TransferController {
if declineRequested { return "You declined the transfer." }
if stored, failure.code == .relayUnreachable || failure.code == .connectionLost {
return
- "Couldn't reach the transfer server. Check your internet connection and try again."
+ "Couldn't reach the link server. Check your internet connection and try again."
}
// Never echo failure.message: croc adds new error text every release,
// and a recovered Go panic arrives with a full stack trace attached.
@@ -682,9 +684,9 @@ final class TransferController {
return "Couldn't reach the relay. Check your internet connection."
case .noPublicRelay:
return
- "No public relay answered. Check your internet connection, or turn on Local network only in Settings if both devices are on the same network."
+ "No public relay answered. Check your internet connection, or turn on Local Network Only in Settings if both devices are on the same network."
case .declined:
- return "The other side declined or cancelled the transfer."
+ return "The other device declined or cancelled the transfer."
case .refused:
return "You declined the transfer."
case .roomFull:
@@ -699,12 +701,12 @@ final class TransferController {
return "Lost connection to the other device."
case .storeLimit:
return
- "The transfer server refused this upload: it is over the server's size, file count or download limit, or too many uploads were made recently."
+ "The link server refused this send: it is over the server's size, file count or download limit, or too many sends were made recently."
case .storeExpired:
return "This link has expired, was revoked, or has no downloads left."
case .invalidSetting:
return
- "An Advanced setting can't be used. Check the proxies, addresses, ports and upload speed limit in Settings > Advanced."
+ "An Advanced setting can't be used. Check the proxies, addresses, ports and send speed limit in Settings > Advanced."
case .other:
return "Transfer failed. Try again, or check your connection."
}
diff --git a/app/CrocApp/Support/DesignTokens.swift b/app/CrocApp/Support/DesignTokens.swift
index a7050955..1411e7e8 100644
--- a/app/CrocApp/Support/DesignTokens.swift
+++ b/app/CrocApp/Support/DesignTokens.swift
@@ -7,8 +7,7 @@ import AppKit
#endif
// This is the one file in the app that splits on `#if os()` for
-// UIKit/AppKit-backed colors — see design/swiftui-mapping.md, "That split
-// already exists in the app; keep it in one place."
+// UIKit/AppKit-backed colors (design/swiftui.md → Colors).
/// 4pt spacing grid. design/spacing-layout.md → Spacing scale.
enum Spacing {
@@ -26,8 +25,7 @@ enum Spacing {
}
/// Concentric radii. design/spacing-layout.md → Radii. Always pair with
-/// `style: .continuous` — "the default circular style visibly mismatches
-/// system chrome" (swiftui-mapping.md).
+/// `style: .continuous` (design/swiftui.md → Spacing, shape, layout).
enum Radius {
static let xs: CGFloat = 6
static let sm: CGFloat = 10
@@ -60,27 +58,34 @@ enum IconSize {
static let large: CGFloat = 28
}
-/// design/spacing-layout.md → Layout caps.
+/// design/spacing-layout.md → Layout cap.
enum LayoutCap {
static let contentMaxWidth: CGFloat = 480
- static let sheetMaxWidth: CGFloat = 540
}
-/// Constants shared by more than one component. design/spacing-layout.md →
-/// "Row heights that are not tokenized (they are component constants, see
-/// components.md)". Anything used by only one component stays a private
-/// constant inside that component's file instead (components.md's own rule).
+/// design/components.md → States: `--opacity-disabled-container`.
+enum Opacity {
+ static let disabledContainer: Double = 0.5
+}
+
+/// design/typography.md → Tracking: `--tracking-code`, `--tracking-eyebrow`.
+enum Tracking {
+ static let code: CGFloat = 0.5
+ static let eyebrow: CGFloat = 0.6
+}
+
+/// Constants shared by more than one component (design/swiftui.md → Spacing,
+/// shape, layout). Anything used by only one component stays a private
+/// constant inside that component's file instead.
enum ComponentMetrics {
- static let settingsRowMinHeight: CGFloat = 52
static let fileRowMinHeight: CGFloat = 48
- static let sheetHeaderMinHeight: CGFloat = 52
- static let switchSize = CGSize(width: 51, height: 31)
- static let settingsRowIconTile: CGFloat = 29
- static let settingsRowIconTileRadius: CGFloat = 7
static let statusBlockCircle: CGFloat = 64
static let statusBlockGlyph: CGFloat = 34
static let trustBadgeFullCircle: CGFloat = 34
static let qrFrameDefault: CGFloat = 168
+ /// Status border alpha for StatusBanner and TrustBadge `full`
+ /// (components.md → StatusBanner: 22% of the kind color).
+ static let statusBorderOpacity: Double = 0.22
}
/// design/motion.md → Motion. Durations in seconds for `Animation`.
@@ -88,7 +93,16 @@ enum Motion {
static let durationFast: Double = 0.12
static let durationBase: Double = 0.22
static let durationSlow: Double = 0.36
- static let pressScale: CGFloat = 0.97
+
+ /// `--ease-out` (motion.md): entrances, cross-fades, progress fills.
+ static func easeOut(_ duration: Double) -> Animation {
+ .timingCurve(0.16, 1, 0.3, 1, duration: duration)
+ }
+
+ /// `--ease-standard` (motion.md): color and background changes.
+ static func easeStandard(_ duration: Double) -> Animation {
+ .timingCurve(0.4, 0, 0.2, 1, duration: duration)
+ }
}
/// design/materials.md → Elevation. Geometry is theme-independent
@@ -134,17 +148,21 @@ extension View {
}
extension Color {
- // MARK: - Accent (design/colors.md → Semantic aliases; design/swiftui-mapping.md → Colors)
+ // MARK: - Accent (design/colors.md → Semantic aliases; design/swiftui.md → Colors)
/// Accent as a fill/icon/border/progress color — routes to the
/// `AccentColor` asset set so `.tint()` / `Color.accentColor` pick it up
- /// for free. "`Color.accentColor` is a fill, not a label color."
- /// (swiftui-mapping.md) — never use this for a word, see `accentText`.
+ /// for free. A fill, not a label color (colors.md → Contrast, rule 1):
+ /// never use this for a word, see `accentText`.
static var accentFill: Color { .accentColor }
/// Pressed state for custom button styles built on the accent fill.
static let accentPressed = Color("ColorAccentPressed")
+ /// `--color-accent-tint`: the drag-over and secondary-button fill. Alpha
+ /// is baked into the set (12% light / 16% dark).
+ static let accentTint = Color("ColorAccentTint")
+
/// Accent as *text*. Accent-as-text is a different value from
/// accent-as-fill: the brand green is 3.41:1 on white as a fill, which
/// clears the 3:1 UI floor but not the 4.5:1 body-text floor, so text
@@ -159,20 +177,21 @@ extension Color {
/// Label drawn on top of a solid accent fill (e.g. a prominent button).
static let onAccent = Color("ColorOnAccent")
- // MARK: - Text (design/swiftui-mapping.md → Colors)
+ /// `--color-on-status`: label on a solid status fill, white in both themes.
+ static let onStatus = Color.white
+
+ // MARK: - Text (design/swiftui.md → Colors)
- /// "prefer the system semantics over custom sets" for text
- /// primary/secondary/tertiary.
+ /// System semantics for text primary/secondary/tertiary.
static var textPrimary: Color { .primary }
- /// "`.secondary` is `--label-2` (0.60), not the web alias — that is
- /// deliberate. The app stays visually native; only the web needs the
- /// 0.75 correction." (swiftui-mapping.md, see colors.md rule 4.)
+ /// `.secondary` is `--label-2` (0.60), not the web alias: deliberate, the
+ /// app stays native and only the web needs the 0.75 correction
+ /// (colors.md → Contrast, rule 4).
static var textSecondary: Color { .secondary }
- /// Tertiary is decorative-only, never text: "`--color-text-tertiary` is
- /// not a text color and not a UI-boundary color. 1.74:1. Decorative
- /// fills only." (colors.md rule 5.)
+ /// Tertiary is decorative-only: not a text color and not a UI-boundary
+ /// color, 1.73:1 (colors.md → Contrast, rule 5).
static var textTertiary: Color {
#if os(iOS)
Color(uiColor: .tertiaryLabel)
@@ -181,11 +200,11 @@ extension Color {
#endif
}
- // MARK: - Status (design/swiftui-mapping.md → Colors)
+ // MARK: - Status (design/swiftui.md → Colors)
- /// "system semantics already match the token values" for the four
- /// status colors. A status color is a fill/glyph only, never a
- /// foreground for text (colors.md rule 2) — see the `*Tint` colors below
+ /// System semantics match the token values for the four status colors.
+ /// A status color is a fill/glyph only, never a foreground for text
+ /// (colors.md → Contrast, rule 2); see the `*Tint` colors below
/// for banner/badge backgrounds, and `Color.textPrimary` for their labels.
static var statusSuccess: Color { .green }
static var statusWarning: Color { .orange }
@@ -200,9 +219,9 @@ extension Color {
static let statusErrorTint = Color("ColorStatusErrorTint")
static let statusInfoTint = Color("ColorStatusInfoTint")
- // MARK: - Surfaces (design/swiftui-mapping.md → Colors)
+ // MARK: - Surfaces (design/swiftui.md → Colors)
- /// "`Color(.systemGroupedBackground)` (iOS); on macOS use `.windowBackground`".
+ /// `--color-surface-grouped`: iOS grouped background, macOS window background.
static var surfaceGrouped: Color {
#if os(iOS)
Color(uiColor: .systemGroupedBackground)
@@ -212,13 +231,9 @@ extension Color {
}
/// `--color-surface-base` (colors.md → Semantic aliases): the plain,
- /// unelevated page fill beneath grouped/card surfaces. Not in
- /// swiftui-mapping.md's Colors table (a gap in that doc); iOS takes the
- /// literal match, `.systemBackground`. macOS has no equivalent "base"
- /// surface distinct from the window background, so this takes the
- /// closest system semantic for a bordered control's own fill —
- /// `.textBackgroundColor` — which is what CodeField (today's only
- /// consumer) needs it for.
+ /// unelevated page fill. iOS takes `.systemBackground`; macOS has no
+ /// "base" surface distinct from the window background, so it takes
+ /// `.textBackgroundColor`, the fill of a bordered control (CodeField).
static var surfaceBase: Color {
#if os(iOS)
Color(uiColor: .systemBackground)
@@ -227,12 +242,9 @@ extension Color {
#endif
}
- /// "`Color(.secondarySystemGroupedBackground)` (iOS); macOS
- /// `.background(.regularMaterial)` or a color set" — this file exposes a
- /// plain `Color`, not a material, so macOS takes the closest solid
- /// analog (`controlBackgroundColor`, content resting above the window
- /// background); a view that wants the actual glass card uses
- /// `.background(.regularMaterial)` directly instead of this token.
+ /// `--color-surface-card`: iOS `.secondarySystemGroupedBackground`; macOS
+ /// takes the closest solid analog, `controlBackgroundColor`. A view that
+ /// wants a glass card uses `.glassEffect()` instead of this token.
static var surfaceCard: Color {
#if os(iOS)
Color(uiColor: .secondarySystemGroupedBackground)
@@ -241,7 +253,7 @@ extension Color {
#endif
}
- /// "`Color(.separator)` / the `Divider` default".
+ /// `--color-separator`: the system separator.
static var separatorToken: Color {
#if os(iOS)
Color(uiColor: .separator)
@@ -260,8 +272,7 @@ extension Color {
}
/// The sender's code-phrase display: the one documented literal font size
-/// in the type scale (design/swiftui-mapping.md → Typography, "the one
-/// place a literal size is acceptable, and it must still scale"). Wrapped in
+/// in the type scale (design/swiftui.md → Typography). Wrapped in
/// `@ScaledMetric` so it keeps tracking Dynamic Type despite the literal.
private struct CodeHeroTextStyle: ViewModifier {
@ScaledMetric(relativeTo: .title) private var size: CGFloat = 30
diff --git a/app/CrocApp/Support/Inflected.swift b/app/CrocApp/Support/Inflected.swift
new file mode 100644
index 00000000..a582387e
--- /dev/null
+++ b/app/CrocApp/Support/Inflected.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+/// Resolves automatic grammar agreement (`^[3 file](inflect: true)`) into a
+/// plain `String`. `String(localized:)` leaves the markup in place;
+/// `AttributedString(localized:)` applies it.
+func inflected(_ text: AttributedString) -> String {
+ String(text.characters)
+}
diff --git a/app/CrocApp/Views/AdvancedSettingsView.swift b/app/CrocApp/Views/AdvancedSettingsView.swift
index aa24c27a..6b0d8f6d 100644
--- a/app/CrocApp/Views/AdvancedSettingsView.swift
+++ b/app/CrocApp/Views/AdvancedSettingsView.swift
@@ -35,7 +35,7 @@ struct AdvancedSettingsView: View {
footnote:
"Applies when sending. The receiving device uses the sender's choice, so the other device does not need to match. Default: xxHash."
) {
- Picker("File hash", selection: $settings.hashAlgorithm) {
+ Picker("File Hash", selection: $settings.hashAlgorithm) {
ForEach(HashAlgorithm.allCases, id: \.self) { hash in
Text(hash.title).tag(hash)
}
@@ -46,12 +46,12 @@ struct AdvancedSettingsView: View {
Section("Network") {
AdvancedRow(
footnote:
- "Applies to this device only. Takes priority over the HTTP proxy if both are set. Connections on your local network skip the proxy. Also used for Send as link. Saved in the Keychain.",
+ "Applies to this device only. Takes priority over the HTTP proxy if both are set. Connections on your local network skip the proxy. Also used for Send as Link. Saved in the Keychain.",
error: AppSettings.socks5ProxyError(settings.socks5Proxy)
) {
- LabeledField("SOCKS5 proxy") {
+ LabeledField("SOCKS5 Proxy") {
TextField(
- "SOCKS5 proxy", text: $settings.socks5Proxy,
+ "SOCKS5 Proxy", text: $settings.socks5Proxy,
prompt: Text("None, or host:port")
)
.urlEntry()
@@ -59,12 +59,12 @@ struct AdvancedSettingsView: View {
}
AdvancedRow(
footnote:
- "Applies to this device only. Ignored when a SOCKS5 proxy is set. Connections on your local network skip the proxy. Also used for Send as link. Saved in the Keychain.",
+ "Applies to this device only. Ignored when a SOCKS5 proxy is set. Connections on your local network skip the proxy. Also used for Send as Link. Saved in the Keychain.",
error: AppSettings.httpProxyError(settings.httpProxy)
) {
- LabeledField("HTTP proxy") {
+ LabeledField("HTTP Proxy") {
TextField(
- "HTTP proxy", text: $settings.httpProxy,
+ "HTTP Proxy", text: $settings.httpProxy,
prompt: Text("None, or host:port")
)
.urlEntry()
@@ -74,7 +74,7 @@ struct AdvancedSettingsView: View {
footnote:
"Looks up relay names with croc's own resolver instead of the system's. Off by default. Applies to this device only."
) {
- Toggle("Built-in DNS resolver", isOn: $settings.internalDNS)
+ Toggle("Built-in DNS Resolver", isOn: $settings.internalDNS)
}
#if os(macOS)
AdvancedRow(
@@ -82,9 +82,9 @@ struct AdvancedSettingsView: View {
"Applies to both devices. They find each other on the local network only if both use the same address. Blank uses \(AppSettings.advancedDefaults.multicastAddress).",
error: AppSettings.multicastAddressError(settings.multicastAddress)
) {
- LabeledField("Multicast address") {
+ LabeledField("Multicast Address") {
TextField(
- "Multicast address", text: $settings.multicastAddress,
+ "Multicast Address", text: $settings.multicastAddress,
prompt: Text(AppSettings.advancedDefaults.multicastAddress)
)
.addressEntry()
@@ -112,9 +112,9 @@ struct AdvancedSettingsView: View {
"Applies when sending. The most this device sends per second, such as 500k, 2m or 1g. The other device is not affected.",
error: AppSettings.throttleError(settings.throttleUpload)
) {
- LabeledField("Upload speed limit") {
+ LabeledField("Send Speed Limit") {
TextField(
- "Upload speed limit", text: $settings.throttleUpload,
+ "Send Speed Limit", text: $settings.throttleUpload,
prompt: Text("No limit")
)
.addressEntry()
@@ -125,9 +125,9 @@ struct AdvancedSettingsView: View {
"Applies when sending. The first port of this device's local relay. A device that receives by sender IP must use this port. Default \(AppSettings.advancedDefaults.basePort).",
error: basePortError
) {
- LabeledField("Base port") {
+ LabeledField("Base Port") {
TextField(
- "Base port", value: $settings.basePort,
+ "Base Port", value: $settings.basePort,
format: .number.grouping(.never)
)
.numberEntry()
@@ -139,9 +139,9 @@ struct AdvancedSettingsView: View {
"Applies when sending. How many extra ports follow the base port, one per connection. Default \(AppSettings.advancedDefaults.transfers).",
error: transfersError
) {
- LabeledField("Parallel connections") {
+ LabeledField("Parallel Connections") {
TextField(
- "Parallel connections", value: $settings.transfers,
+ "Parallel Connections", value: $settings.transfers,
format: .number.grouping(.never)
)
.numberEntry()
@@ -158,6 +158,7 @@ struct AdvancedSettingsView: View {
Section {
Button("Reset to Defaults") { confirmReset = true }
+ .buttonVariant(.plain)
.disabled(settings.advancedIsDefault)
} footer: {
Text(
diff --git a/app/CrocApp/Views/ButtonVariants.swift b/app/CrocApp/Views/ButtonVariants.swift
new file mode 100644
index 00000000..45fddc38
--- /dev/null
+++ b/app/CrocApp/Views/ButtonVariants.swift
@@ -0,0 +1,93 @@
+import SwiftUI
+
+/// The five button variants. design/components.md → Button; the SwiftUI
+/// translation is design/swiftui.md → Components. Views never style a button
+/// ad hoc: they pick a variant.
+enum ButtonVariant {
+ case prominent
+ case secondary
+ case destructive
+ case destructiveTinted
+ case plain
+}
+
+/// A `PrimitiveButtonStyle` rather than a bare modifier chain: the label color
+/// has to sit on the label content itself, inside the system style, or the
+/// style repaints it with the global tint (`Color.accentFill`, 3.41:1 as text).
+private struct ButtonVariantStyle: PrimitiveButtonStyle {
+ let variant: ButtonVariant
+ @Environment(\.isEnabled) private var isEnabled
+ #if os(macOS)
+ @Environment(\.appearsActive) private var appearsActive
+ #endif
+
+ func makeBody(configuration: Configuration) -> some View {
+ let button = Button(role: configuration.role, action: configuration.trigger) {
+ labelContent(configuration.label)
+ }
+ .buttonBorderShape(.capsule)
+
+ switch variant {
+ case .prominent:
+ button.buttonStyle(.borderedProminent).tint(Color.accentFill)
+ case .secondary:
+ button.buttonStyle(.bordered).tint(Color.accentFill)
+ case .destructive:
+ button.buttonStyle(.borderedProminent).tint(Color.statusError)
+ case .destructiveTinted:
+ button.buttonStyle(.bordered).tint(Color.statusError)
+ case .plain:
+ button.buttonStyle(.borderless).tint(Color.accentFill)
+ }
+ }
+
+ /// The pinned colors assume the enabled, active fill. A disabled button or
+ /// an inactive macOS window gets a system fill, so the system picks the label.
+ private var usesVariantLabelColor: Bool {
+ #if os(macOS)
+ isEnabled && appearsActive
+ #else
+ isEnabled
+ #endif
+ }
+
+ /// Every variant is semibold (design/components.md → Button); the system
+ /// bordered styles default to regular.
+ @ViewBuilder
+ private func labelContent(_ label: Configuration.Label) -> some View {
+ if usesVariantLabelColor {
+ label.fontWeight(.semibold).foregroundStyle(labelColor)
+ } else {
+ label.fontWeight(.semibold)
+ }
+ }
+
+ private var labelColor: Color {
+ switch variant {
+ case .prominent: Color.onAccent
+ case .secondary: Color.accentTextOnTint
+ case .destructive: Color.onStatus
+ case .destructiveTinted: Color.textPrimary
+ case .plain: Color.accentTextOnTint
+ }
+ }
+}
+
+extension View {
+ /// Applies a button variant. Large primary actions add `.controlSize(.large)`.
+ func buttonVariant(_ variant: ButtonVariant) -> some View {
+ buttonStyle(ButtonVariantStyle(variant: variant))
+ }
+}
+
+#Preview("Button variants") {
+ VStack(spacing: Spacing.space4) {
+ Button("Accept") {}.buttonVariant(.prominent).controlSize(.large)
+ Button("Copy Code") {}.buttonVariant(.secondary)
+ Button("Decline") {}.buttonVariant(.destructive)
+ Button("Clear History") {}.buttonVariant(.destructiveTinted)
+ Button("Review") {}.buttonVariant(.plain)
+ Button("Disabled") {}.buttonVariant(.prominent).disabled(true)
+ }
+ .padding(Spacing.space5)
+}
diff --git a/app/CrocApp/Views/CodeField.swift b/app/CrocApp/Views/CodeField.swift
index 9a5ab003..92cff1ca 100644
--- a/app/CrocApp/Views/CodeField.swift
+++ b/app/CrocApp/Views/CodeField.swift
@@ -1,7 +1,7 @@
import SwiftUI
-/// Monospaced entry for the `word-word-word` transfer code.
-/// design/components.md → CodeField, lines 82-91.
+/// Monospaced entry for the `word-word-word` code phrase.
+/// design/components.md → CodeField.
///
/// The paste affordance is part of this component's own spec (the wrapper's
/// asymmetric padding — 14 leading for text, 6 trailing — exists to seat a
@@ -26,19 +26,17 @@ struct CodeField: View {
@FocusState private var isFocused: Bool
- // components.md line 86: wrapper padding is `0 6px 0 14px`, not a
- // Spacing step — single-component constant per DesignTokens.swift's own
- // rule ("Anything used by only one component stays a private constant").
+ // components.md → CodeField: wrapper padding is `0 6px 0 14px`, not a
+ // Spacing step, so it is a private constant of this component.
private static let leadingPadding: CGFloat = 14
private static let trailingPadding: CGFloat = 6
- private static let codeTracking: CGFloat = 0.5 // --tracking-code, typography.md
var body: some View {
VStack(alignment: .leading, spacing: Spacing.space2) {
field
if isInvalid, let invalidMessage {
Text(invalidMessage)
- .font(.caption)
+ .font(.footnote)
.foregroundStyle(Color.textSecondary)
}
}
@@ -48,8 +46,8 @@ struct CodeField: View {
HStack(spacing: Spacing.space2) {
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
- .font(.body.monospaced()) // --type-code-body, swiftui-mapping.md → Typography
- .tracking(Self.codeTracking)
+ .font(.subheadline.monospaced())
+ .tracking(Tracking.code)
.focused($isFocused)
.accessibilityHint(isInvalid ? (invalidMessage ?? "") : "")
.autocorrectionDisabled()
@@ -78,10 +76,10 @@ struct CodeField: View {
.strokeBorder(borderColor, lineWidth: BorderWidth.hairline)
)
.disabled(isDisabled)
- .opacity(isDisabled ? 0.5 : 1)
+ .opacity(isDisabled ? Opacity.disabledContainer : 1)
}
- // components.md line 86: separator idle → accent focused → error invalid.
+ // components.md → CodeField: separator idle, accent focused, error invalid.
private var borderColor: Color {
if isInvalid { return .statusError }
if isFocused { return .accentFill }
diff --git a/app/CrocApp/Views/CodePhraseDisplay.swift b/app/CrocApp/Views/CodePhraseDisplay.swift
new file mode 100644
index 00000000..f1df5711
--- /dev/null
+++ b/app/CrocApp/Views/CodePhraseDisplay.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+/// The sender's code phrase: eyebrow, the code, and the Copy button.
+/// design/components.md → CodePhraseDisplay and QRFrame (steps 1 to 3; the QR
+/// frame and caption stay with the caller). `compact` is the 300-wide menu bar
+/// column, which shows the code at `--type-title3`.
+struct CodePhraseDisplay: View {
+ let code: String
+ var compact = false
+
+ @State private var copied = false
+
+ // components.md → CodePhraseDisplay: the code sits on a glass pill; its
+ // padding is the pill's own, not a grid step.
+ private static let pillPaddingInline = Spacing.space6
+ private static let copiedDuration: Duration = .seconds(2)
+
+ var body: some View {
+ VStack(spacing: compact ? Spacing.space3 : Spacing.space5) {
+ Text("Ready to send")
+ .textCase(.uppercase)
+ .font(.footnote.weight(.semibold))
+ .tracking(Tracking.eyebrow)
+ .foregroundStyle(Color.textSecondary)
+ codeText
+ Button {
+ Clipboard.copy(code)
+ copied = true
+ } label: {
+ Label(
+ copied ? "Copied" : "Copy Code",
+ systemImage: copied ? "checkmark" : "doc.on.doc")
+ }
+ .buttonVariant(.secondary)
+ .task(id: copied) {
+ guard copied else { return }
+ try? await Task.sleep(for: Self.copiedDuration)
+ guard !Task.isCancelled else { return }
+ copied = false
+ }
+ .phoneHaptic(trigger: copied) { _, new in new ? .selection : nil }
+ }
+ }
+
+ @ViewBuilder private var codeText: some View {
+ let text = Text(code)
+ .textSelection(.enabled)
+ .tracking(Tracking.code)
+ .multilineTextAlignment(.center)
+ .accessibilityLabel("Transfer code")
+ .accessibilityValue(code.replacingOccurrences(of: "-", with: ", "))
+ if compact {
+ text.font(.title3.monospaced().weight(.semibold))
+ } else {
+ text
+ .codeHeroTextStyle()
+ .padding(.horizontal, Self.pillPaddingInline)
+ .padding(.vertical, Spacing.space4)
+ // A capsule clips the code once it wraps at large text sizes.
+ .glassEffect(.regular, in: .rect(cornerRadius: Radius.xl, style: .continuous))
+ }
+ }
+}
diff --git a/app/CrocApp/Views/DirectionHeader.swift b/app/CrocApp/Views/DirectionHeader.swift
new file mode 100644
index 00000000..fc714414
--- /dev/null
+++ b/app/CrocApp/Views/DirectionHeader.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+
+/// The arrow glyph and the word, which always travel together.
+/// design/components.md → TransferProgress, Direction header. A Send-as-link
+/// transfer says Sending and Receiving too.
+struct DirectionHeader: View {
+ let direction: TransferController.Direction
+
+ @ScaledMetric(relativeTo: .headline) private var glyphSize: CGFloat = IconSize.small
+
+ var body: some View {
+ HStack(spacing: Spacing.space3) {
+ Image(systemName: direction == .send ? "arrow.up" : "arrow.down")
+ .font(.system(size: glyphSize, weight: .semibold))
+ .foregroundStyle(Color.accentFill)
+ .accessibilityHidden(true)
+ Text(direction == .send ? "Sending" : "Receiving")
+ .font(.headline)
+ }
+ .accessibilityElement(children: .combine)
+ }
+}
diff --git a/app/CrocApp/Views/DropZone.swift b/app/CrocApp/Views/DropZone.swift
new file mode 100644
index 00000000..914b35e8
--- /dev/null
+++ b/app/CrocApp/Views/DropZone.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+
+/// The empty send target. design/components.md → DropZone. Drawn, because no
+/// system control does the job; it is never the only way in, so every use sits
+/// beside a real file-picker button. The drop destination itself stays with the
+/// caller, which passes the `isTargeted` state in.
+struct DropZone: View {
+ enum Size {
+ case regular
+ case compact
+ }
+
+ let title: LocalizedStringKey
+ var hint: LocalizedStringKey?
+ var size: Size = .regular
+ let isTargeted: Bool
+
+ // components.md → DropZone: min height 168 (menu bar 96), glyph 44
+ // (34 compact, iconography.md → Sizes in use), scaled with Dynamic Type.
+ static let minHeight: CGFloat = 168
+ private static let compactMinHeight: CGFloat = 96
+ private static let dashLength: CGFloat = 6
+ private static let dashGap: CGFloat = 4
+ private static let strokeWidth: CGFloat = 2
+ @ScaledMetric(relativeTo: .largeTitle) private var regularGlyph: CGFloat = 44
+ @ScaledMetric(relativeTo: .title) private var compactGlyph: CGFloat = 34
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ private var shape: RoundedRectangle {
+ RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
+ }
+
+ var body: some View {
+ VStack(spacing: Spacing.space4) {
+ Image(systemName: "square.and.arrow.up")
+ .font(.system(size: size == .regular ? regularGlyph : compactGlyph))
+ .foregroundStyle(isTargeted ? Color.accentTextOnTint : Color.textSecondary)
+ .accessibilityHidden(true)
+ Text(title)
+ .font(.headline)
+ .foregroundStyle(isTargeted ? Color.accentTextOnTint : Color.textPrimary)
+ if let hint {
+ Text(hint)
+ .font(.subheadline)
+ .foregroundStyle(Color.textSecondary)
+ }
+ }
+ .multilineTextAlignment(.center)
+ .padding(Spacing.space5)
+ .frame(
+ maxWidth: .infinity,
+ minHeight: size == .regular ? Self.minHeight : Self.compactMinHeight
+ )
+ // --color-fill-tertiary is the system fill ladder (swiftui.md → Not mirrored).
+ .background(
+ isTargeted ? AnyShapeStyle(Color.accentTint) : AnyShapeStyle(.fill.tertiary),
+ in: shape
+ )
+ .overlay {
+ if isTargeted {
+ shape.strokeBorder(Color.accentFill, lineWidth: Self.strokeWidth)
+ } else {
+ shape.strokeBorder(
+ Color.separatorToken,
+ style: StrokeStyle(
+ lineWidth: Self.strokeWidth, dash: [Self.dashLength, Self.dashGap]))
+ }
+ }
+ .animation(
+ reduceMotion ? nil : Motion.easeOut(Motion.durationFast), value: isTargeted
+ )
+ .accessibilityElement(children: .combine)
+ }
+}
+
+#Preview("Idle") {
+ DropZone(
+ title: "Drop files or folders here", hint: "or add them with the buttons below",
+ isTargeted: false
+ )
+ .padding(Spacing.space5)
+}
+
+#Preview("Drag over") {
+ DropZone(
+ title: "Drop files or folders here", hint: "or add them with the buttons below",
+ isTargeted: true
+ )
+ .padding(Spacing.space5)
+}
diff --git a/app/CrocApp/Views/FileRow.swift b/app/CrocApp/Views/FileRow.swift
index 3e7c223a..b03efd3d 100644
--- a/app/CrocApp/Views/FileRow.swift
+++ b/app/CrocApp/Views/FileRow.swift
@@ -1,29 +1,58 @@
import SwiftUI
import UniformTypeIdentifiers
-/// Send queue / incoming preview row. design/components.md → FileRow, lines
-/// 108-114. `isFirst`/`isLast` drive the same first/last corner-rounding and
-/// inter-row separator rules as SettingsRow, for a card of rows in a `List`.
+/// Send queue and accept-gate row. design/components.md → FileRow. `isFirst` /
+/// `isLast` drive the first/last corner rounding and the inter-row separator
+/// for a card of rows in a `List`. A row with no `onRemove` (the accept gate)
+/// draws no remove control.
struct FileRow: View {
- let url: URL
+ let name: String
+ /// Nil when the size is unknown or the row is a folder.
+ let size: Int64?
+ let symbolName: String
var isFirst: Bool = false
var isLast: Bool = false
- let onRemove: () -> Void
+ var onRemove: (() -> Void)?
- // components.md lines 112/114, iconography.md "Sizes in use": type icon
- // and remove control are both 20 — not one of IconSize's three steps, so
- // this stays a private constant per DesignTokens.swift's own rule.
- private static let glyphSize: CGFloat = 20
- /// Pinned tail length for the middle-truncation rule (components.md
- /// line 114: "the last 8 characters ... are pinned").
+ /// A staged local file: name, size and glyph come from the URL.
+ init(url: URL, isFirst: Bool = false, isLast: Bool = false, onRemove: @escaping () -> Void) {
+ let isFolder = url.hasDirectoryPath
+ self.name = url.lastPathComponent
+ self.size =
+ isFolder
+ ? nil
+ : (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init)
+ self.symbolName = Self.symbolName(extension: url.pathExtension, isFolder: isFolder)
+ self.isFirst = isFirst
+ self.isLast = isLast
+ self.onRemove = onRemove
+ }
+
+ /// A remote entry, as listed by the accept gate: name and size only.
+ init(name: String, size: Int64, isFirst: Bool = false, isLast: Bool = false) {
+ self.name = name
+ self.size = size
+ self.symbolName = Self.symbolName(
+ extension: URL(filePath: name).pathExtension, isFolder: false)
+ self.isFirst = isFirst
+ self.isLast = isLast
+ self.onRemove = nil
+ }
+
+ // components.md → FileRow: type glyph and remove control are both 20, not
+ // one of IconSize's three steps, so this stays a private constant scaled
+ // with Dynamic Type.
+ @ScaledMetric(relativeTo: .callout) private var glyphSize: CGFloat = 20
+ /// Pinned tail length for the middle-truncation rule (components.md →
+ /// FileRow: the last 8 characters stay visible).
private static let pinnedTailLength = 8
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
HStack(spacing: Spacing.space3) {
- Image(systemName: typeSymbolName)
- .font(.system(size: Self.glyphSize))
+ Image(systemName: symbolName)
+ .font(.system(size: glyphSize))
.foregroundStyle(Color.accentFill)
.accessibilityHidden(true)
@@ -32,7 +61,7 @@ struct FileRow: View {
// is wider than the row at these sizes: wrap the name and put
// the size under it.
VStack(alignment: .leading, spacing: Spacing.space2) {
- Text(url.lastPathComponent)
+ Text(name)
.font(.callout)
.foregroundStyle(Color.textPrimary)
.lineLimit(3)
@@ -42,7 +71,7 @@ struct FileRow: View {
.frame(maxWidth: .infinity, alignment: .leading)
} else {
filenameLabel
- .font(.callout) // typography.md: --type-callout is used for "file names"
+ .font(.callout)
.foregroundStyle(Color.textPrimary)
Spacer(minLength: Spacing.space3)
@@ -50,17 +79,21 @@ struct FileRow: View {
sizeLabel
}
- Button(action: onRemove) {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: Self.glyphSize))
- .foregroundStyle(Color.textSecondary)
- #if os(iOS)
- .frame(minWidth: ControlHeight.hitTargetMin, minHeight: ControlHeight.hitTargetMin)
- .contentShape(Rectangle())
- #endif
+ if let onRemove {
+ Button(action: onRemove) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: glyphSize))
+ .foregroundStyle(Color.textSecondary)
+ #if os(iOS)
+ .frame(
+ minWidth: ControlHeight.hitTargetMin, minHeight: ControlHeight.hitTargetMin
+ )
+ .contentShape(Rectangle())
+ #endif
+ }
+ .buttonVariant(.plain)
+ .accessibilityLabel("Remove \(name)")
}
- .buttonStyle(.plain)
- .accessibilityLabel("Remove \(url.lastPathComponent)")
}
.padding(.horizontal, Spacing.space4)
.padding(.vertical, Spacing.space3)
@@ -98,30 +131,26 @@ struct FileRow: View {
)
}
- // iconography.md → Mapping: folder/image/text/generic-doc rows.
- private var typeSymbolName: String {
- if url.hasDirectoryPath { return "folder" }
- guard let type = UTType(filenameExtension: url.pathExtension) else { return "doc" }
+ // design/iconography.md → Shared meanings: folder, image, text, generic file.
+ private static func symbolName(extension ext: String, isFolder: Bool) -> String {
+ if isFolder { return "folder" }
+ guard let type = UTType(filenameExtension: ext) else { return "doc" }
if type.conforms(to: .image) { return "photo" }
if type.conforms(to: .text) { return "doc.text" }
return "doc"
}
private var sizeText: String? {
- guard !url.hasDirectoryPath,
- let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize
- else { return nil }
- return Int64(size).formatted(.byteCount(style: .file))
+ size?.formatted(.byteCount(style: .file))
}
- // components.md line 114: last `pinnedTailLength` characters (extension
+ // components.md → FileRow: last `pinnedTailLength` characters (extension
// + tail) stay in a non-shrinking span so the extension is always
// legible; only the head ellipsises. No native SwiftUI truncation mode
// does this, so it's built from two `Text` views instead of relying on
// `.truncationMode(.middle)`.
@ViewBuilder
private var filenameLabel: some View {
- let name = url.lastPathComponent
if name.count <= Self.pinnedTailLength {
Text(name).lineLimit(1)
} else {
diff --git a/app/CrocApp/Views/ForwardProgressBar.swift b/app/CrocApp/Views/ForwardProgressBar.swift
new file mode 100644
index 00000000..7d4ad569
--- /dev/null
+++ b/app/CrocApp/Views/ForwardProgressBar.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+/// A determinate bar that animates at `--dur-base` `--ease-out` and never moves
+/// backwards. design/motion.md → Choreography. `resetKey` names what the bar
+/// measures (the current file, the total size): when it changes, the bar snaps
+/// to the new fraction and moves forward from there.
+struct ForwardProgressBar: View {
+ let fraction: Double
+ let resetKey: AnyHashable
+
+ @State private var shown: Double
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ init(fraction: Double, resetKey: AnyHashable) {
+ self.fraction = fraction
+ self.resetKey = resetKey
+ _shown = State(initialValue: fraction)
+ }
+
+ var body: some View {
+ ProgressView(value: shown)
+ .animation(reduceMotion ? nil : Motion.easeOut(Motion.durationBase), value: shown)
+ .onChange(of: fraction) { _, new in shown = max(shown, new) }
+ .onChange(of: resetKey) {
+ var transaction = Transaction()
+ transaction.disablesAnimations = true
+ withTransaction(transaction) { shown = fraction }
+ }
+ }
+}
diff --git a/app/CrocApp/Views/HistoryView.swift b/app/CrocApp/Views/HistoryView.swift
index f2a1bed1..b1480973 100644
--- a/app/CrocApp/Views/HistoryView.swift
+++ b/app/CrocApp/Views/HistoryView.swift
@@ -27,9 +27,9 @@ struct HistoryView: View {
} actions: {
HStack(spacing: Spacing.space4) {
Button("Send") { router.openSend(with: []) }
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
Button("Receive") { router.openReceive() }
- .buttonStyle(.bordered)
+ .buttonVariant(.secondary)
}
}
} else {
@@ -51,12 +51,13 @@ struct HistoryView: View {
.swipeActions(edge: .trailing) {
Button("Delete", role: .destructive) { history.delete(record) }
if controller.canRevoke(record) {
- Button("Revoke") { revokeCandidate = record }.tint(.orange)
+ Button("Revoke") { revokeCandidate = record }
+ .tint(Color.statusError)
}
}
.swipeActions(edge: .leading) {
if record.canResend {
- Button("Send Again") { resend(record) }.tint(.accentColor)
+ Button("Send Again") { resend(record) }.tint(Color.accentFill)
}
}
#endif
@@ -74,14 +75,16 @@ struct HistoryView: View {
.confirmationDialog("Clear all history?", isPresented: $confirmClear) {
Button("Clear All", role: .destructive) { history.clear() }
} message: {
- Text("Removes the list only — received files stay where they are.")
+ Text("Removes the list only. Received files stay where they are.")
}
.confirmationDialog(
"Revoke this link?", isPresented: revokeDialogShown, presenting: revokeCandidate
) { record in
Button("Revoke Link", role: .destructive) { revoke(record) }
} message: { _ in
- Text("The files are deleted from the server and the link stops working for everyone.")
+ Text(
+ "The files are deleted from the link server and the link stops working for everyone."
+ )
}
.alert("Couldn't revoke the link", isPresented: revokeFailureShown) {
Button("OK", role: .cancel) {}
@@ -134,6 +137,8 @@ struct HistoryView: View {
private struct HistoryRow: View {
let record: TransferRecord
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
+ // components.md → HistoryRow: direction glyph at --icon-size-md, scaled.
+ @ScaledMetric(relativeTo: .title3) private var glyphSize: CGFloat = IconSize.medium
private var isLargeText: Bool { dynamicTypeSize.isAccessibilitySize }
@@ -142,41 +147,41 @@ private struct HistoryRow: View {
if isLargeText {
// The glyph and badge sit above the text so it keeps the
// full row width instead of a few characters per line.
- VStack(alignment: .leading, spacing: 8) {
- HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: Spacing.space3) {
+ HStack(spacing: Spacing.space4) {
directionGlyph
statusBadge
}
textColumn
}
} else {
- HStack(spacing: 12) {
+ HStack(spacing: Spacing.space4) {
directionGlyph
textColumn
statusBadge
}
}
}
- .padding(.vertical, 2)
+ .padding(.vertical, Spacing.space1)
.accessibilityElement(children: .combine)
}
private var directionGlyph: some View {
Image(systemName: record.isSend ? "arrow.up.circle" : "arrow.down.circle")
- .font(.title3)
+ .font(.system(size: glyphSize))
.accessibilityLabel(record.isSend ? "Sent" : "Received")
- .foregroundStyle(record.status == .completed ? Color.accentColor : .secondary)
+ .foregroundStyle(record.status == .completed ? Color.accentFill : Color.textSecondary)
}
private var textColumn: some View {
- VStack(alignment: .leading, spacing: 2) {
+ VStack(alignment: .leading, spacing: Spacing.space1) {
Text(title)
.lineLimit(isLargeText ? 3 : 1)
.truncationMode(.middle)
let layout =
isLargeText
- ? AnyLayout(VStackLayout(alignment: .leading, spacing: 2))
- : AnyLayout(HStackLayout(spacing: 8))
+ ? AnyLayout(VStackLayout(alignment: .leading, spacing: Spacing.space1))
+ : AnyLayout(HStackLayout(spacing: Spacing.space3))
layout {
Text(record.date, format: .relative(presentation: .named))
if record.totalBytes > 0 {
@@ -221,13 +226,13 @@ private struct HistoryRow: View {
Group {
switch record.status {
case .completed:
- Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(Color.statusSuccess)
case .failed:
- Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
+ Image(systemName: "xmark.circle.fill").foregroundStyle(Color.statusError)
case .cancelled:
- Image(systemName: "slash.circle").foregroundStyle(.secondary)
+ Image(systemName: "slash.circle").foregroundStyle(Color.textSecondary)
case .declined:
- Image(systemName: "hand.raised.fill").foregroundStyle(.orange)
+ Image(systemName: "hand.raised.fill").foregroundStyle(Color.statusError)
}
}
.accessibilityLabel(record.status.title)
diff --git a/app/CrocApp/Views/HomeView.swift b/app/CrocApp/Views/HomeView.swift
index a16160ad..84c9ca1d 100644
--- a/app/CrocApp/Views/HomeView.swift
+++ b/app/CrocApp/Views/HomeView.swift
@@ -3,17 +3,47 @@ import SwiftUI
/// Two-verb home (prior-art pattern): giant Send / Receive, nothing else.
struct HomeView: View {
@Environment(AppRouter.self) private var router
+ @Environment(AppSettings.self) private var settings
+ @Environment(TransferController.self) private var controller
+
+ // The two verbs are the page's whole job, so they are tall: 100 is a
+ // component constant (it is not a control size).
+ private static let verbMinHeight: CGFloat = 100
+
+ private var hasPendingRequest: Bool {
+ if case .incoming = controller.phase { return true }
+ return false
+ }
var body: some View {
@Bindable var router = router
NavigationStack(path: $router.path) {
- VStack(spacing: 20) {
- NavigationLink(value: AppRouter.Route.send) {
+ VStack(spacing: Spacing.space6) {
+ TrustBadge(relay: settings.relayKind)
+
+ if hasPendingRequest {
+ // components.md → AcceptGate: a live request is never lost.
+ // Backing out of the transfer screen answers nothing, so the
+ // way back stays on Home until the sender gives up or the
+ // user answers.
+ StatusBanner(
+ kind: .info,
+ title: "Files are waiting for your answer",
+ detail: "The other device is waiting for you to accept or decline.",
+ actionTitle: "Review"
+ ) {
+ router.path = [.receive]
+ }
+ }
+
+ Button {
+ router.path.append(.send)
+ } label: {
Label("Send", systemImage: "arrow.up.circle.fill")
.font(.title2.bold())
- .frame(maxWidth: .infinity, minHeight: 100)
+ .frame(maxWidth: .infinity, minHeight: Self.verbMinHeight)
}
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
.overlay(alignment: .topTrailing) {
// Dock/window/share drops queue into pendingSendURLs
// without navigating while a transfer is active (queue-
@@ -34,15 +64,17 @@ struct HomeView: View {
}
}
- NavigationLink(value: AppRouter.Route.receive) {
+ Button {
+ router.path.append(.receive)
+ } label: {
Label("Receive", systemImage: "arrow.down.circle.fill")
.font(.title2.bold())
- .frame(maxWidth: .infinity, minHeight: 100)
+ .frame(maxWidth: .infinity, minHeight: Self.verbMinHeight)
}
- .buttonStyle(.bordered)
+ .buttonVariant(.secondary)
}
- .padding()
- .frame(maxWidth: 480)
+ .padding(Spacing.space5)
+ .frame(maxWidth: LayoutCap.contentMaxWidth)
.navigationTitle("CrocApp")
.toolbar {
#if os(iOS)
@@ -107,4 +139,11 @@ struct SettingsScreen: View {
}
}
-#Preview { HomeView().environment(AppRouter.shared) }
+#Preview {
+ HomeView()
+ .environment(AppRouter.shared)
+ .environment(AppSettings())
+ .environment(
+ TransferController(
+ settings: AppSettings(), vault: StoredTransferVault(secrets: MemorySecretStore())))
+}
diff --git a/app/CrocApp/Views/HowItWorksView.swift b/app/CrocApp/Views/HowItWorksView.swift
index 8e407a59..32ae0270 100644
--- a/app/CrocApp/Views/HowItWorksView.swift
+++ b/app/CrocApp/Views/HowItWorksView.swift
@@ -3,12 +3,15 @@ import SwiftUI
/// F36: explains the code-phrase / PAKE / untrusted-relay model.
/// Facts: docs/knowledge/what-is-croc.md.
struct HowItWorksView: View {
+ @Environment(AppSettings.self) private var settings
+
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.space6) {
+ TrustBadge(relay: settings.relayKind, variant: .full)
item(
"key.fill", "One code, one transfer",
- "Every transfer uses a short one-time code phrase. Whoever has the code is the transfer partner — share it over a channel you trust, then it expires with the transfer."
+ "Every transfer uses a short one-time code phrase. Whoever has the code is the transfer partner. Share it over a channel you trust; it expires with the transfer."
)
item(
"lock.shield.fill", "Strong keys from short codes",
@@ -24,21 +27,21 @@ struct HowItWorksView: View {
)
item(
"point.3.connected.trianglepath.dotted", "Direct path for large files",
- "With Connection set to Automatic, a send of 128 MiB or more also tries Tailcat: a peer-to-peer connection set up through tailcat.dev, with its own relays (*.ipn.dev) when the devices cannot reach each other. Whichever path is faster carries the transfer, still end-to-end encrypted. A send set to Relay only never contacts these servers; a receive follows the sender's choice."
+ "With Connection set to Automatic, a send of 128 MiB or more also tries Tailcat: a direct connection set up through tailcat.dev, with its own relays (*.ipn.dev) when the devices cannot reach each other. Whichever path is faster carries the transfer, still end-to-end encrypted. A send set to Relay only never contacts these servers; a receive follows the sender's choice."
)
item(
"wifi", "Local network when possible",
- "Devices on the same network transfer directly — the data never leaves your network. The local path and the relay race; the faster one wins."
+ "Devices on the same network transfer directly, and the data never leaves your network. The local path and the relay race; the faster one wins."
)
item(
"checkmark.shield.fill", "You stay in control",
- "Nothing is saved without your OK: incoming transfers show a file preview you accept or decline. Auto-accept is off unless you turn it on."
+ "Nothing is saved without your OK: a transfer you receive shows a file preview you accept or decline. Auto-Accept Transfers is off unless you turn it on."
)
}
- .padding()
+ .padding(Spacing.space5)
.frame(maxWidth: LayoutCap.contentMaxWidth)
}
- .navigationTitle("How it works")
+ .navigationTitle("How It Works")
}
private func item(_ icon: String, _ title: String, _ text: String) -> some View {
diff --git a/app/CrocApp/Views/MenuBarQuickSendView.swift b/app/CrocApp/Views/MenuBarQuickSendView.swift
index 80181b37..8275b97e 100644
--- a/app/CrocApp/Views/MenuBarQuickSendView.swift
+++ b/app/CrocApp/Views/MenuBarQuickSendView.swift
@@ -12,15 +12,18 @@ struct MenuBarQuickSendView: View {
let quickSend: MenuBarQuickSend
@State private var isDropTargeted = false
+ // components.md → MenuBarQuickSend: the window is 300 wide.
+ private static let windowWidth: CGFloat = 300
+
var body: some View {
VStack(spacing: Spacing.space4) {
content
Divider()
Button("Open CrocApp") { openMainWindow() }
- .buttonStyle(.link)
+ .buttonVariant(.plain)
}
.padding(Spacing.space5)
- .frame(width: 300)
+ .frame(width: Self.windowWidth)
}
@ViewBuilder private var content: some View {
@@ -37,19 +40,18 @@ struct MenuBarQuickSendView: View {
waiting(code: code)
case .confirmSend:
VStack(spacing: Spacing.space3) {
- Text("The receiver is connected. Send now?").multilineTextAlignment(.center)
+ Text("The other device is connected. Send now?").multilineTextAlignment(.center)
HStack {
- Button("Cancel", role: .destructive) { controller.cancel() }
+ cancelButton
Button("Send") { controller.respond(accept: true) }
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
}
}
case .incoming:
VStack(spacing: Spacing.space3) {
Label("Files are waiting for your answer", systemImage: "arrow.down.circle")
- Text("Open CrocApp to review and accept them.")
- .font(.callout).foregroundStyle(.secondary)
- cancelButton
+ Text("Open CrocApp to accept or decline them.")
+ .font(.callout).foregroundStyle(Color.textSecondary)
}
case .transferring(let p):
transferring(p)
@@ -58,16 +60,18 @@ struct MenuBarQuickSendView: View {
Label(
summary.success ? "Transfer complete" : "Transfer finished with errors",
systemImage: summary.success
- ? "checkmark.circle.fill" : "exclamationmark.circle")
+ ? "checkmark.circle" : "exclamationmark.circle.fill")
if let receivedText {
Button("Copy Received Text") { Clipboard.copy(receivedText) }
+ .buttonVariant(.secondary)
}
doneButton
}
case .stored(let receipt):
VStack(spacing: Spacing.space3) {
- Label("Upload ready", systemImage: "checkmark.circle.fill")
+ Label("Link ready", systemImage: "checkmark.circle")
Button("Copy Link") { Clipboard.copy(receipt.link, thisDeviceOnly: true) }
+ .buttonVariant(.secondary)
doneButton
}
case .failed(let message):
@@ -80,30 +84,17 @@ struct MenuBarQuickSendView: View {
private var idle: some View {
VStack(spacing: Spacing.space3) {
- VStack(spacing: Spacing.space3) {
- Image(systemName: "square.and.arrow.up")
- .font(.title)
- .accessibilityHidden(true)
- Text("Drop files to send")
- }
- .frame(maxWidth: .infinity, minHeight: 96)
- .background(
- RoundedRectangle(cornerRadius: Radius.md, style: .continuous)
- .strokeBorder(
- isDropTargeted ? Color.accentColor : Color.separatorToken,
- style: StrokeStyle(
- lineWidth: isDropTargeted ? 2 : BorderWidth.hairline, dash: [6, 4]))
- )
- .accessibilityElement(children: .combine)
- .accessibilityLabel("Drop files here to send")
- .dropDestination(for: URL.self) { urls, _ in
- quickSend.send(urls: urls)
- } isTargeted: {
- isDropTargeted = $0
- }
+ DropZone(title: "Drop files to send", size: .compact, isTargeted: isDropTargeted)
+ .accessibilityLabel("Drop files here to send")
+ .dropDestination(for: URL.self) { urls, _ in
+ quickSend.send(urls: urls)
+ } isTargeted: {
+ isDropTargeted = $0
+ }
HStack {
Button("Choose Files…") { quickSend.chooseFiles() }
+ .buttonVariant(.secondary)
Button("Send Clipboard Text") {
// A repeat tap leaves the notice unchanged, so an
// onChange would stay silent; announce per tap instead.
@@ -111,30 +102,21 @@ struct MenuBarQuickSendView: View {
AccessibilityNotification.Announcement(notice).post()
}
}
+ .buttonVariant(.secondary)
}
if let notice = quickSend.notice {
- Text(notice).font(.caption).foregroundStyle(.secondary)
+ Text(notice).font(.caption).foregroundStyle(Color.textSecondary)
}
}
}
private func waiting(code: String) -> some View {
VStack(spacing: Spacing.space3) {
- Text(direction == .send ? "Ready to send" : "Waiting for the sender…")
- .font(.headline)
if direction == .send {
- Text(code)
- .font(.title3.monospaced().weight(.semibold))
- .textSelection(.enabled)
- .multilineTextAlignment(.center)
- .accessibilityLabel("Transfer code")
- .accessibilityValue(code.replacingOccurrences(of: "-", with: ", "))
- Button {
- Clipboard.copy(code)
- } label: {
- Label("Copy Code", systemImage: "doc.on.doc")
- }
+ CodePhraseDisplay(code: code, compact: true)
QRCodeView(content: TransferLink.webURL(forCode: code)?.absoluteString ?? code)
+ } else {
+ Text("Waiting for the sender…").font(.headline)
}
cancelButton
}
@@ -142,13 +124,20 @@ struct MenuBarQuickSendView: View {
private func transferring(_ p: TransferProgress) -> some View {
let done = p.bytesFinished + p.fileSent
+ let total = max(p.totalSize, 1)
+ let fraction = min(1, Double(done) / Double(total))
return VStack(spacing: Spacing.space3) {
+ DirectionHeader(direction: direction)
Text(p.fileName).lineLimit(1).truncationMode(.middle)
- ProgressView(value: Double(done), total: Double(max(p.totalSize, 1)))
+ ForwardProgressBar(fraction: fraction, resetKey: p.totalSize)
.accessibilityLabel(progressLabel)
- .accessibilityValue(
- (Double(done) / Double(max(p.totalSize, 1))).formatted(
- .percent.precision(.fractionLength(0))))
+ .accessibilityValue(fraction.formatted(.percent.precision(.fractionLength(0))))
+ Text(
+ "\(done.formatted(.byteCount(style: .file, spellsOutZero: false))) / \(p.totalSize.formatted(.byteCount(style: .file)))"
+ )
+ .font(.caption)
+ .foregroundStyle(Color.textSecondary)
+ .monospacedDigit()
cancelButton
}
}
@@ -156,21 +145,17 @@ struct MenuBarQuickSendView: View {
private var direction: TransferController.Direction { controller.direction }
private var progressLabel: String {
- switch (direction, controller.isStoredTransfer) {
- case (.send, false): "Sending progress"
- case (.send, true): "Uploading progress"
- case (.receive, false): "Receiving progress"
- case (.receive, true): "Downloading progress"
- }
+ direction == .send ? "Sending progress" : "Receiving progress"
}
private var cancelButton: some View {
Button("Cancel", role: .destructive) { controller.cancel() }
+ .buttonVariant(.destructiveTinted)
}
private var doneButton: some View {
Button("Done") { controller.reset() }
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
}
private func openMainWindow() {
diff --git a/app/CrocApp/Views/OnboardingView.swift b/app/CrocApp/Views/OnboardingView.swift
index fc4f463b..2278f3c3 100644
--- a/app/CrocApp/Views/OnboardingView.swift
+++ b/app/CrocApp/Views/OnboardingView.swift
@@ -6,18 +6,22 @@ import SwiftUI
struct OnboardingView: View {
let done: () -> Void
+ // components.md → OnboardingSheet: mark glyph 56, scaled with Dynamic Type.
+ @ScaledMetric(relativeTo: .largeTitle) private var markSize: CGFloat = 56
+
var body: some View {
VStack(spacing: Spacing.space7) {
Image(systemName: "arrow.left.arrow.right.circle.fill")
- .font(.system(size: 56))
- .foregroundStyle(Color.accentColor)
+ .font(.system(size: markSize))
+ .foregroundStyle(Color.accentFill)
+ .accessibilityHidden(true)
Text("Welcome to CrocApp")
- .font(.title.bold())
+ .font(.title2.bold())
VStack(alignment: .leading, spacing: Spacing.space5) {
bullet(
"key.fill",
"One code phrase does everything",
- "Sending shows a short code. Enter it on the other device — that's the address and the password."
+ "Sending shows a short code phrase. Enter it on the other device and the two connect."
)
bullet(
"lock.fill",
@@ -27,7 +31,7 @@ struct OnboardingView: View {
bullet(
"globe",
"Works anywhere",
- "Same Wi-Fi or different continents — transfers find the fastest path automatically."
+ "Same Wi-Fi or different continents, transfers find the fastest path automatically."
)
}
.frame(maxWidth: LayoutCap.contentMaxWidth)
@@ -50,7 +54,7 @@ struct OnboardingView: View {
Text("Get Started")
.frame(maxWidth: .infinity)
}
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
.controlSize(.large)
.frame(maxWidth: LayoutCap.contentMaxWidth)
}
@@ -64,7 +68,7 @@ struct OnboardingView: View {
HStack(alignment: .top, spacing: Spacing.space4) {
Image(systemName: icon)
.font(.title3)
- .foregroundStyle(Color.accentColor)
+ .foregroundStyle(Color.accentFill)
// minWidth, not a fixed width: a fixed frame clips the glyph
// at accessibility text sizes.
.frame(minWidth: IconSize.large)
diff --git a/app/CrocApp/Views/PhoneHaptics.swift b/app/CrocApp/Views/PhoneHaptics.swift
new file mode 100644
index 00000000..fab1daae
--- /dev/null
+++ b/app/CrocApp/Views/PhoneHaptics.swift
@@ -0,0 +1,53 @@
+import CrocKit
+import SwiftUI
+
+#if os(iOS)
+import UIKit
+#endif
+
+/// The four haptic events in design/motion.md → Haptics and sound. iPhone only:
+/// iPad's haptics are inconsistent across models and macOS has none.
+extension View {
+ /// `.sensoryFeedback(trigger:_:)` that stays silent off iPhone. Return nil
+ /// from `feedback` to skip a change.
+ func phoneHaptic(
+ trigger: T, _ feedback: @escaping (_ oldValue: T, _ newValue: T) -> SensoryFeedback?
+ ) -> some View {
+ #if os(iOS)
+ sensoryFeedback(trigger: trigger) { old, new in
+ UIDevice.current.userInterfaceIdiom == .phone ? feedback(old, new) : nil
+ }
+ #else
+ self
+ #endif
+ }
+}
+
+/// What a phase change means for haptics. Everything else in a running
+/// transfer (progress, connecting, waiting) stays silent. A failure the user
+/// asked for (Cancel, Decline) is a button press and stays silent too.
+enum TransferHaptic: Equatable {
+ case completed
+ case failed
+ case incoming
+
+ init?(_ phase: TransferController.Phase, endedByUser: Bool = false) {
+ switch phase {
+ case .done(let summary, _): self = summary.success ? .completed : .failed
+ case .stored: self = .completed
+ case .failed:
+ if endedByUser { return nil }
+ self = .failed
+ case .incoming: self = .incoming
+ default: return nil
+ }
+ }
+
+ var feedback: SensoryFeedback {
+ switch self {
+ case .completed: .success
+ case .failed: .error
+ case .incoming: .warning
+ }
+ }
+}
diff --git a/app/CrocApp/Views/PowerSettingsSections.swift b/app/CrocApp/Views/PowerSettingsSections.swift
index 886e99f8..93d9e9cb 100644
--- a/app/CrocApp/Views/PowerSettingsSections.swift
+++ b/app/CrocApp/Views/PowerSettingsSections.swift
@@ -8,7 +8,7 @@ struct PowerSettingsSections: View {
@Environment(AppSettings.self) private var settings
private static let connectionLockedReason =
- "Unavailable while Local network only is on: those transfers never use a relay."
+ "Unavailable while Local Network Only is on: those transfers never use a relay."
var body: some View {
@Bindable var settings = settings
@@ -20,7 +20,7 @@ struct PowerSettingsSections: View {
)
.autocorrectionDisabled()
TextField(
- "IPv6 address", text: $settings.relayAddress6,
+ "IPv6 Address", text: $settings.relayAddress6,
prompt: Text("Public relay pool")
)
.autocorrectionDisabled()
@@ -36,7 +36,7 @@ struct PowerSettingsSections: View {
Section {
TextField(
- "Server", text: $settings.storedServer,
+ "Link Server", text: $settings.storedServer,
prompt: Text(AppSettings.defaultStoredServer)
)
.autocorrectionDisabled()
@@ -52,19 +52,20 @@ struct PowerSettingsSections: View {
Button("Reset to \(AppSettings.defaultStoredServer)") {
settings.storedServer = AppSettings.defaultStoredServer
}
+ .buttonVariant(.plain)
}
} header: {
Text("Send as link")
} footer: {
Text(
- "Send as link uploads encrypted files to this server. The server holds them until the link expires and cannot read them; the key is in the link."
+ "Send as Link uploads encrypted files to this server. The link server holds them until the link expires and cannot read them; the key is in the link."
)
}
Section {
Picker("Connection", selection: $settings.transport) {
Text("Automatic").tag(Transport.auto)
- Text("Relay only").tag(Transport.relay)
+ Text("Relay Only").tag(Transport.relay)
}
.disabled(settings.onlyLocal)
.accessibilityHint(settings.onlyLocal ? Self.connectionLockedReason : "")
@@ -81,9 +82,9 @@ struct PowerSettingsSections: View {
}
Section {
- Toggle("Local network only", isOn: $settings.onlyLocal)
- Toggle("Disable compression", isOn: $settings.noCompress)
- Toggle("Zip folders before sending", isOn: $settings.zipFolder)
+ Toggle("Local Network Only", isOn: $settings.onlyLocal)
+ Toggle("Disable Compression", isOn: $settings.noCompress)
+ Toggle("Zip Folders Before Sending", isOn: $settings.zipFolder)
} header: {
Text("Transfer")
} footer: {
@@ -100,10 +101,10 @@ struct PowerSettingsSections: View {
}
Section {
- Toggle("Confirm on both sides", isOn: $settings.bothSidesConfirm)
- Toggle("Auto-accept incoming files", isOn: $settings.autoAccept)
+ Toggle("Confirm on Both Sides", isOn: $settings.bothSidesConfirm)
+ Toggle("Auto-Accept Transfers", isOn: $settings.autoAccept)
if settings.autoAccept {
- Picker("When a file already exists", selection: $settings.whenFileExists) {
+ Picker("When a File Already Exists", selection: $settings.whenFileExists) {
Text("Keep Both").tag(FilePolicy.keepBoth)
Text("Replace").tag(FilePolicy.replace)
}
@@ -114,13 +115,13 @@ struct PowerSettingsSections: View {
if settings.autoAccept {
// A Section footer is not a StatusBanner surface; Section
// already styles footers as secondary, so a status color
- // here would tint body text (colors.md rule 2 violation).
+ // here would tint body text (colors.md → Contrast, rule 2).
Text(
- "Files from anyone who has your code are saved without preview or confirmation. Keep Both saves a new copy next to an existing file; Replace overwrites it. Unsafe file names still cancel the transfer. Both-sides confirm overrides auto-accept."
+ "Files from anyone who has your code are saved without preview or confirmation. Keep Both saves a new copy next to an existing file; Replace overwrites it. Unsafe file names still cancel the transfer. Confirm on Both Sides overrides Auto-Accept Transfers."
)
} else {
Text(
- "Auto-accept skips the incoming-files preview. Leave it off unless you fully trust the sender."
+ "Auto-Accept Transfers skips the file preview. Leave it off unless you fully trust the sender."
)
}
}
diff --git a/app/CrocApp/Views/QRCodeView.swift b/app/CrocApp/Views/QRCodeView.swift
index 865ece91..1eb14223 100644
--- a/app/CrocApp/Views/QRCodeView.swift
+++ b/app/CrocApp/Views/QRCodeView.swift
@@ -1,14 +1,21 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
-/// Crisp QR rendering of a transfer code (F6). Content is the deeplink form
-/// "croc://" so future F32 deeplinks and other croc GUIs can read it;
-/// the scanner side accepts both the bare code and the prefixed form.
+/// Crisp QR rendering of what the sender shows on screen. The payload is the
+/// receive link from `TransferLink.webURL(forCode:)` (`https://crocapp.dev/r/#`),
+/// which opens the app through the universal link or the browser receive page;
+/// it is not a `croc://` URL. The scanner side accepts the link, the legacy
+/// `croc://` form and the bare code (`TransferLink.parse`).
+/// design/components.md → CodePhraseDisplay and QRFrame.
struct QRCodeView: View {
let content: String
- var label = "QR code for the transfer code"
+ var label = "QR code for the code phrase"
@State private var image: CGImage?
+ // White in both themes: scanners need the contrast whatever the appearance
+ // (design/swiftui.md → Allowed literals).
+ private let frameFill = Color.white
+
var body: some View {
Group {
if let image {
@@ -21,19 +28,15 @@ struct QRCodeView: View {
height: ComponentMetrics.qrFrameDefault
)
.padding(Spacing.space4)
- // design/components.md → CodePhraseDisplay + QRFrame:
- // white background in BOTH themes — scanners need
- // contrast regardless of appearance. Deliberate hardcode,
- // not a token violation.
.background(
- Color.white,
+ frameFill,
in: RoundedRectangle(cornerRadius: Radius.md, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.md, style: .continuous)
.strokeBorder(Color.separatorToken, lineWidth: BorderWidth.hairline)
)
- // components.md → QRFrame: --shadow-md. SwiftUI's
+ // components.md → CodePhraseDisplay and QRFrame: --shadow-md. SwiftUI's
// .shadow() is single-layer where the token is two CSS
// layers; .shadowMd() stacks two .shadow() calls to
// reproduce both rather than approximate with one.
@@ -59,7 +62,7 @@ struct QRCodeView: View {
)
.padding(Spacing.space4)
.background(
- Color.white,
+ frameFill,
in: RoundedRectangle(cornerRadius: Radius.md, style: .continuous)
)
.overlay(
@@ -83,4 +86,4 @@ struct QRCodeView: View {
}
}
-#Preview { QRCodeView(content: "croc://1234-example-code-phrase") }
+#Preview { QRCodeView(content: "https://crocapp.dev/r/#1234-example-code-phrase") }
diff --git a/app/CrocApp/Views/QRScannerView.swift b/app/CrocApp/Views/QRScannerView.swift
index 29f5cbcc..0b28b4cd 100644
--- a/app/CrocApp/Views/QRScannerView.swift
+++ b/app/CrocApp/Views/QRScannerView.swift
@@ -51,21 +51,22 @@ struct QRScannerSheet: View {
ContentUnavailableView(
"Camera unavailable",
systemImage: "video.slash",
- description: Text("Check camera permission in Settings."))
+ description: Text("The camera couldn't start. Close this and try again."))
} else {
QRScannerView(onScan: onScan) { startFailed = true }
}
} else {
ContentUnavailableView(
"Camera scanning isn't available on this device",
- systemImage: "qrcode.viewfinder")
+ systemImage: "video.slash",
+ description: Text("Enter the code phrase instead."))
}
}
// Settings can fix a denial, so offer the shortcut there -- mirrors the
// local-network denied banner in TransferStatusView.
private var deniedView: some View {
- VStack(spacing: 12) {
+ VStack(spacing: Spacing.space4) {
ContentUnavailableView(
"Camera access denied",
systemImage: "video.slash",
@@ -76,6 +77,7 @@ struct QRScannerSheet: View {
UIApplication.shared.open(url)
}
}
+ .buttonVariant(.secondary)
}
}
diff --git a/app/CrocApp/Views/ReceiveView.swift b/app/CrocApp/Views/ReceiveView.swift
index c1db6971..9c016f21 100644
--- a/app/CrocApp/Views/ReceiveView.swift
+++ b/app/CrocApp/Views/ReceiveView.swift
@@ -12,6 +12,8 @@ struct ReceiveView: View {
@State private var storedLink: URL?
@State private var showScanner = false
@State private var showFolderPicker = false
+ /// Bumped per scanned code; drives the `.selection` haptic.
+ @State private var scanCount = 0
var body: some View {
Group {
@@ -40,7 +42,7 @@ struct ReceiveView: View {
}
private var form: some View {
- VStack(spacing: 16) {
+ VStack(spacing: Spacing.space5) {
if let storedLink {
storedLinkRow(storedLink)
} else {
@@ -66,8 +68,10 @@ struct ReceiveView: View {
} label: {
Label("Scan QR Code", systemImage: "qrcode.viewfinder")
}
+ .buttonVariant(.secondary)
.sheet(isPresented: $showScanner) {
QRScannerSheet { payload in
+ scanCount += 1
if let link = TransferLink.parse(payload) { router.open(link) }
showScanner = false
}
@@ -89,8 +93,9 @@ struct ReceiveView: View {
Button {
showFolderPicker = true
} label: {
- Text("Change").hitTarget()
+ Text("Change…").hitTarget()
}
+ .buttonVariant(.plain)
.fileImporter(
isPresented: $showFolderPicker,
allowedContentTypes: [.folder]
@@ -105,6 +110,7 @@ struct ReceiveView: View {
} label: {
Text("Reset").hitTarget()
}
+ .buttonVariant(.plain)
}
}
.font(.callout)
@@ -124,7 +130,7 @@ struct ReceiveView: View {
Label("Receive", systemImage: "arrow.down.circle.fill")
.frame(maxWidth: .infinity)
}
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
.controlSize(.large)
.disabled(
storedLink == nil
@@ -132,22 +138,34 @@ struct ReceiveView: View {
< EngineConstraints.minCodeLength
)
}
- .padding()
- .frame(maxWidth: 480)
+ .padding(Spacing.space5)
+ .frame(maxWidth: LayoutCap.contentMaxWidth)
+ .phoneHaptic(trigger: scanCount) { _, _ in .selection }
}
private func storedLinkRow(_ url: URL) -> some View {
let host = TransferController.codeHint(storedLink: url)
return HStack(spacing: Spacing.space3) {
- Label("Download link from \(host)", systemImage: "link")
- .lineLimit(2)
+ Label {
+ VStack(alignment: .leading, spacing: Spacing.space1) {
+ Text("Receive from Link")
+ Text(host)
+ .font(.footnote)
+ .foregroundStyle(Color.textSecondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ } icon: {
+ Image(systemName: "link")
+ }
Spacer()
Button {
storedLink = nil
} label: {
Text("Clear").hitTarget()
}
- .accessibilityLabel("Clear download link")
+ .buttonVariant(.plain)
+ .accessibilityLabel("Clear link")
}
.padding(.horizontal, Spacing.space4)
.frame(minHeight: ControlHeight.large)
diff --git a/app/CrocApp/Views/SendView.swift b/app/CrocApp/Views/SendView.swift
index 486e3f85..f3aa1bd8 100644
--- a/app/CrocApp/Views/SendView.swift
+++ b/app/CrocApp/Views/SendView.swift
@@ -24,6 +24,12 @@ struct SendView: View {
@State private var linkLifetime = LinkLifetime.day
@State private var linkDownloads = 1
+ /// Cap on the staged-files list and the text editor; the floor is the
+ /// DropZone's, so the Files tab does not change height as files arrive.
+ private static let queueMaxHeight: CGFloat = 280
+ /// The accent border a drag draws over the staged list (DropZone: 2pt).
+ private let dropBorderWidth: CGFloat = 2
+
/// How long a stored upload stays on the server (R18).
private enum LinkLifetime: Int, CaseIterable {
case hour = 3_600
@@ -63,9 +69,9 @@ struct SendView: View {
// Padding inside the scroll view: outside it, the scroll view's
// clip cuts off the Toggle switch, which draws past its frame.
if dynamicTypeSize.isAccessibilitySize {
- ScrollView { formContent.padding() }
+ ScrollView { formContent.padding(Spacing.space5) }
} else {
- formContent.padding()
+ formContent.padding(Spacing.space5)
}
}
.frame(maxWidth: LayoutCap.contentMaxWidth)
@@ -73,7 +79,7 @@ struct SendView: View {
private var formContent: some View {
VStack(spacing: Spacing.space5) {
- Picker("What to send", selection: $mode) {
+ Picker("What to Send", selection: $mode) {
ForEach(Mode.allCases, id: \.self) { Text($0.rawValue) }
}
.pickerStyle(.segmented)
@@ -88,15 +94,16 @@ struct SendView: View {
linkOptions
if !linkSelected {
- TextField(
- "Custom code (optional, at least \(EngineConstraints.minCodeLength) characters)",
- text: $customCode
- )
- .textFieldStyle(.roundedBorder)
- .autocorrectionDisabled()
- #if os(iOS)
- .textInputAutocapitalization(.never)
- #endif
+ CodeField(
+ placeholder: "Custom code (optional)",
+ text: $customCode,
+ isInvalid: !customCodeTrimmed.isEmpty
+ && customCodeTrimmed.count < EngineConstraints.minCodeLength,
+ invalidMessage:
+ "A code is at least \(EngineConstraints.minCodeLength) characters."
+ ) { pasted in
+ customCode = pasted
+ }
}
Button {
@@ -107,24 +114,27 @@ struct SendView: View {
} else if mode == .files {
controller.startSend(
urls: pickedURLs,
- customCode: customCode.trimmingCharacters(in: .whitespaces))
+ customCode: customCodeTrimmed)
} else {
- controller.startSendText(
- text, customCode: customCode.trimmingCharacters(in: .whitespaces))
+ controller.startSendText(text, customCode: customCodeTrimmed)
}
} label: {
Label(
- linkSelected ? "Upload" : "Send",
+ linkSelected ? "Send as Link" : "Send",
systemImage: linkSelected ? "link.circle.fill" : "arrow.up.circle.fill"
)
.frame(maxWidth: .infinity)
}
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
.controlSize(.large)
.disabled(!canStart)
}
}
+ private var customCodeTrimmed: String {
+ customCode.trimmingCharacters(in: .whitespaces)
+ }
+
/// Why "Send as link" cannot be used for what is staged, or nil (R18).
private var linkUnavailableReason: String? {
if mode == .text { return "Text can't be sent as a link. Switch to Files." }
@@ -152,13 +162,13 @@ struct SendView: View {
@ViewBuilder private var linkOptions: some View {
VStack(alignment: .leading, spacing: Spacing.space3) {
- Toggle("Send as link", isOn: Binding(get: { linkSelected }, set: { sendAsLink = $0 }))
+ Toggle("Send as Link", isOn: Binding(get: { linkSelected }, set: { sendAsLink = $0 }))
.disabled(linkUnavailableReason != nil)
.accessibilityHint(linkToggleHint)
if let reason = linkUnavailableReason {
Text(reason).font(.caption).foregroundStyle(.secondary)
} else if linkSelected {
- Picker("Link lasts", selection: $linkLifetime) {
+ Picker("Link Lasts", selection: $linkLifetime) {
ForEach(LinkLifetime.allCases, id: \.self) { Text($0.label) }
}
Stepper(value: $linkDownloads, in: 1...100) {
@@ -168,13 +178,13 @@ struct SendView: View {
StatusBanner(kind: .error, title: error + " Change it in Settings.")
} else {
Text(
- "Files are encrypted on this device and uploaded to \(serverHost). Anyone with the link can download them until it expires or runs out."
+ "Files are encrypted on this device before they reach \(serverHost). Anyone with the link can receive them until it expires or runs out."
)
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
- Text("Upload encrypted files and share a link instead of a code.")
+ Text("Send encrypted files as a link instead of with a code.")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -192,9 +202,7 @@ struct SendView: View {
&& AppSettings.storedServerError(settings.storedServer) == nil
}
let codeOK =
- customCode.isEmpty
- || customCode.trimmingCharacters(in: .whitespaces).count
- >= EngineConstraints.minCodeLength
+ customCode.isEmpty || customCodeTrimmed.count >= EngineConstraints.minCodeLength
let payloadOK = mode == .files ? !pickedURLs.isEmpty : !text.isEmpty
return codeOK && payloadOK
}
@@ -212,10 +220,10 @@ struct SendView: View {
VStack(spacing: Spacing.space4) {
Group {
if pickedURLs.isEmpty {
- ContentUnavailableView(
- "Drop files or folders here",
- systemImage: "square.and.arrow.up.on.square",
- description: Text("or add them with the buttons below"))
+ DropZone(
+ title: "Drop files or folders here",
+ hint: "or add them with the buttons below",
+ isTargeted: isDropTargeted)
} else {
List {
ForEach(Array(pickedURLs.enumerated()), id: \.element) { index, url in
@@ -235,15 +243,15 @@ struct SendView: View {
// problem: without this the List paints the system
// grouped background behind FileRow's own card fill.
.scrollContentBackground(.hidden)
- }
- }
- .frame(minHeight: 160, maxHeight: 280)
- .overlay {
- if isDropTargeted {
- // design/components.md → DropZone: drag-over border is
- // 2px solid accent at the same radius as idle (--radius-lg).
- RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
- .strokeBorder(Color.accentColor, lineWidth: 2)
+ .frame(minHeight: DropZone.minHeight, maxHeight: Self.queueMaxHeight)
+ .overlay {
+ // The empty DropZone draws its own drag-over state;
+ // a list in the way gets the same accent border.
+ if isDropTargeted {
+ RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
+ .strokeBorder(Color.accentFill, lineWidth: dropBorderWidth)
+ }
+ }
}
}
.dropDestination(for: URL.self) { urls, _ in
@@ -266,6 +274,7 @@ struct SendView: View {
@ViewBuilder private var importButtons: some View {
Button("Add Files") { showFileImporter = true }
+ .buttonVariant(.secondary)
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.item],
@@ -276,6 +285,7 @@ struct SendView: View {
}
}
Button("Add Folder") { showFolderImporter = true }
+ .buttonVariant(.secondary)
.fileImporter(
isPresented: $showFolderImporter,
allowedContentTypes: [.folder]
@@ -293,12 +303,11 @@ struct SendView: View {
TextEditor(text: $text)
.accessibilityLabel("Text to send")
.font(.body.monospaced())
- .frame(minHeight: 160, maxHeight: 280)
+ .frame(minHeight: DropZone.minHeight, maxHeight: Self.queueMaxHeight)
.overlay {
- // Not a design/components.md named component; nearest
- // token to the prior 8px radius.
+ // Not a named component; nearest token to the prior 8pt radius.
RoundedRectangle(cornerRadius: Radius.sm, style: .continuous)
- .strokeBorder(Color.secondary.opacity(0.3))
+ .strokeBorder(Color.separatorToken, lineWidth: BorderWidth.hairline)
}
PasteButton(payloadType: String.self) { strings in
if let pasted = strings.first {
diff --git a/app/CrocApp/Views/SettingsView.swift b/app/CrocApp/Views/SettingsView.swift
index 09f8321e..b1dfd304 100644
--- a/app/CrocApp/Views/SettingsView.swift
+++ b/app/CrocApp/Views/SettingsView.swift
@@ -35,8 +35,8 @@ struct SettingsView: View {
private var general: some View {
Form {
Section("Receive") {
- LabeledContent("Save received files to") {
- HStack(spacing: 8) {
+ LabeledContent("Save Received Files To") {
+ HStack(spacing: Spacing.space3) {
Text(
outputFolder.isUserSelected
? outputFolder.url.path : outputFolder.defaultDisplayName
@@ -44,14 +44,17 @@ struct SettingsView: View {
.lineLimit(1)
.truncationMode(.middle)
Button("Change…") { showFolderPicker = true }
+ .buttonVariant(.plain)
if outputFolder.isUserSelected {
Button("Reset") { outputFolder.resetToDefault() }
+ .buttonVariant(.plain)
}
}
}
Button("Show in Finder") {
NSWorkspace.shared.activateFileViewerSelecting([outputFolder.url])
}
+ .buttonVariant(.plain)
.focused($focusedControl, equals: .showInFinder)
}
@@ -87,9 +90,9 @@ struct SettingsView: View {
extension SettingsView {
@ViewBuilder fileprivate var menuBarSection: some View {
Section {
- Toggle("Show in menu bar", isOn: Bindable(settings).showInMenuBar)
+ Toggle("Show in Menu Bar", isOn: Bindable(settings).showInMenuBar)
Toggle(
- "Open at login",
+ "Open at Login",
isOn: Binding(
get: { loginItem.isRegistered }, set: { loginItem.setRegistered($0) })
)
@@ -99,6 +102,7 @@ extension SettingsView {
Text("Allow CrocApp in System Settings > General > Login Items.")
.font(.caption).foregroundStyle(.secondary)
Button("Open Settings") { SMAppService.openSystemSettingsLoginItems() }
+ .buttonVariant(.plain)
}
}
if let message = loginItem.errorMessage {
diff --git a/app/CrocApp/Views/StagedFilesSheet.swift b/app/CrocApp/Views/StagedFilesSheet.swift
index c8fae049..e71011eb 100644
--- a/app/CrocApp/Views/StagedFilesSheet.swift
+++ b/app/CrocApp/Views/StagedFilesSheet.swift
@@ -10,10 +10,11 @@ struct StagedFilesSheet: View {
List(files, id: \.self) { url in
Label(url.lastPathComponent, systemImage: "doc")
}
- .navigationTitle("Shared files")
+ .navigationTitle("Shared Files")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Discard", role: .destructive) { discard() }
+ .buttonVariant(.destructiveTinted)
}
ToolbarItem(placement: .confirmationAction) {
Button("Send") { send() }
diff --git a/app/CrocApp/Views/StatusBanner.swift b/app/CrocApp/Views/StatusBanner.swift
index a4bc6ea8..75dfe1d4 100644
--- a/app/CrocApp/Views/StatusBanner.swift
+++ b/app/CrocApp/Views/StatusBanner.swift
@@ -1,7 +1,7 @@
import Accessibility
import SwiftUI
-/// Inline advisory banner. design/components.md → StatusBanner, lines 144-157.
+/// Inline advisory banner. design/components.md → StatusBanner.
struct StatusBanner: View {
enum Kind {
case info
@@ -27,7 +27,7 @@ struct StatusBanner: View {
}
}
- // iconography.md → Mapping: info/warning/error/success rows.
+ // design/iconography.md → Shared meanings: info/warning/error/success.
var symbolName: String {
switch self {
case .info: "info.circle"
@@ -41,18 +41,23 @@ struct StatusBanner: View {
let kind: Kind
let title: String
var detail: String?
+ /// Optional trailing `plain` action (components.md → StatusBanner).
+ var actionTitle: LocalizedStringKey?
+ var action: (() -> Void)?
+
+ // components.md → StatusBanner: glyph at 19, scaled with Dynamic Type.
+ @ScaledMetric(relativeTo: .subheadline) private var glyphSize: CGFloat = 19
var body: some View {
HStack(alignment: .top, spacing: Spacing.space3) {
- // iconography.md line 77: banner leading glyph 19, stroke 2.
- // Decorative — the title carries the meaning (colors.md rule 2).
+ // Decorative: the title carries the meaning (colors.md → Contrast, rule 2).
Image(systemName: kind.symbolName)
- .font(.system(size: 19, weight: .semibold))
+ .font(.system(size: glyphSize, weight: .semibold))
.foregroundStyle(kind.color)
.accessibilityHidden(true)
- VStack(alignment: .leading, spacing: 2) {
+ VStack(alignment: .leading, spacing: Spacing.space1) {
// Not drawn in the kind color: on its own tint that measures
- // 1.96-2.95:1 in light (components.md line 157).
+ // 1.96-2.95:1 in light (components.md → StatusBanner).
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textPrimary)
@@ -61,6 +66,10 @@ struct StatusBanner: View {
.font(.footnote)
.foregroundStyle(Color.textSecondary)
}
+ if let actionTitle, let action {
+ Button(actionTitle, action: action)
+ .buttonVariant(.plain)
+ }
}
}
.padding(.horizontal, Spacing.space5)
@@ -68,9 +77,11 @@ struct StatusBanner: View {
.background(kind.tint, in: RoundedRectangle(cornerRadius: Radius.md, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.md, style: .continuous)
- .strokeBorder(kind.color.opacity(0.22), lineWidth: BorderWidth.hairline)
+ .strokeBorder(
+ kind.color.opacity(ComponentMetrics.statusBorderOpacity),
+ lineWidth: BorderWidth.hairline)
)
- // components.md line 152: error's `role` is `alert`. SwiftUI has no
+ // components.md → StatusBanner: error is announced. SwiftUI has no
// live-region trait, so a VoiceOver announcement on appearance is
// the closest native equivalent.
.onAppear {
diff --git a/app/CrocApp/Views/StatusBlock.swift b/app/CrocApp/Views/StatusBlock.swift
index 0515c834..75e73f8c 100644
--- a/app/CrocApp/Views/StatusBlock.swift
+++ b/app/CrocApp/Views/StatusBlock.swift
@@ -2,7 +2,7 @@ import Accessibility
import SwiftUI
/// Large centered result (transfer done / failed). design/components.md →
-/// StatusBlock, lines 161-166.
+/// StatusBlock.
struct StatusBlock: View {
enum Kind {
case success
@@ -22,7 +22,7 @@ struct StatusBlock: View {
}
}
- // iconography.md → Mapping: success/error rows.
+ // design/iconography.md → Shared meanings: success and error.
var symbolName: String {
switch self {
case .success: "checkmark.circle"
@@ -35,20 +35,23 @@ struct StatusBlock: View {
let title: String
var detail: String?
+ // components.md → StatusBlock: a 64 circle holding a 34 glyph, scaled with
+ // Dynamic Type so the glyph never outgrows its circle.
+ @ScaledMetric(relativeTo: .title) private var circleSize: CGFloat =
+ ComponentMetrics.statusBlockCircle
+ @ScaledMetric(relativeTo: .title) private var glyphSize: CGFloat =
+ ComponentMetrics.statusBlockGlyph
+
var body: some View {
VStack(spacing: Spacing.space3) {
ZStack {
Circle()
.fill(kind.tint)
- .frame(
- width: ComponentMetrics.statusBlockCircle,
- height: ComponentMetrics.statusBlockCircle
- )
- // iconography.md line 80: StatusBlock circle glyph 34, stroke 2.
- // Decorative — the title states the outcome in words, never
- // color alone (components.md line 165).
+ .frame(width: circleSize, height: circleSize)
+ // Decorative: the title states the outcome in words, never
+ // color alone (components.md → StatusBlock).
Image(systemName: kind.symbolName)
- .font(.system(size: ComponentMetrics.statusBlockGlyph, weight: .semibold))
+ .font(.system(size: glyphSize, weight: .semibold))
.foregroundStyle(kind.color)
.accessibilityHidden(true)
}
diff --git a/app/CrocApp/Views/TransferStatusView.swift b/app/CrocApp/Views/TransferStatusView.swift
index c88b9dfc..5c861113 100644
--- a/app/CrocApp/Views/TransferStatusView.swift
+++ b/app/CrocApp/Views/TransferStatusView.swift
@@ -11,6 +11,9 @@ struct TransferStatusView: View {
@Environment(LocalNetworkChecker.self) private var localNetwork
@Environment(OutputFolderStore.self) private var outputFolder
@State private var showsSlowPhaseHint = false
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ // components.md → StatusBlock/EmptyState: decorative glyph at 44, scaled.
+ @ScaledMetric(relativeTo: .largeTitle) private var confirmGlyphSize: CGFloat = 44
// Long enough that ordinary relay/handshake latency doesn't false-positive,
// short enough to reassure before the user assumes the app is hung.
@@ -23,6 +26,28 @@ struct TransferStatusView: View {
}
}
+ /// The phase case without its payload: the cross-fade keys on this, not on
+ /// every progress tick (design/motion.md → Choreography).
+ private enum PhaseKind: Hashable {
+ case idle, starting, waiting, connecting, confirmSend, incoming
+ case transferring, done, stored, failed
+ }
+
+ private var phaseKind: PhaseKind {
+ switch controller.phase {
+ case .idle: .idle
+ case .starting: .starting
+ case .waiting: .waiting
+ case .connecting: .connecting
+ case .confirmSend: .confirmSend
+ case .incoming: .incoming
+ case .transferring: .transferring
+ case .done: .done
+ case .stored: .stored
+ case .failed: .failed
+ }
+ }
+
var body: some View {
// The phase content scrolls when taller than the screen (accessibility
// text sizes) and stays centred when it fits. Not ViewThatFits: it
@@ -39,7 +64,7 @@ struct TransferStatusView: View {
.scrollBounceBehavior(.basedOnSize)
.scrollClipDisabled()
}
- .padding()
+ .padding(Spacing.space5)
.frame(maxWidth: LayoutCap.contentMaxWidth)
// Resets and restarts whenever isInEarlyPhase flips, so the hint
// never keeps counting into transferring/done/failed, but survives
@@ -59,40 +84,61 @@ struct TransferStatusView: View {
localNetworkDeniedBanner
}
- switch controller.phase {
- case .idle:
- EmptyView()
- case .starting:
- ProgressView().accessibilityHidden(true)
- Text("Starting…").foregroundStyle(.secondary)
- if showsSlowPhaseHint {
- slowPhaseHintText
- }
- case .connecting:
- ProgressView().accessibilityHidden(true)
- Text("Connecting…").foregroundStyle(.secondary)
- if showsSlowPhaseHint {
- slowPhaseHintText
- }
- case .confirmSend:
- confirmSendView
- case .waiting(let code):
- waitingView(code: code)
- case .incoming(let list, let conflicts, let blocked):
- IncomingRequestView(list: list, conflicts: conflicts, blocked: blocked)
- case .transferring(let progress):
- transferringView(progress)
- case .done(let summary, let receivedText):
- doneView(summary, receivedText: receivedText)
- case .stored(let receipt):
- storedView(receipt)
- case .failed(let message):
- failedView(message)
+ // One cross-fade on the block that swaps; its contents do not
+ // animate in separately. The ZStack lets old and new overlap
+ // instead of stacking while both are in the tree.
+ ZStack {
+ phaseContent
+ .id(phaseKind)
+ .transition(.opacity)
}
+ .animation(
+ reduceMotion ? nil : Motion.easeOut(Motion.durationBase), value: phaseKind)
if showsCancel {
Button("Cancel", role: .destructive) { controller.cancel() }
+ .buttonVariant(.destructiveTinted)
+ }
+ }
+ }
+
+ @ViewBuilder private var phaseContent: some View {
+ switch controller.phase {
+ case .idle:
+ EmptyView()
+ case .starting:
+ earlyPhase("Starting…")
+ case .connecting:
+ earlyPhase("Connecting…")
+ case .confirmSend:
+ confirmSendView
+ case .waiting(let code):
+ waitingView(code: code)
+ case .incoming(let list, let conflicts, let blocked):
+ IncomingRequestView(list: list, conflicts: conflicts, blocked: blocked)
+ case .transferring(let progress):
+ transferringView(progress)
+ case .done(let summary, let receivedText):
+ doneView(summary, receivedText: receivedText)
+ case .stored(let receipt):
+ storedView(receipt)
+ case .failed(let message):
+ failedView(message)
+ }
+ }
+
+ private func earlyPhase(_ title: LocalizedStringKey) -> some View {
+ VStack(spacing: Spacing.space7) {
+ ProgressView().accessibilityHidden(true)
+ Text(title).foregroundStyle(Color.textSecondary)
+ // Fades in at --dur-slow so it reads as information, not alarm.
+ ZStack {
+ if showsSlowPhaseHint {
+ slowPhaseHintText.transition(.opacity)
+ }
}
+ .animation(
+ reduceMotion ? nil : Motion.easeOut(Motion.durationSlow), value: showsSlowPhaseHint)
}
}
@@ -101,13 +147,15 @@ struct TransferStatusView: View {
"This is taking longer than usual. Double-check the code, or the other device may not be ready."
)
.font(.footnote)
- .foregroundStyle(.secondary)
+ .foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
}
+ /// Cancel is not offered at the accept gate: it would answer the sender on
+ /// the user's behalf. Decline is the way to say no (components.md → AcceptGate).
private var showsCancel: Bool {
switch controller.phase {
- case .starting, .waiting, .connecting, .transferring, .incoming: return true
+ case .starting, .waiting, .connecting, .transferring: return true
default: return false
}
}
@@ -124,61 +172,46 @@ struct TransferStatusView: View {
"Transfers use the relay only. Enable it in System Settings › Privacy & Security › Local Network for faster direct transfers."
#endif
- // design/colors.md line 107: local-network-denied is `info` (blue), not
- // a warning — the relay path still works, this is a "could be faster"
+ // components.md → StatusBanner: local-network-denied is `info` (blue), not
+ // a warning. The relay path still works, this is a "could be faster"
// notice, not a problem.
private var localNetworkDeniedBanner: some View {
- VStack(spacing: Spacing.space3) {
- StatusBanner(
- kind: .info,
- title: "Local network access is off",
- detail: Self.localNetworkDeniedDetail
- )
- Button("Open Settings") {
- #if os(iOS)
- if let url = URL(string: UIApplication.openSettingsURLString) {
- UIApplication.shared.open(url)
- }
- #else
- // Community-standard deep link, not a documented Apple API --
- // same category as shareddocuments:// on the receive side.
- // Measured on macOS 26: this opens Privacy & Security at the
- // top; the ?Privacy_LocalNetwork anchor does not select the
- // Local Network row (nor does the .PrivacySecurity.extension
- // spelling). The banner names the rest of the path, so the
- // user finishes the last hop. Recheck if Apple ever honours it.
- if let url = URL(
- string:
- "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork"
- ) {
- NSWorkspace.shared.open(url)
- }
- #endif
- }
- .font(.footnote)
+ StatusBanner(
+ kind: .info,
+ title: "Local network access is off",
+ detail: Self.localNetworkDeniedDetail,
+ actionTitle: "Open Settings",
+ action: openLocalNetworkSettings
+ )
+ }
+
+ private func openLocalNetworkSettings() {
+ #if os(iOS)
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ #else
+ // Community-standard deep link, not a documented Apple API --
+ // same category as shareddocuments:// on the receive side.
+ // Measured on macOS 26: this opens Privacy & Security at the
+ // top; the ?Privacy_LocalNetwork anchor does not select the
+ // Local Network row (nor does the .PrivacySecurity.extension
+ // spelling). The banner names the rest of the path, so the
+ // user finishes the last hop. Recheck if Apple ever honours it.
+ if let url = URL(
+ string:
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork"
+ ) {
+ NSWorkspace.shared.open(url)
}
+ #endif
}
// MARK: - Waiting (sender: code ready)
private func waitingView(code: String) -> some View {
VStack(spacing: Spacing.space5) {
- Text("Ready to send").font(.headline)
- Text(code)
- .codeHeroTextStyle()
- .textSelection(.enabled)
- .accessibilityLabel("Transfer code")
- .accessibilityValue(code.replacingOccurrences(of: "-", with: ", "))
- .multilineTextAlignment(.center)
- .padding(.horizontal, Spacing.space6)
- .padding(.vertical, Spacing.space4)
- // A capsule clips the code once it wraps at large text sizes.
- .glassEffect(in: .rect(cornerRadius: Radius.xl))
- Button {
- Clipboard.copy(code)
- } label: {
- Label("Copy Code", systemImage: "doc.on.doc")
- }
+ CodePhraseDisplay(code: code)
let webURL = TransferLink.webURL(forCode: code)
QRCodeView(content: webURL?.absoluteString ?? code)
if let webURL {
@@ -188,17 +221,18 @@ struct TransferStatusView: View {
}
Text("On the other device, enter this code or scan the QR.")
.font(.callout)
- .foregroundStyle(.secondary)
+ .foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
if let note = browserReceiveNote {
Text(note)
.font(.caption)
- .foregroundStyle(.secondary)
+ .foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
}
TrustBadge(relay: controller.activeRelay)
ProgressView().accessibilityHidden(true)
- Text("Waiting for the receiver…").font(.caption).foregroundStyle(.secondary)
+ Text("Waiting for the other device…").font(.caption).foregroundStyle(
+ Color.textSecondary)
}
}
@@ -217,22 +251,23 @@ struct TransferStatusView: View {
private var confirmSendView: some View {
VStack(spacing: Spacing.space5) {
- // Decorative icon, size and status-tint both from the component's
- // own judgment (not a speced component) — colors.md rule 2 allows
- // a status color as an icon fill, just never as a label.
+ // Decorative glyph in a status color: allowed as a fill, never as
+ // a label (colors.md → Contrast, rule 2).
Image(systemName: "person.crop.circle.badge.checkmark")
- .font(.system(size: 40))
- .foregroundStyle(.blue)
+ .font(.system(size: confirmGlyphSize))
+ .foregroundStyle(Color.statusInfo)
.accessibilityHidden(true)
- Text("Receiver connected").font(.headline)
+ Text("The other device is connected").font(.headline)
Text("Send the selected items to the connected device?")
- .foregroundStyle(.secondary)
+ .foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
TrustBadge(relay: controller.activeRelay)
HStack(spacing: Spacing.space5) {
Button("Cancel", role: .destructive) { controller.cancel() }
+ .buttonVariant(.destructiveTinted)
Button("Send") { controller.respond(accept: true) }
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
+ .controlSize(.large)
}
}
}
@@ -240,24 +275,21 @@ struct TransferStatusView: View {
// MARK: - Transferring
private func transferringView(_ p: TransferProgress) -> some View {
- VStack(spacing: Spacing.space5) {
- // components.md → TransferProgress: direction header carries the
- // arrow glyph AND the word — neither is optional.
- HStack(spacing: Spacing.space3) {
- Image(systemName: controller.direction == .send ? "arrow.up" : "arrow.down")
- .font(.system(size: 18, weight: .semibold))
- .foregroundStyle(Color.accentFill)
- .accessibilityHidden(true)
- Text(transferHeading)
- .font(.headline)
- }
+ let fileFraction = fraction(p.fileSent, of: p.fileSize)
+ let totalDone = p.bytesFinished + p.fileSent
+ let totalFraction = fraction(totalDone, of: p.totalSize)
+ return VStack(spacing: Spacing.space5) {
+ DirectionHeader(direction: controller.direction)
VStack(alignment: .leading, spacing: Spacing.space3) {
Text(p.fileName)
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
- ProgressView(value: fraction(p.fileSent, of: p.fileSize))
- .accessibilityLabel("Current file progress")
+ ForwardProgressBar(
+ fraction: fileFraction,
+ resetKey: ProgressScope(file: p.currentFile, size: p.fileSize)
+ )
+ .accessibilityLabel("Current file progress")
HStack {
Text("File \(min(p.currentFile + 1, p.totalFiles)) of \(p.totalFiles)")
Spacer()
@@ -268,35 +300,37 @@ struct TransferStatusView: View {
)
}
.font(.caption)
- .foregroundStyle(.secondary)
+ .foregroundStyle(Color.textSecondary)
+ .monospacedDigit()
}
.accessibilityElement(children: .combine)
- .accessibilityValue(percent(fraction(p.fileSent, of: p.fileSize)))
+ .accessibilityValue(percent(fileFraction))
VStack(alignment: .leading, spacing: Spacing.space3) {
- ProgressView(value: fraction(p.bytesFinished + p.fileSent, of: p.totalSize))
- .accessibilityLabel("Overall progress")
+ ForwardProgressBar(
+ fraction: totalFraction, resetKey: ProgressScope(file: -1, size: p.totalSize)
+ )
+ .accessibilityLabel("Overall progress")
HStack {
Text(
- "Total \((p.bytesFinished + p.fileSent).formatted(.byteCount(style: .file, spellsOutZero: false))) / \(p.totalSize.formatted(.byteCount(style: .file)))"
+ "Total \(totalDone.formatted(.byteCount(style: .file, spellsOutZero: false))) / \(p.totalSize.formatted(.byteCount(style: .file)))"
)
+ .foregroundStyle(Color.textSecondary)
Spacer()
- if controller.speedBytesPerSec > 0 {
- Text(
- "\(Int64(controller.speedBytesPerSec).formatted(.byteCount(style: .file)))/s"
- )
- }
+ Text(speedText)
+ .fontWeight(.medium)
+ .foregroundStyle(Color.textPrimary)
}
.font(.caption)
- .foregroundStyle(.secondary)
+ .monospacedDigit()
}
.accessibilityElement(children: .combine)
- .accessibilityValue(percent(fraction(p.bytesFinished + p.fileSent, of: p.totalSize)))
+ .accessibilityValue(percent(totalFraction))
if controller.isStoredTransfer {
// A stored transfer meets no relay, so the relay badge would
// describe a path it does not take.
- Text("Encrypted on this device. The server only holds the encrypted files.")
+ Text("Encrypted on this device. The link server only holds the encrypted files.")
.font(.caption)
- .foregroundStyle(.secondary)
+ .foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
} else {
TrustBadge(relay: controller.activeRelay)
@@ -304,13 +338,18 @@ struct TransferStatusView: View {
}
}
- private var transferHeading: String {
- switch (controller.direction, controller.isStoredTransfer) {
- case (.send, false): "Sending"
- case (.send, true): "Uploading"
- case (.receive, false): "Receiving"
- case (.receive, true): "Downloading"
- }
+ /// What a progress bar measures. A new scope snaps the bar instead of
+ /// letting it run backwards.
+ private struct ProgressScope: Hashable {
+ let file: Int
+ let size: Int64
+ }
+
+ /// An unknown or zero speed is an em dash, never "0 B/s": a zero reads as
+ /// a stall (content.md → Numbers).
+ private var speedText: String {
+ guard controller.speedBytesPerSec > 0 else { return "—" }
+ return "\(Int64(controller.speedBytesPerSec).formatted(.byteCount(style: .file)))/s"
}
private func fraction(_ part: Int64, of whole: Int64) -> Double {
@@ -345,7 +384,11 @@ struct TransferStatusView: View {
? "Received text"
: (summary.success ? "Transfer complete" : "Transfer finished with problems"),
detail: receivedText == nil
- ? "\(summary.files) file(s) • \(summary.totalSize.formatted(.byteCount(style: .file)))"
+ ? inflected(
+ AttributedString(
+ localized:
+ "^[\(summary.files) file](inflect: true) • \(summary.totalSize.formatted(.byteCount(style: .file)))"
+ ))
: nil
)
if let receivedText {
@@ -355,12 +398,13 @@ struct TransferStatusView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
- .frame(maxHeight: 200)
+ .frame(maxHeight: Self.receivedTextMaxHeight)
Button {
Clipboard.copy(receivedText)
} label: {
Label("Copy Text", systemImage: "doc.on.doc")
}
+ .buttonVariant(.secondary)
}
if controller.direction == .receive, let folder = controller.lastOutputFolder {
#if os(iOS)
@@ -377,6 +421,7 @@ struct TransferStatusView: View {
} label: {
Label("Open in Files", systemImage: "folder")
}
+ .buttonVariant(.secondary)
}
#else
Button {
@@ -384,33 +429,41 @@ struct TransferStatusView: View {
} label: {
Label("Show in Finder", systemImage: "folder")
}
+ .buttonVariant(.secondary)
#endif
}
Button("Done") { controller.reset() }
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
+ .controlSize(.large)
}
}
+ private static let receivedTextMaxHeight: CGFloat = 200
+
private func storedView(_ receipt: StoredReceipt) -> some View {
VStack(spacing: Spacing.space5) {
StatusBlock(
kind: .success, title: "Link ready",
- detail:
- "Works until \(receipt.expiresAt.formatted(date: .abbreviated, time: .shortened)), for \(receipt.downloads) download(s)"
+ detail: inflected(
+ AttributedString(
+ localized:
+ "Works until \(receipt.expiresAt.formatted(date: .abbreviated, time: .shortened)), for ^[\(receipt.downloads) download](inflect: true)"
+ ))
)
- QRCodeView(content: receipt.link, label: "QR code for the download link")
+ QRCodeView(content: receipt.link, label: "QR code for the link")
Text(receipt.link)
.font(.caption.monospaced())
.lineLimit(3)
.truncationMode(.middle)
.textSelection(.enabled)
- .accessibilityLabel("Download link")
+ .accessibilityLabel("Link")
HStack(spacing: Spacing.space5) {
Button {
Clipboard.copy(receipt.link, thisDeviceOnly: true)
} label: {
Label("Copy Link", systemImage: "doc.on.doc")
}
+ .buttonVariant(.secondary)
if let url = URL(string: receipt.link) {
ShareLink(item: url) {
Label("Share Link", systemImage: "square.and.arrow.up")
@@ -421,10 +474,11 @@ struct TransferStatusView: View {
"Anyone who gets this link can download the files until it expires or runs out. Revoke it any time in History."
)
.font(.callout)
- .foregroundStyle(.secondary)
+ .foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
Button("Done") { controller.reset() }
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
+ .controlSize(.large)
}
}
@@ -432,50 +486,57 @@ struct TransferStatusView: View {
VStack(spacing: Spacing.space5) {
StatusBlock(kind: .error, title: message)
Button("OK") { controller.reset() }
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
+ .controlSize(.large)
}
}
}
-/// Incoming file list preview with accept/decline (F9), Keep Both / Replace
-/// when files already exist (F8, R24), and unsafe-name blocking.
+/// The accept gate. design/components.md → AcceptGate: a phase of the transfer
+/// screen, not a sheet or an alert. Decline says no to the other device, Accept
+/// (or Keep Both and Replace when a name is taken) says yes. Leaving answers
+/// nothing, and there is no Cancel here.
struct IncomingRequestView: View {
@Environment(TransferController.self) private var controller
let list: FileList
let conflicts: [String]
let blocked: [String]
- @ScaledMetric private var listMinHeight: CGFloat = 120
@ScaledMetric private var listMaxHeight: CGFloat = 240
var body: some View {
VStack(spacing: Spacing.space5) {
- Text("Incoming transfer").font(.headline)
- List(list.files, id: \.name) { entry in
- HStack {
- Text(entry.name).lineLimit(1).truncationMode(.middle)
- Spacer()
- Text(entry.size.formatted(.byteCount(style: .file)))
- .foregroundStyle(.secondary)
+ DirectionHeader(direction: .receive)
+ TrustBadge(relay: controller.activeRelay)
+ ScrollView {
+ // A card of rows: FileRow draws the first/last corner rounding
+ // and the separators.
+ VStack(spacing: 0) {
+ ForEach(Array(list.files.enumerated()), id: \.element.name) { index, entry in
+ FileRow(
+ name: entry.name, size: entry.size,
+ isFirst: index == 0, isLast: index == list.files.count - 1)
+ }
}
}
- // Without this the List paints the system grouped background
- // behind the rows, which reads as a stray grey slab under the
- // file card whenever the list is shorter than its own frame.
- .scrollContentBackground(.hidden)
- .frame(minHeight: listMinHeight, maxHeight: listMaxHeight)
+ .scrollBounceBehavior(.basedOnSize)
+ .frame(maxHeight: listMaxHeight)
Text(
- "\(list.files.count) file(s) • \(list.totalSize.formatted(.byteCount(style: .file)))"
+ "^[\(list.files.count) file](inflect: true) • \(list.totalSize.formatted(.byteCount(style: .file)))"
)
.font(.caption)
- .foregroundStyle(.secondary)
+ .foregroundStyle(Color.textSecondary)
+ .monospacedDigit()
if !blocked.isEmpty {
StatusBanner(kind: .error, title: "Blocked: unsafe file names in this transfer.")
} else if !conflicts.isEmpty {
StatusBanner(
kind: .warning,
- title:
- "\(conflicts.count) item(s) already exist. Keep Both saves the new ones under a new name; Replace overwrites them and resumes partly received files."
- )
+ title: "Some names are already taken",
+ detail: inflected(
+ AttributedString(
+ localized:
+ "^[\(conflicts.count) item](inflect: true) in this transfer share a name with a file here. Keep Both saves the new ones under a new name; Replace overwrites them and resumes partly received files."
+ )))
}
// Three labels do not fit side by side at accessibility text sizes
// on a phone, so they stack there.
@@ -488,15 +549,21 @@ struct IncomingRequestView: View {
@ViewBuilder private var actions: some View {
Button("Decline", role: .destructive) { controller.respond(accept: false) }
+ .buttonVariant(.destructive)
+ .controlSize(.large)
if blocked.isEmpty, !conflicts.isEmpty {
Button("Replace") { controller.respond(accept: true, policy: .replace) }
+ .buttonVariant(.secondary)
+ .controlSize(.large)
Button("Keep Both") { controller.respond(accept: true, policy: .keepBoth) }
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
+ .controlSize(.large)
} else {
// Until the conflict scan reports, Accept keeps both: it
// never overwrites a file the scan has not shown yet.
Button("Accept") { controller.respond(accept: true, policy: .keepBoth) }
- .buttonStyle(.borderedProminent)
+ .buttonVariant(.prominent)
+ .controlSize(.large)
.disabled(!blocked.isEmpty)
}
}
diff --git a/app/CrocApp/Views/TrustBadge.swift b/app/CrocApp/Views/TrustBadge.swift
index cd0cc1d0..c2fa8f83 100644
--- a/app/CrocApp/Views/TrustBadge.swift
+++ b/app/CrocApp/Views/TrustBadge.swift
@@ -3,12 +3,12 @@ import SwiftUI
/// F36: end-to-end encryption badge + active relay indicator, shown on the
/// waiting and transferring screens. Relay kind is captured at transfer
/// start (TransferController.activeRelay) so it can't drift mid-transfer.
-/// design/components.md → TrustBadge, lines 169-178.
+/// design/components.md → TrustBadge.
struct TrustBadge: View {
enum Variant {
/// Inline capsule. Sits in the Home header and on transfer screens.
case pill
- /// Row card for the "How it works" trust screen.
+ /// Row card for the How It Works screen.
case full
}
@@ -16,6 +16,13 @@ struct TrustBadge: View {
/// Defaulted so existing `TrustBadge(relay:)` call sites keep compiling.
var variant: Variant = .pill
+ // components.md → TrustBadge: pill lock at 14, full glyph at 19, scaled
+ // with Dynamic Type.
+ @ScaledMetric(relativeTo: .footnote) private var pillGlyph: CGFloat = 14
+ @ScaledMetric(relativeTo: .subheadline) private var fullGlyph: CGFloat = 19
+ @ScaledMetric(relativeTo: .subheadline) private var fullCircle: CGFloat =
+ ComponentMetrics.trustBadgeFullCircle
+
var body: some View {
switch variant {
case .pill: pill
@@ -25,53 +32,43 @@ struct TrustBadge: View {
// MARK: - Pill
- // components.md line 173: block/inline-start/inline-end padding 5/9/11,
- // icon 14 — components.md's own web-only token table, kept as component
- // constants here per its instruction not to scatter them at call sites.
- // iconography.md line 78: pill icon 14, stroke 2.2.
private var pill: some View {
- HStack(spacing: 4) {
+ HStack(spacing: Spacing.space2) {
Image(systemName: "lock.fill")
- .font(.system(size: 14, weight: .semibold))
+ .font(.system(size: pillGlyph, weight: .semibold))
.foregroundStyle(Color.statusSuccess)
.accessibilityHidden(true)
// The pill's label is primary, not success: success on its own
- // tint is 1.98:1 in light (components.md line 176). Only the
+ // tint is 1.98:1 in light (components.md → TrustBadge). Only the
// glyph carries the success color.
Text("End-to-end encrypted")
.font(.footnote.weight(.semibold))
.foregroundStyle(Color.textPrimary)
}
- .padding(.vertical, 5)
- .padding(.leading, 9)
- .padding(.trailing, 11)
+ .padding(.vertical, Spacing.space2)
+ .padding(.leading, Spacing.space3)
+ .padding(.trailing, Spacing.space4)
.background(Color.statusSuccessTint, in: Capsule())
.accessibilityElement(children: .combine)
}
// MARK: - Full
- // components.md line 174: radius-md, success tint background, 22%
- // success border, 34px solid-success circle, --paper shield-check at 19.
- // iconography.md line 79: full-circle glyph 19, stroke 2.
private var full: some View {
HStack(spacing: Spacing.space4) {
ZStack {
Circle()
.fill(Color.statusSuccess)
- .frame(
- width: ComponentMetrics.trustBadgeFullCircle,
- height: ComponentMetrics.trustBadgeFullCircle
- )
+ .frame(width: fullCircle, height: fullCircle)
// The full variant escapes the pill's contrast problem by
// inverting: a solid success fill with the glyph knocked out
- // of it in --paper (components.md line 176).
+ // of it in --paper (components.md → TrustBadge).
Image(systemName: "checkmark.shield.fill")
- .font(.system(size: 19, weight: .semibold))
+ .font(.system(size: fullGlyph, weight: .semibold))
.foregroundStyle(Color.paper)
.accessibilityHidden(true)
}
- VStack(alignment: .leading, spacing: 2) {
+ VStack(alignment: .leading, spacing: Spacing.space1) {
Text("End-to-end encrypted")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textPrimary)
@@ -89,23 +86,24 @@ struct TrustBadge: View {
)
.overlay(
RoundedRectangle(cornerRadius: Radius.md, style: .continuous)
- .strokeBorder(Color.statusSuccess.opacity(0.22), lineWidth: BorderWidth.hairline)
+ .strokeBorder(
+ Color.statusSuccess.opacity(ComponentMetrics.statusBorderOpacity),
+ lineWidth: BorderWidth.hairline)
)
.accessibilityElement(children: .combine)
}
- // Claim only what is true (components.md line 178): PAKE-authenticated,
- // end-to-end encrypted, relay sees ciphertext only — never more than that.
+ // Claim only what is true (content.md → Claims): never more than that.
private var relayLine: String {
switch relay {
case .localOnly:
- return "Local network only — nothing leaves your network"
+ return "Local network only: nothing leaves your network"
case .directToSender:
return "Straight to the sender, no relay in between"
case .custom(let address):
- return "Via custom relay \(address) — it sees only encrypted data"
+ return "Via custom relay \(address), which sees only encrypted data"
case .publicDefault:
- return "Via the public croc relay — it sees only encrypted data"
+ return "Via the public croc relay, which sees only encrypted data"
}
}
}
diff --git a/app/CrocAppTests/FriendlyMessageTests.swift b/app/CrocAppTests/FriendlyMessageTests.swift
index a0adda5d..b2e26f67 100644
--- a/app/CrocAppTests/FriendlyMessageTests.swift
+++ b/app/CrocAppTests/FriendlyMessageTests.swift
@@ -27,9 +27,9 @@ import Testing
(.relayUnreachable, "Couldn't reach the relay. Check your internet connection."),
(
.noPublicRelay,
- "No public relay answered. Check your internet connection, or turn on Local network only in Settings if both devices are on the same network."
+ "No public relay answered. Check your internet connection, or turn on Local Network Only in Settings if both devices are on the same network."
),
- (.declined, "The other side declined or cancelled the transfer."),
+ (.declined, "The other device declined or cancelled the transfer."),
(.refused, "You declined the transfer."),
(.roomFull, "That code is already in use. Try a different code."),
(.badPassword, "The relay rejected its password. Check the relay password in Settings."),
@@ -38,12 +38,12 @@ import Testing
(.connectionLost, "Lost connection to the other device."),
(
.storeLimit,
- "The transfer server refused this upload: it is over the server's size, file count or download limit, or too many uploads were made recently."
+ "The link server refused this send: it is over the server's size, file count or download limit, or too many sends were made recently."
),
(.storeExpired, "This link has expired, was revoked, or has no downloads left."),
(
.invalidSetting,
- "An Advanced setting can't be used. Check the proxies, addresses, ports and upload speed limit in Settings > Advanced."
+ "An Advanced setting can't be used. Check the proxies, addresses, ports and send speed limit in Settings > Advanced."
),
(.other, "Transfer failed. Try again, or check your connection."),
])
@@ -59,7 +59,7 @@ import Testing
declineRequested: false, stored: true)
#expect(
copy
- == "Couldn't reach the transfer server. Check your internet connection and try again."
+ == "Couldn't reach the link server. Check your internet connection and try again."
)
}
diff --git a/app/CrocShare/ShareStagingView.swift b/app/CrocShare/ShareStagingView.swift
index faf1a101..bad5e806 100644
--- a/app/CrocShare/ShareStagingView.swift
+++ b/app/CrocShare/ShareStagingView.swift
@@ -28,7 +28,8 @@ struct ShareStagingView: View {
}
case .failed(let error):
ContentUnavailableView {
- Label("Couldn't prepare files", systemImage: "exclamationmark.triangle")
+ Label(
+ "Couldn't prepare files", systemImage: "exclamationmark.triangle.fill")
} description: {
Text(error.localizedDescription)
}
diff --git a/assets/screenshots/app-store/ipad-13/01-send-files-light.png b/assets/screenshots/app-store/ipad-13/01-send-files-light.png
index b82a95ec..2ae9a2a9 100644
Binary files a/assets/screenshots/app-store/ipad-13/01-send-files-light.png and b/assets/screenshots/app-store/ipad-13/01-send-files-light.png differ
diff --git a/assets/screenshots/app-store/ipad-13/02-send-code-dark.png b/assets/screenshots/app-store/ipad-13/02-send-code-dark.png
index 0cdf1ff1..58197184 100644
Binary files a/assets/screenshots/app-store/ipad-13/02-send-code-dark.png and b/assets/screenshots/app-store/ipad-13/02-send-code-dark.png differ
diff --git a/assets/screenshots/app-store/ipad-13/03-sending-light.png b/assets/screenshots/app-store/ipad-13/03-sending-light.png
index 64798aa4..f7f6cbd5 100644
Binary files a/assets/screenshots/app-store/ipad-13/03-sending-light.png and b/assets/screenshots/app-store/ipad-13/03-sending-light.png differ
diff --git a/assets/screenshots/app-store/ipad-13/04-receive-incoming-dark.png b/assets/screenshots/app-store/ipad-13/04-receive-incoming-dark.png
index cdc691d7..181cc20e 100644
Binary files a/assets/screenshots/app-store/ipad-13/04-receive-incoming-dark.png and b/assets/screenshots/app-store/ipad-13/04-receive-incoming-dark.png differ
diff --git a/assets/screenshots/app-store/ipad-13/05-receiving-light.png b/assets/screenshots/app-store/ipad-13/05-receiving-light.png
index 0f323448..8bc14880 100644
Binary files a/assets/screenshots/app-store/ipad-13/05-receiving-light.png and b/assets/screenshots/app-store/ipad-13/05-receiving-light.png differ
diff --git a/assets/screenshots/app-store/ipad-13/06-receive-done-dark.png b/assets/screenshots/app-store/ipad-13/06-receive-done-dark.png
index ca10ef7f..f7d5f798 100644
Binary files a/assets/screenshots/app-store/ipad-13/06-receive-done-dark.png and b/assets/screenshots/app-store/ipad-13/06-receive-done-dark.png differ
diff --git a/assets/screenshots/app-store/ipad-13/07-stored-link-light.png b/assets/screenshots/app-store/ipad-13/07-stored-link-light.png
index 659d5e66..a3e818ff 100644
Binary files a/assets/screenshots/app-store/ipad-13/07-stored-link-light.png and b/assets/screenshots/app-store/ipad-13/07-stored-link-light.png differ
diff --git a/assets/screenshots/app-store/ipad-13/08-history-dark.png b/assets/screenshots/app-store/ipad-13/08-history-dark.png
index 2a6739d3..370b05aa 100644
Binary files a/assets/screenshots/app-store/ipad-13/08-history-dark.png and b/assets/screenshots/app-store/ipad-13/08-history-dark.png differ
diff --git a/assets/screenshots/app-store/ipad-13/09-settings-light.png b/assets/screenshots/app-store/ipad-13/09-settings-light.png
index 39e0e207..c932e255 100644
Binary files a/assets/screenshots/app-store/ipad-13/09-settings-light.png and b/assets/screenshots/app-store/ipad-13/09-settings-light.png differ
diff --git a/assets/screenshots/app-store/ipad-13/10-how-it-works-dark.png b/assets/screenshots/app-store/ipad-13/10-how-it-works-dark.png
index 7bc37e65..540a8bb6 100644
Binary files a/assets/screenshots/app-store/ipad-13/10-how-it-works-dark.png and b/assets/screenshots/app-store/ipad-13/10-how-it-works-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/01-send-files-light.png b/assets/screenshots/app-store/iphone-6.3/01-send-files-light.png
index 2c0804c8..398b7e72 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/01-send-files-light.png and b/assets/screenshots/app-store/iphone-6.3/01-send-files-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/02-send-code-dark.png b/assets/screenshots/app-store/iphone-6.3/02-send-code-dark.png
index 6cb331fe..fb578e5e 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/02-send-code-dark.png and b/assets/screenshots/app-store/iphone-6.3/02-send-code-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/03-sending-light.png b/assets/screenshots/app-store/iphone-6.3/03-sending-light.png
index c7365a08..a2fdd56e 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/03-sending-light.png and b/assets/screenshots/app-store/iphone-6.3/03-sending-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/04-receive-incoming-dark.png b/assets/screenshots/app-store/iphone-6.3/04-receive-incoming-dark.png
index 8351f490..41c9fd03 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/04-receive-incoming-dark.png and b/assets/screenshots/app-store/iphone-6.3/04-receive-incoming-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/05-receiving-light.png b/assets/screenshots/app-store/iphone-6.3/05-receiving-light.png
index 981cf617..c5ceaebd 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/05-receiving-light.png and b/assets/screenshots/app-store/iphone-6.3/05-receiving-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/06-receive-done-dark.png b/assets/screenshots/app-store/iphone-6.3/06-receive-done-dark.png
index 9e40f36f..133b200f 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/06-receive-done-dark.png and b/assets/screenshots/app-store/iphone-6.3/06-receive-done-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/07-stored-link-light.png b/assets/screenshots/app-store/iphone-6.3/07-stored-link-light.png
index f2acfcfe..5f9e676d 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/07-stored-link-light.png and b/assets/screenshots/app-store/iphone-6.3/07-stored-link-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/08-history-dark.png b/assets/screenshots/app-store/iphone-6.3/08-history-dark.png
index 9dc1e40a..1fa96c36 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/08-history-dark.png and b/assets/screenshots/app-store/iphone-6.3/08-history-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/09-settings-light.png b/assets/screenshots/app-store/iphone-6.3/09-settings-light.png
index af398e0e..d39c8a17 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/09-settings-light.png and b/assets/screenshots/app-store/iphone-6.3/09-settings-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.3/10-how-it-works-dark.png b/assets/screenshots/app-store/iphone-6.3/10-how-it-works-dark.png
index 6912f942..185e8868 100644
Binary files a/assets/screenshots/app-store/iphone-6.3/10-how-it-works-dark.png and b/assets/screenshots/app-store/iphone-6.3/10-how-it-works-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/01-send-files-light.png b/assets/screenshots/app-store/iphone-6.9/01-send-files-light.png
index 20d1d4aa..8b60c6de 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/01-send-files-light.png and b/assets/screenshots/app-store/iphone-6.9/01-send-files-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/02-send-code-dark.png b/assets/screenshots/app-store/iphone-6.9/02-send-code-dark.png
index fc2eb0bc..86aa131e 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/02-send-code-dark.png and b/assets/screenshots/app-store/iphone-6.9/02-send-code-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/03-sending-light.png b/assets/screenshots/app-store/iphone-6.9/03-sending-light.png
index b2937c8c..b7ff6e0e 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/03-sending-light.png and b/assets/screenshots/app-store/iphone-6.9/03-sending-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/04-receive-incoming-dark.png b/assets/screenshots/app-store/iphone-6.9/04-receive-incoming-dark.png
index 1c31e308..55556158 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/04-receive-incoming-dark.png and b/assets/screenshots/app-store/iphone-6.9/04-receive-incoming-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/05-receiving-light.png b/assets/screenshots/app-store/iphone-6.9/05-receiving-light.png
index c4207487..8e8b5577 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/05-receiving-light.png and b/assets/screenshots/app-store/iphone-6.9/05-receiving-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/06-receive-done-dark.png b/assets/screenshots/app-store/iphone-6.9/06-receive-done-dark.png
index 2067d19f..8c45d24d 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/06-receive-done-dark.png and b/assets/screenshots/app-store/iphone-6.9/06-receive-done-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/07-stored-link-light.png b/assets/screenshots/app-store/iphone-6.9/07-stored-link-light.png
index 96b9bcf2..aa41bf04 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/07-stored-link-light.png and b/assets/screenshots/app-store/iphone-6.9/07-stored-link-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/08-history-dark.png b/assets/screenshots/app-store/iphone-6.9/08-history-dark.png
index 0407d635..271fc365 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/08-history-dark.png and b/assets/screenshots/app-store/iphone-6.9/08-history-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/09-settings-light.png b/assets/screenshots/app-store/iphone-6.9/09-settings-light.png
index bcb2e81b..7d4da67a 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/09-settings-light.png and b/assets/screenshots/app-store/iphone-6.9/09-settings-light.png differ
diff --git a/assets/screenshots/app-store/iphone-6.9/10-how-it-works-dark.png b/assets/screenshots/app-store/iphone-6.9/10-how-it-works-dark.png
index 961ae989..44c820bf 100644
Binary files a/assets/screenshots/app-store/iphone-6.9/10-how-it-works-dark.png and b/assets/screenshots/app-store/iphone-6.9/10-how-it-works-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/01-send-files-light.png b/assets/screenshots/app-store/iphone-duo-outer/01-send-files-light.png
index fcf61eea..6321c0ef 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/01-send-files-light.png and b/assets/screenshots/app-store/iphone-duo-outer/01-send-files-light.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/02-send-code-dark.png b/assets/screenshots/app-store/iphone-duo-outer/02-send-code-dark.png
index 7c7ea8c4..593ee5ca 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/02-send-code-dark.png and b/assets/screenshots/app-store/iphone-duo-outer/02-send-code-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/03-sending-light.png b/assets/screenshots/app-store/iphone-duo-outer/03-sending-light.png
index b7fb3145..599a6605 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/03-sending-light.png and b/assets/screenshots/app-store/iphone-duo-outer/03-sending-light.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/04-receive-incoming-dark.png b/assets/screenshots/app-store/iphone-duo-outer/04-receive-incoming-dark.png
index bbb83d8e..4b1b4cf7 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/04-receive-incoming-dark.png and b/assets/screenshots/app-store/iphone-duo-outer/04-receive-incoming-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/05-receiving-light.png b/assets/screenshots/app-store/iphone-duo-outer/05-receiving-light.png
index d3d46b16..1e618daa 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/05-receiving-light.png and b/assets/screenshots/app-store/iphone-duo-outer/05-receiving-light.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/06-receive-done-dark.png b/assets/screenshots/app-store/iphone-duo-outer/06-receive-done-dark.png
index ad120f8c..a36cf84e 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/06-receive-done-dark.png and b/assets/screenshots/app-store/iphone-duo-outer/06-receive-done-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/07-stored-link-light.png b/assets/screenshots/app-store/iphone-duo-outer/07-stored-link-light.png
index c391751b..2614b68f 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/07-stored-link-light.png and b/assets/screenshots/app-store/iphone-duo-outer/07-stored-link-light.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/08-history-dark.png b/assets/screenshots/app-store/iphone-duo-outer/08-history-dark.png
index 00fe7fb9..a42a270e 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/08-history-dark.png and b/assets/screenshots/app-store/iphone-duo-outer/08-history-dark.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/09-settings-light.png b/assets/screenshots/app-store/iphone-duo-outer/09-settings-light.png
index 138fd849..cc0f0fcd 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/09-settings-light.png and b/assets/screenshots/app-store/iphone-duo-outer/09-settings-light.png differ
diff --git a/assets/screenshots/app-store/iphone-duo-outer/10-how-it-works-dark.png b/assets/screenshots/app-store/iphone-duo-outer/10-how-it-works-dark.png
index 25d7a25d..9a7091fb 100644
Binary files a/assets/screenshots/app-store/iphone-duo-outer/10-how-it-works-dark.png and b/assets/screenshots/app-store/iphone-duo-outer/10-how-it-works-dark.png differ
diff --git a/assets/screenshots/app-store/mac/01-send-files-light.png b/assets/screenshots/app-store/mac/01-send-files-light.png
index 9b8e6916..ca0437a9 100644
Binary files a/assets/screenshots/app-store/mac/01-send-files-light.png and b/assets/screenshots/app-store/mac/01-send-files-light.png differ
diff --git a/assets/screenshots/app-store/mac/02-send-code-dark.png b/assets/screenshots/app-store/mac/02-send-code-dark.png
index e3b6103f..976d43dc 100644
Binary files a/assets/screenshots/app-store/mac/02-send-code-dark.png and b/assets/screenshots/app-store/mac/02-send-code-dark.png differ
diff --git a/assets/screenshots/app-store/mac/03-sending-light.png b/assets/screenshots/app-store/mac/03-sending-light.png
index dd24ed13..38f729cb 100644
Binary files a/assets/screenshots/app-store/mac/03-sending-light.png and b/assets/screenshots/app-store/mac/03-sending-light.png differ
diff --git a/assets/screenshots/app-store/mac/04-receive-incoming-dark.png b/assets/screenshots/app-store/mac/04-receive-incoming-dark.png
index cb9877fa..ba13c23a 100644
Binary files a/assets/screenshots/app-store/mac/04-receive-incoming-dark.png and b/assets/screenshots/app-store/mac/04-receive-incoming-dark.png differ
diff --git a/assets/screenshots/app-store/mac/05-receiving-light.png b/assets/screenshots/app-store/mac/05-receiving-light.png
index bb9cb7d3..c50dbaf6 100644
Binary files a/assets/screenshots/app-store/mac/05-receiving-light.png and b/assets/screenshots/app-store/mac/05-receiving-light.png differ
diff --git a/assets/screenshots/app-store/mac/06-receive-done-dark.png b/assets/screenshots/app-store/mac/06-receive-done-dark.png
index 5ecaed13..d1f6f6b3 100644
Binary files a/assets/screenshots/app-store/mac/06-receive-done-dark.png and b/assets/screenshots/app-store/mac/06-receive-done-dark.png differ
diff --git a/assets/screenshots/app-store/mac/07-stored-link-light.png b/assets/screenshots/app-store/mac/07-stored-link-light.png
index 55e7d792..216d6f90 100644
Binary files a/assets/screenshots/app-store/mac/07-stored-link-light.png and b/assets/screenshots/app-store/mac/07-stored-link-light.png differ
diff --git a/assets/screenshots/app-store/mac/08-history-dark.png b/assets/screenshots/app-store/mac/08-history-dark.png
index ee9daa6a..fe6941eb 100644
Binary files a/assets/screenshots/app-store/mac/08-history-dark.png and b/assets/screenshots/app-store/mac/08-history-dark.png differ
diff --git a/assets/screenshots/app-store/mac/09-settings-light.png b/assets/screenshots/app-store/mac/09-settings-light.png
index b471c7b1..b6f31b82 100644
Binary files a/assets/screenshots/app-store/mac/09-settings-light.png and b/assets/screenshots/app-store/mac/09-settings-light.png differ
diff --git a/assets/screenshots/app-store/mac/10-how-it-works-dark.png b/assets/screenshots/app-store/mac/10-how-it-works-dark.png
index e72875a1..32986621 100644
Binary files a/assets/screenshots/app-store/mac/10-how-it-works-dark.png and b/assets/screenshots/app-store/mac/10-how-it-works-dark.png differ
diff --git a/assets/screenshots/ipad/history-dark.png b/assets/screenshots/ipad/history-dark.png
index 4fadfad2..ea3d96fd 100644
Binary files a/assets/screenshots/ipad/history-dark.png and b/assets/screenshots/ipad/history-dark.png differ
diff --git a/assets/screenshots/ipad/history-light.png b/assets/screenshots/ipad/history-light.png
index ae8543cb..ed5a5a2e 100644
Binary files a/assets/screenshots/ipad/history-light.png and b/assets/screenshots/ipad/history-light.png differ
diff --git a/assets/screenshots/ipad/home-dark.png b/assets/screenshots/ipad/home-dark.png
index ab20f260..01db5338 100644
Binary files a/assets/screenshots/ipad/home-dark.png and b/assets/screenshots/ipad/home-dark.png differ
diff --git a/assets/screenshots/ipad/home-light.png b/assets/screenshots/ipad/home-light.png
index cf1447bf..b7268581 100644
Binary files a/assets/screenshots/ipad/home-light.png and b/assets/screenshots/ipad/home-light.png differ
diff --git a/assets/screenshots/ipad/how-it-works-dark.png b/assets/screenshots/ipad/how-it-works-dark.png
index 6b4d95c5..ebc7f069 100644
Binary files a/assets/screenshots/ipad/how-it-works-dark.png and b/assets/screenshots/ipad/how-it-works-dark.png differ
diff --git a/assets/screenshots/ipad/how-it-works-light.png b/assets/screenshots/ipad/how-it-works-light.png
index 6f2c5700..985206ba 100644
Binary files a/assets/screenshots/ipad/how-it-works-light.png and b/assets/screenshots/ipad/how-it-works-light.png differ
diff --git a/assets/screenshots/ipad/onboarding-dark.png b/assets/screenshots/ipad/onboarding-dark.png
index cc1cc38d..b879a273 100644
Binary files a/assets/screenshots/ipad/onboarding-dark.png and b/assets/screenshots/ipad/onboarding-dark.png differ
diff --git a/assets/screenshots/ipad/onboarding-light.png b/assets/screenshots/ipad/onboarding-light.png
index 245f91dc..884708ed 100644
Binary files a/assets/screenshots/ipad/onboarding-light.png and b/assets/screenshots/ipad/onboarding-light.png differ
diff --git a/assets/screenshots/ipad/receive-code-dark.png b/assets/screenshots/ipad/receive-code-dark.png
index 6b4e453f..b065dffc 100644
Binary files a/assets/screenshots/ipad/receive-code-dark.png and b/assets/screenshots/ipad/receive-code-dark.png differ
diff --git a/assets/screenshots/ipad/receive-code-light.png b/assets/screenshots/ipad/receive-code-light.png
index 10ead291..0a4b7bde 100644
Binary files a/assets/screenshots/ipad/receive-code-light.png and b/assets/screenshots/ipad/receive-code-light.png differ
diff --git a/assets/screenshots/ipad/receive-conflict-dark.png b/assets/screenshots/ipad/receive-conflict-dark.png
index cb21f03b..116ddf8b 100644
Binary files a/assets/screenshots/ipad/receive-conflict-dark.png and b/assets/screenshots/ipad/receive-conflict-dark.png differ
diff --git a/assets/screenshots/ipad/receive-conflict-light.png b/assets/screenshots/ipad/receive-conflict-light.png
index 863d7960..11bf45af 100644
Binary files a/assets/screenshots/ipad/receive-conflict-light.png and b/assets/screenshots/ipad/receive-conflict-light.png differ
diff --git a/assets/screenshots/ipad/receive-done-dark.png b/assets/screenshots/ipad/receive-done-dark.png
index ed4dc7ee..1bdac7c4 100644
Binary files a/assets/screenshots/ipad/receive-done-dark.png and b/assets/screenshots/ipad/receive-done-dark.png differ
diff --git a/assets/screenshots/ipad/receive-done-light.png b/assets/screenshots/ipad/receive-done-light.png
index c98aa229..025effc3 100644
Binary files a/assets/screenshots/ipad/receive-done-light.png and b/assets/screenshots/ipad/receive-done-light.png differ
diff --git a/assets/screenshots/ipad/receive-incoming-dark.png b/assets/screenshots/ipad/receive-incoming-dark.png
index b2723c58..7482ad36 100644
Binary files a/assets/screenshots/ipad/receive-incoming-dark.png and b/assets/screenshots/ipad/receive-incoming-dark.png differ
diff --git a/assets/screenshots/ipad/receive-incoming-light.png b/assets/screenshots/ipad/receive-incoming-light.png
index 09eec0f4..26939e8d 100644
Binary files a/assets/screenshots/ipad/receive-incoming-light.png and b/assets/screenshots/ipad/receive-incoming-light.png differ
diff --git a/assets/screenshots/ipad/receiving-dark.png b/assets/screenshots/ipad/receiving-dark.png
index eab86fad..36f46e6f 100644
Binary files a/assets/screenshots/ipad/receiving-dark.png and b/assets/screenshots/ipad/receiving-dark.png differ
diff --git a/assets/screenshots/ipad/receiving-light.png b/assets/screenshots/ipad/receiving-light.png
index d83900ad..8a643f19 100644
Binary files a/assets/screenshots/ipad/receiving-light.png and b/assets/screenshots/ipad/receiving-light.png differ
diff --git a/assets/screenshots/ipad/send-code-dark.png b/assets/screenshots/ipad/send-code-dark.png
index 3a60014b..efc662de 100644
Binary files a/assets/screenshots/ipad/send-code-dark.png and b/assets/screenshots/ipad/send-code-dark.png differ
diff --git a/assets/screenshots/ipad/send-code-light.png b/assets/screenshots/ipad/send-code-light.png
index 1c1b3ab3..85815b23 100644
Binary files a/assets/screenshots/ipad/send-code-light.png and b/assets/screenshots/ipad/send-code-light.png differ
diff --git a/assets/screenshots/ipad/send-files-dark.png b/assets/screenshots/ipad/send-files-dark.png
index 006cb747..fcf7d4b4 100644
Binary files a/assets/screenshots/ipad/send-files-dark.png and b/assets/screenshots/ipad/send-files-dark.png differ
diff --git a/assets/screenshots/ipad/send-files-light.png b/assets/screenshots/ipad/send-files-light.png
index dce953bc..f4ffbf0b 100644
Binary files a/assets/screenshots/ipad/send-files-light.png and b/assets/screenshots/ipad/send-files-light.png differ
diff --git a/assets/screenshots/ipad/sending-dark.png b/assets/screenshots/ipad/sending-dark.png
index 5bbce66f..2d71b80b 100644
Binary files a/assets/screenshots/ipad/sending-dark.png and b/assets/screenshots/ipad/sending-dark.png differ
diff --git a/assets/screenshots/ipad/sending-light.png b/assets/screenshots/ipad/sending-light.png
index e3f97cb3..44330f59 100644
Binary files a/assets/screenshots/ipad/sending-light.png and b/assets/screenshots/ipad/sending-light.png differ
diff --git a/assets/screenshots/ipad/settings-advanced-dark.png b/assets/screenshots/ipad/settings-advanced-dark.png
index 1db5e50d..a8c83102 100644
Binary files a/assets/screenshots/ipad/settings-advanced-dark.png and b/assets/screenshots/ipad/settings-advanced-dark.png differ
diff --git a/assets/screenshots/ipad/settings-advanced-light.png b/assets/screenshots/ipad/settings-advanced-light.png
index 53fc5256..f61739fd 100644
Binary files a/assets/screenshots/ipad/settings-advanced-light.png and b/assets/screenshots/ipad/settings-advanced-light.png differ
diff --git a/assets/screenshots/ipad/settings-dark.png b/assets/screenshots/ipad/settings-dark.png
index 9aff5f33..b40e397b 100644
Binary files a/assets/screenshots/ipad/settings-dark.png and b/assets/screenshots/ipad/settings-dark.png differ
diff --git a/assets/screenshots/ipad/settings-light.png b/assets/screenshots/ipad/settings-light.png
index 8fc85853..96bb777d 100644
Binary files a/assets/screenshots/ipad/settings-light.png and b/assets/screenshots/ipad/settings-light.png differ
diff --git a/assets/screenshots/ipad/stored-link-dark.png b/assets/screenshots/ipad/stored-link-dark.png
index ecd8a1c2..a03e26c4 100644
Binary files a/assets/screenshots/ipad/stored-link-dark.png and b/assets/screenshots/ipad/stored-link-dark.png differ
diff --git a/assets/screenshots/ipad/stored-link-light.png b/assets/screenshots/ipad/stored-link-light.png
index 3470cd91..509f20c3 100644
Binary files a/assets/screenshots/ipad/stored-link-light.png and b/assets/screenshots/ipad/stored-link-light.png differ
diff --git a/assets/screenshots/iphone/history-dark.png b/assets/screenshots/iphone/history-dark.png
index 7ff7ceae..713de1f7 100644
Binary files a/assets/screenshots/iphone/history-dark.png and b/assets/screenshots/iphone/history-dark.png differ
diff --git a/assets/screenshots/iphone/history-light.png b/assets/screenshots/iphone/history-light.png
index 1d2823ba..7cd513b2 100644
Binary files a/assets/screenshots/iphone/history-light.png and b/assets/screenshots/iphone/history-light.png differ
diff --git a/assets/screenshots/iphone/home-dark.png b/assets/screenshots/iphone/home-dark.png
index 344fe2de..61e9faf2 100644
Binary files a/assets/screenshots/iphone/home-dark.png and b/assets/screenshots/iphone/home-dark.png differ
diff --git a/assets/screenshots/iphone/home-light.png b/assets/screenshots/iphone/home-light.png
index bbc5e494..58cb77fe 100644
Binary files a/assets/screenshots/iphone/home-light.png and b/assets/screenshots/iphone/home-light.png differ
diff --git a/assets/screenshots/iphone/how-it-works-dark.png b/assets/screenshots/iphone/how-it-works-dark.png
index beafc567..ffc0a350 100644
Binary files a/assets/screenshots/iphone/how-it-works-dark.png and b/assets/screenshots/iphone/how-it-works-dark.png differ
diff --git a/assets/screenshots/iphone/how-it-works-light.png b/assets/screenshots/iphone/how-it-works-light.png
index b5671dab..0ce92c16 100644
Binary files a/assets/screenshots/iphone/how-it-works-light.png and b/assets/screenshots/iphone/how-it-works-light.png differ
diff --git a/assets/screenshots/iphone/onboarding-dark.png b/assets/screenshots/iphone/onboarding-dark.png
index f9c3063c..f1f3a66d 100644
Binary files a/assets/screenshots/iphone/onboarding-dark.png and b/assets/screenshots/iphone/onboarding-dark.png differ
diff --git a/assets/screenshots/iphone/onboarding-light.png b/assets/screenshots/iphone/onboarding-light.png
index 7a412977..04267b1b 100644
Binary files a/assets/screenshots/iphone/onboarding-light.png and b/assets/screenshots/iphone/onboarding-light.png differ
diff --git a/assets/screenshots/iphone/receive-code-dark.png b/assets/screenshots/iphone/receive-code-dark.png
index 9a4afe68..f6ee8958 100644
Binary files a/assets/screenshots/iphone/receive-code-dark.png and b/assets/screenshots/iphone/receive-code-dark.png differ
diff --git a/assets/screenshots/iphone/receive-code-light.png b/assets/screenshots/iphone/receive-code-light.png
index 3563177d..d09211db 100644
Binary files a/assets/screenshots/iphone/receive-code-light.png and b/assets/screenshots/iphone/receive-code-light.png differ
diff --git a/assets/screenshots/iphone/receive-conflict-dark.png b/assets/screenshots/iphone/receive-conflict-dark.png
index 2c9b9771..95d1ab5d 100644
Binary files a/assets/screenshots/iphone/receive-conflict-dark.png and b/assets/screenshots/iphone/receive-conflict-dark.png differ
diff --git a/assets/screenshots/iphone/receive-conflict-light.png b/assets/screenshots/iphone/receive-conflict-light.png
index 2f458053..3a68a399 100644
Binary files a/assets/screenshots/iphone/receive-conflict-light.png and b/assets/screenshots/iphone/receive-conflict-light.png differ
diff --git a/assets/screenshots/iphone/receive-done-dark.png b/assets/screenshots/iphone/receive-done-dark.png
index 533091fb..68a0749e 100644
Binary files a/assets/screenshots/iphone/receive-done-dark.png and b/assets/screenshots/iphone/receive-done-dark.png differ
diff --git a/assets/screenshots/iphone/receive-done-light.png b/assets/screenshots/iphone/receive-done-light.png
index ac45ede5..89e6aa77 100644
Binary files a/assets/screenshots/iphone/receive-done-light.png and b/assets/screenshots/iphone/receive-done-light.png differ
diff --git a/assets/screenshots/iphone/receive-incoming-dark.png b/assets/screenshots/iphone/receive-incoming-dark.png
index ff7032a1..d25c4f52 100644
Binary files a/assets/screenshots/iphone/receive-incoming-dark.png and b/assets/screenshots/iphone/receive-incoming-dark.png differ
diff --git a/assets/screenshots/iphone/receive-incoming-light.png b/assets/screenshots/iphone/receive-incoming-light.png
index 0e0372a1..fd0249ea 100644
Binary files a/assets/screenshots/iphone/receive-incoming-light.png and b/assets/screenshots/iphone/receive-incoming-light.png differ
diff --git a/assets/screenshots/iphone/receiving-dark.png b/assets/screenshots/iphone/receiving-dark.png
index e9355969..10f40b05 100644
Binary files a/assets/screenshots/iphone/receiving-dark.png and b/assets/screenshots/iphone/receiving-dark.png differ
diff --git a/assets/screenshots/iphone/receiving-light.png b/assets/screenshots/iphone/receiving-light.png
index 2a6580a7..81ad143e 100644
Binary files a/assets/screenshots/iphone/receiving-light.png and b/assets/screenshots/iphone/receiving-light.png differ
diff --git a/assets/screenshots/iphone/send-code-dark.png b/assets/screenshots/iphone/send-code-dark.png
index 5e0c5382..1d323f63 100644
Binary files a/assets/screenshots/iphone/send-code-dark.png and b/assets/screenshots/iphone/send-code-dark.png differ
diff --git a/assets/screenshots/iphone/send-code-light.png b/assets/screenshots/iphone/send-code-light.png
index 2aa5987c..49ce2b04 100644
Binary files a/assets/screenshots/iphone/send-code-light.png and b/assets/screenshots/iphone/send-code-light.png differ
diff --git a/assets/screenshots/iphone/send-files-dark.png b/assets/screenshots/iphone/send-files-dark.png
index 1cbc158c..a1168611 100644
Binary files a/assets/screenshots/iphone/send-files-dark.png and b/assets/screenshots/iphone/send-files-dark.png differ
diff --git a/assets/screenshots/iphone/send-files-light.png b/assets/screenshots/iphone/send-files-light.png
index ae0c3fe9..9af9d678 100644
Binary files a/assets/screenshots/iphone/send-files-light.png and b/assets/screenshots/iphone/send-files-light.png differ
diff --git a/assets/screenshots/iphone/sending-dark.png b/assets/screenshots/iphone/sending-dark.png
index 720e76a1..4369db88 100644
Binary files a/assets/screenshots/iphone/sending-dark.png and b/assets/screenshots/iphone/sending-dark.png differ
diff --git a/assets/screenshots/iphone/sending-light.png b/assets/screenshots/iphone/sending-light.png
index 63fafef6..90afc7c4 100644
Binary files a/assets/screenshots/iphone/sending-light.png and b/assets/screenshots/iphone/sending-light.png differ
diff --git a/assets/screenshots/iphone/settings-advanced-dark.png b/assets/screenshots/iphone/settings-advanced-dark.png
index 4167f3e7..2949d5fc 100644
Binary files a/assets/screenshots/iphone/settings-advanced-dark.png and b/assets/screenshots/iphone/settings-advanced-dark.png differ
diff --git a/assets/screenshots/iphone/settings-advanced-light.png b/assets/screenshots/iphone/settings-advanced-light.png
index 7d8a70b3..6f5c28a5 100644
Binary files a/assets/screenshots/iphone/settings-advanced-light.png and b/assets/screenshots/iphone/settings-advanced-light.png differ
diff --git a/assets/screenshots/iphone/settings-dark.png b/assets/screenshots/iphone/settings-dark.png
index d7c314ab..22095892 100644
Binary files a/assets/screenshots/iphone/settings-dark.png and b/assets/screenshots/iphone/settings-dark.png differ
diff --git a/assets/screenshots/iphone/settings-light.png b/assets/screenshots/iphone/settings-light.png
index 2926613f..6d35316c 100644
Binary files a/assets/screenshots/iphone/settings-light.png and b/assets/screenshots/iphone/settings-light.png differ
diff --git a/assets/screenshots/iphone/stored-link-dark.png b/assets/screenshots/iphone/stored-link-dark.png
index 0a557a66..22b17f37 100644
Binary files a/assets/screenshots/iphone/stored-link-dark.png and b/assets/screenshots/iphone/stored-link-dark.png differ
diff --git a/assets/screenshots/iphone/stored-link-light.png b/assets/screenshots/iphone/stored-link-light.png
index ef5a17bf..337426ac 100644
Binary files a/assets/screenshots/iphone/stored-link-light.png and b/assets/screenshots/iphone/stored-link-light.png differ
diff --git a/assets/screenshots/mac/history-dark.png b/assets/screenshots/mac/history-dark.png
index 1b2c1f9a..71a22fd2 100644
Binary files a/assets/screenshots/mac/history-dark.png and b/assets/screenshots/mac/history-dark.png differ
diff --git a/assets/screenshots/mac/history-light.png b/assets/screenshots/mac/history-light.png
index 766dbb9a..561efeed 100644
Binary files a/assets/screenshots/mac/history-light.png and b/assets/screenshots/mac/history-light.png differ
diff --git a/assets/screenshots/mac/home-dark.png b/assets/screenshots/mac/home-dark.png
index 0133e365..525452e5 100644
Binary files a/assets/screenshots/mac/home-dark.png and b/assets/screenshots/mac/home-dark.png differ
diff --git a/assets/screenshots/mac/home-light.png b/assets/screenshots/mac/home-light.png
index f46374f9..e9746f23 100644
Binary files a/assets/screenshots/mac/home-light.png and b/assets/screenshots/mac/home-light.png differ
diff --git a/assets/screenshots/mac/how-it-works-dark.png b/assets/screenshots/mac/how-it-works-dark.png
index 6f23dc94..dd2d4d20 100644
Binary files a/assets/screenshots/mac/how-it-works-dark.png and b/assets/screenshots/mac/how-it-works-dark.png differ
diff --git a/assets/screenshots/mac/how-it-works-light.png b/assets/screenshots/mac/how-it-works-light.png
index 4672c493..6b1b61e5 100644
Binary files a/assets/screenshots/mac/how-it-works-light.png and b/assets/screenshots/mac/how-it-works-light.png differ
diff --git a/assets/screenshots/mac/receive-code-dark.png b/assets/screenshots/mac/receive-code-dark.png
index 86689a40..1f3778a3 100644
Binary files a/assets/screenshots/mac/receive-code-dark.png and b/assets/screenshots/mac/receive-code-dark.png differ
diff --git a/assets/screenshots/mac/receive-code-light.png b/assets/screenshots/mac/receive-code-light.png
index a35f51f5..3813fa9c 100644
Binary files a/assets/screenshots/mac/receive-code-light.png and b/assets/screenshots/mac/receive-code-light.png differ
diff --git a/assets/screenshots/mac/receive-conflict-dark.png b/assets/screenshots/mac/receive-conflict-dark.png
index c5d3f6d3..1dda1bbf 100644
Binary files a/assets/screenshots/mac/receive-conflict-dark.png and b/assets/screenshots/mac/receive-conflict-dark.png differ
diff --git a/assets/screenshots/mac/receive-conflict-light.png b/assets/screenshots/mac/receive-conflict-light.png
index b85800f6..34295f4e 100644
Binary files a/assets/screenshots/mac/receive-conflict-light.png and b/assets/screenshots/mac/receive-conflict-light.png differ
diff --git a/assets/screenshots/mac/receive-done-dark.png b/assets/screenshots/mac/receive-done-dark.png
index c681a98f..b63d4db5 100644
Binary files a/assets/screenshots/mac/receive-done-dark.png and b/assets/screenshots/mac/receive-done-dark.png differ
diff --git a/assets/screenshots/mac/receive-done-light.png b/assets/screenshots/mac/receive-done-light.png
index 2898b74f..50153c89 100644
Binary files a/assets/screenshots/mac/receive-done-light.png and b/assets/screenshots/mac/receive-done-light.png differ
diff --git a/assets/screenshots/mac/receive-incoming-dark.png b/assets/screenshots/mac/receive-incoming-dark.png
index 9f4105ab..59ef431d 100644
Binary files a/assets/screenshots/mac/receive-incoming-dark.png and b/assets/screenshots/mac/receive-incoming-dark.png differ
diff --git a/assets/screenshots/mac/receive-incoming-light.png b/assets/screenshots/mac/receive-incoming-light.png
index 909fdea9..628679f9 100644
Binary files a/assets/screenshots/mac/receive-incoming-light.png and b/assets/screenshots/mac/receive-incoming-light.png differ
diff --git a/assets/screenshots/mac/receiving-dark.png b/assets/screenshots/mac/receiving-dark.png
index 096b80f5..b7d5156c 100644
Binary files a/assets/screenshots/mac/receiving-dark.png and b/assets/screenshots/mac/receiving-dark.png differ
diff --git a/assets/screenshots/mac/receiving-light.png b/assets/screenshots/mac/receiving-light.png
index 3673f615..64995116 100644
Binary files a/assets/screenshots/mac/receiving-light.png and b/assets/screenshots/mac/receiving-light.png differ
diff --git a/assets/screenshots/mac/send-code-dark.png b/assets/screenshots/mac/send-code-dark.png
index 19dd964b..3bd380bc 100644
Binary files a/assets/screenshots/mac/send-code-dark.png and b/assets/screenshots/mac/send-code-dark.png differ
diff --git a/assets/screenshots/mac/send-code-light.png b/assets/screenshots/mac/send-code-light.png
index 87133ff4..35840573 100644
Binary files a/assets/screenshots/mac/send-code-light.png and b/assets/screenshots/mac/send-code-light.png differ
diff --git a/assets/screenshots/mac/send-files-dark.png b/assets/screenshots/mac/send-files-dark.png
index 7b86f23a..3739ae75 100644
Binary files a/assets/screenshots/mac/send-files-dark.png and b/assets/screenshots/mac/send-files-dark.png differ
diff --git a/assets/screenshots/mac/send-files-light.png b/assets/screenshots/mac/send-files-light.png
index 3d00ec0d..7ded2bea 100644
Binary files a/assets/screenshots/mac/send-files-light.png and b/assets/screenshots/mac/send-files-light.png differ
diff --git a/assets/screenshots/mac/sending-dark.png b/assets/screenshots/mac/sending-dark.png
index f6b2f052..5957e316 100644
Binary files a/assets/screenshots/mac/sending-dark.png and b/assets/screenshots/mac/sending-dark.png differ
diff --git a/assets/screenshots/mac/sending-light.png b/assets/screenshots/mac/sending-light.png
index 21c2096e..ca7b03d2 100644
Binary files a/assets/screenshots/mac/sending-light.png and b/assets/screenshots/mac/sending-light.png differ
diff --git a/assets/screenshots/mac/settings-advanced-dark.png b/assets/screenshots/mac/settings-advanced-dark.png
index 8d4c8411..bdaa03f1 100644
Binary files a/assets/screenshots/mac/settings-advanced-dark.png and b/assets/screenshots/mac/settings-advanced-dark.png differ
diff --git a/assets/screenshots/mac/settings-advanced-light.png b/assets/screenshots/mac/settings-advanced-light.png
index baeaf21e..17bc9615 100644
Binary files a/assets/screenshots/mac/settings-advanced-light.png and b/assets/screenshots/mac/settings-advanced-light.png differ
diff --git a/assets/screenshots/mac/settings-dark.png b/assets/screenshots/mac/settings-dark.png
index 2c79a597..f8b111ae 100644
Binary files a/assets/screenshots/mac/settings-dark.png and b/assets/screenshots/mac/settings-dark.png differ
diff --git a/assets/screenshots/mac/settings-light.png b/assets/screenshots/mac/settings-light.png
index 7b35901f..1f380cdf 100644
Binary files a/assets/screenshots/mac/settings-light.png and b/assets/screenshots/mac/settings-light.png differ
diff --git a/assets/screenshots/mac/stored-link-dark.png b/assets/screenshots/mac/stored-link-dark.png
index a3d51272..12a3c0e6 100644
Binary files a/assets/screenshots/mac/stored-link-dark.png and b/assets/screenshots/mac/stored-link-dark.png differ
diff --git a/assets/screenshots/mac/stored-link-light.png b/assets/screenshots/mac/stored-link-light.png
index 57afa8c8..ba956ce7 100644
Binary files a/assets/screenshots/mac/stored-link-light.png and b/assets/screenshots/mac/stored-link-light.png differ
diff --git a/design/AGENTS.md b/design/AGENTS.md
index 19aa937d..57f83da3 100644
--- a/design/AGENTS.md
+++ b/design/AGENTS.md
@@ -1,89 +1,89 @@
# design/
-Canonical design system for every CrocApp surface: the SwiftUI apps (iOS / macOS / iPadOS), the landing page, and the docs site. Every color, size, type style, motion value and component measurement in the project is defined here. Nothing visual is invented at a call site.
+Canonical design system for every CrocApp surface: the SwiftUI apps (iOS, iPadOS, macOS), the landing page, the docs site, and the release artwork (app icon, DMG window, social cards). Every color, size, type style, motion value, component, glyph and word of UI copy is defined here. Nothing visual is invented at a call site.
## Layout
-| Path | Purpose |
+| Path | Owns |
| --- | --- |
-| `colors.md` | Brand green scale, Apple system semantics, semantic aliases, verified contrast, the seven contrast rules |
-| `typography.md` | Font stacks, app type scale, web display scale, mono sizes, tracking |
-| `spacing-layout.md` | 4pt grid, concentric radii, control sizing, layout caps, breakpoints, borders |
-| `materials.md` | Liquid Glass materials, reduced transparency, elevation, focus ring |
+| `brand.md` | The brandbook: positioning, voice, logo and mascot, color and type at a glance, imagery, naming, attribution, App Store badges |
+| `content.md` | Copy mechanics: capitalization, sentences, errors, numbers, terminology, the approved security claims |
+| `colors.md` | Palette, semantic aliases, verified contrast and the seven contrast rules |
+| `typography.md` | Font stacks, the app type scale, the web display scale, tracking |
+| `spacing-layout.md` | 4pt grid, radii, control sizes, layout caps, web layout, breakpoints, borders |
+| `materials.md` | Liquid Glass, reduced transparency, elevation, focus |
| `motion.md` | Easing, durations, phase choreography, haptics, the no-audio rule |
-| `components.md` | Every component's exact measurements, variants and states |
-| `iconography.md` | SF Symbols (app) ↔ Lucide (web) mapping, sizes, stroke weights |
-| `accessibility.md` | The binding accessibility floor for all three surfaces |
-| `content.md` | Capitalization, sentences, errors, number and date formatting, terminology |
-| `brand.md` | Voice, accent usage, naming, asset inventory |
-| `platforms.md` | What legitimately differs between iPhone, iPad and macOS |
-| `swiftui.md` | Token → SwiftUI / Apple API translation |
-| `web.md` | Token → landing page / docs site translation |
-| `tokens.css` | Every token as a CSS custom property, for web |
+| `components.md` | Every component: which system control it is, or the exact measurements when we draw it |
+| `iconography.md` | SF Symbols (app) to Lucide (web) mapping, sizes, stroke weights |
+| `accessibility.md` | The binding accessibility floor for all surfaces |
+| `platforms.md` | What legitimately differs between iPhone, iPad, macOS and the web |
+| `swiftui.md` | Token to SwiftUI translation |
+| `web.md` | Token to landing page and docs site translation |
+| `tokens.css` | Every token as a CSS custom property |
-Topic files define values. `swiftui.md` and `web.md` are the two platform files — they translate, never redefine.
+Topic files define values. `swiftui.md` and `web.md` translate them and never redefine one.
-`scripts/verify-contrast.sh` recomputes every ratio `colors.md` claims, straight from `tokens.css`, and asserts the seven rules as invariants. Run it after touching any color.
+## Skills
-## Three mirrors, hand-synced
+Load `apple-design` before any design work (this directory, a view, a stylesheet, artwork), plus `swiftui-expert-skill` and `mobile-ios-design` for SwiftUI views. Install and conflict rules are in the root `AGENTS.md` → Skills. When a skill and this directory disagree, this directory wins: the skills teach springs and overshoot, and `motion.md` rules both out.
-The markdown files are canonical. Both mirrors are written by hand and nothing generates either:
+## Mirrors and checks
+
+The markdown is canonical. Two hand-written mirrors carry it into code:
| Mirror | Consumed by |
| --- | --- |
| `tokens.css` | landing page, docs site |
-| `app/CrocApp/Support/DesignTokens.swift` + `app/CrocApp/Assets.xcassets` | the app |
+| `app/CrocApp/Support/DesignTokens.swift` + `app/CrocApp/Assets.xcassets` color sets | the app |
+
+Change a value in a markdown file and in both mirrors in the same commit. `design/tokens.css` is the only committed CSS copy: `scripts/assemble-site.sh` copies it for the landing page and `web/docs`'s `tokens` npm script for the docs site. Never edit a copy.
-Change a value in a markdown file and change it in both mirrors in the same commit, or they drift silently.
+Two scripts enforce this directory, and CI runs both on any change to `design/`, `app/` Swift or color sets, or web CSS:
-`design/tokens.css` is the only committed copy. The web copies are gitignored and written by tooling — `scripts/assemble-site.sh` for the landing page, `web/docs`'s `tokens` npm script for the docs site. Never edit a copy.
+```bash
+scripts/verify-contrast.sh # every ratio colors.md claims, recomputed from tokens.css, plus the seven rules
+scripts/verify-design.sh # mirror parity (tokens.css = DesignTokens.swift = color sets) and the no-literals lint
+```
+
+Run them after touching any value. A failure means the change or this directory is wrong; fix one of them, never the check.
## Rules
-1. **Tokens, not literals.** No raw hex, px, pt or duration in app or site code. If a value is missing, add the token here first.
-2. **SF Symbols only in SwiftUI apps.** Lucide exists purely because SF Symbols cannot ship to the web. Never bundle Lucide, never draw a custom glyph where a symbol exists. See `iconography.md`.
-3. **System font only.** SF Pro / SF Mono through the system font APIs. Apple's font files are not redistributable — never ship one.
-4. **Both themes, always.** Every surface has a light and a dark value: `light-dark()` on web, the asset catalog in SwiftUI. One exception, `components.md` → DiskImage, which is dark only because Finder cannot swap a DMG background.
-5. **Liquid Glass is native.** `.glassEffect()` is the target; the CSS `backdrop-filter` recipe approximates it, never the reverse.
-6. **Light is the constrained theme.** Check contrast there first — dark clears everything. The seven rules in `colors.md` → Contrast bind all three surfaces, and `components.md` is written to them.
+1. **Tokens, not literals.** No raw hex, px, pt or duration in app or site code. If a value is missing, add the token here first. The few allowed literals are listed in `web.md` and `swiftui.md`.
+2. **System first.** Where Apple ships the control (`Toggle`, `Picker`, `Form`, `ProgressView`, toolbars, alerts), the app uses it and this directory only says which one and what to set. We draw a component only when no system control does the job.
+3. **SF Symbols only in the app.** Lucide exists only because SF Symbols cannot ship to the web. See `iconography.md`.
+4. **System font only.** SF Pro and SF Mono through the system font APIs. Apple's font files are not redistributable; never ship one.
+5. **Both themes, always.** Every surface has a light and a dark value: `light-dark()` on web, the asset catalog in the app. The one exception is `components.md` → DiskImage.
+6. **Light is the constrained theme.** Check contrast there first. The seven rules in `colors.md` → Contrast bind every surface.
+7. **One accent, no gradients in UI.** `brand.md` → Color at a glance.
## Naming
-`----`, all lowercase, hyphenated, no abbreviations that are not already in the table below.
+`----`, lowercase, hyphenated.
-| Layer | Prefix | Example | Rule |
+| Layer | Pattern | Example | Rule |
| --- | --- | --- | --- |
-| base palette | the thing itself | `--green-600`, `--label-2`, `--fill-3` | literal values, numeric steps, **never consumed directly by a component** |
-| semantic alias | `--color-` | `--color-accent-text-on-tint` | what it is *for*, never what it looks like. This is the layer you consume |
-| scale step | category + integer | `--space-5`, `--radius-lg` | the number means position in the scale, not the value |
-| platform-scoped | `-web` suffix | `--content-max-width-web` | web-only; an unsuffixed token is shared |
+| base palette | the thing itself | `--green-600`, `--label-2`, `--fill-3` | literal values. **Never consumed by a component**; aliases point at them |
+| semantic alias | `--color-` | `--color-accent-text-on-tint` | what it is for, never what it looks like. The layer you consume |
+| scale step | category + step | `--space-5`, `--radius-lg` | the step is a position in the scale, not the value |
+| web-only | a `web` name segment | `--gutter-web`, `--btn-pad-x-lg-web`, `--type-web-hero-size` | the app never mirrors it. Every token without `web` in its name is shared, and the app mirrors it unless `swiftui.md` → Not mirrored says why not |
-Never encode a value in a name (`--green-600`, not `--green-dark`), never encode a component in a shared token (`--btn-*` and `--pill-*` are the documented exception, and they are web-only).
+Never encode a value in a name (`--green-600`, not `--green-dark`).
-## Adding a token
+## Adding or removing a token
-1. **Check it is really missing.** Most "missing" values are an existing token used at the wrong layer.
-2. Add it to the topic file that owns it, with the role it plays, not just the number.
+1. **Check it is really missing.** Most missing values are an existing token at the wrong layer.
+2. Add it to the topic file that owns it, with the role it plays.
3. Add it to `tokens.css` in the matching group.
-4. Add it to `swiftui.md`, and to `DesignTokens.swift` or a color set if the app needs it. If the app deliberately does not need it, say so in `swiftui.md` rather than leaving a hole.
-5. If it is a color, run `scripts/verify-contrast.sh`, and add a row to `colors.md` → Contrast if anything reads on top of it.
-6. If it is a component measurement, it probably is not a token: `components.md` keeps those as component constants.
-
-## Reserved tokens
-
-Some tokens have no consumer and are still correct to keep. They exist so the app stays visually native, or so a scale is complete:
-
-| Token | Why it stays |
-| --- | --- |
-| `--bg-secondary`, `--bg-tertiary`, `--fill-4`, `--separator-opaque`, `--color-text-quaternary`, `--color-fill-secondary` | Apple semantics. The ladder has to be complete or a view reaches for the wrong rung |
-| `--type-title1`, `--type-caption2` | the app scale is `Font.TextStyle` and must stay whole |
-| `--ink` | the source of truth for the `` literals, which cannot take a `var()` |
+4. If it is shared, add it to `DesignTokens.swift` or a color set and to the parity table in `scripts/verify-design.sh`. If the app deliberately does not mirror it, add it to `swiftui.md` → Not mirrored instead.
+5. If it is a color, run `scripts/verify-contrast.sh` and add a row to `colors.md` → Contrast for anything that reads on top of it.
+6. A component measurement used by one component is a component constant in `components.md`, not a token.
-Anything not on that list and not in use is dead. `--green-50`, `--green-100`, `--ease-spring` and `--tracking-caption` were cut for exactly that reason.
+A token is live when the app or a site consumes it, or when it is a step in a scale. **Scales stay whole** (spacing, radius, type, weight, tracking, control height, icon size, icon stroke, glass, Apple's label/fill/background ladders) so nobody reaches for the wrong rung. Any other token with no consumer is dead: delete it from every file in the same commit.
## Editing
-- Values are cross-referenced heavily. Changing one means grepping `design/` for it, plus both mirrors.
-- Measurements in `components.md` are exact, not approximate. `scripts/build-dmg.sh` takes its geometry from that file's DiskImage section; changing one without the other leaves the file lying.
-- If a screen needs a glyph, a size or a state that is not here, add the row before writing the code.
-- Verified numbers carry the date they were checked (contrast ratios, the Lucide version, the iOS 26 APIs). Re-verifying means moving the date.
+- Values are cross-referenced. Changing one means grepping `design/` and both mirrors for it.
+- `scripts/build-dmg.sh` takes its geometry from `components.md` → DiskImage; change both together.
+- Verified facts carry the date they were checked (contrast ratios, the Lucide version, Apple API names). Re-verifying means moving the date.
+- Cite design sections by name (`components.md` → AcceptGate), never by line number. Line numbers rot.
diff --git a/design/accessibility.md b/design/accessibility.md
index fb811aac..fd1b7444 100644
--- a/design/accessibility.md
+++ b/design/accessibility.md
@@ -1,50 +1,52 @@
# Accessibility
-The binding floor for the app, the landing page and the docs site. Nothing in `components.md` is specced below it. Each section states the requirement; the reasoning lives in the topic file it points at.
+The binding floor for the app, the landing page and the docs site. Nothing in `components.md` is specced below it. Each section states the requirement; the reasoning lives in the file it points at.
## Contrast
-WCAG 2.2 AA: body text ≥ 4.5:1, large text and UI component boundaries ≥ 3:1. **Light is the constrained theme** — check it first, dark clears everything.
+WCAG 2.2 AA: body text 4.5:1, large text and UI boundaries 3:1. **Light is the constrained theme:** check it first. Ratios and the seven rules are in `colors.md` → Contrast and are enforced by `scripts/verify-contrast.sh`. Two decide most work:
-Measured ratios and the seven rules that follow from them are in `colors.md` → Contrast. Two of them decide most component work:
+- **Accent is a fill, not a label.** Accent text is `--color-accent-text`, or `--color-accent-text-on-tint` on grouped and tinted surfaces. In the app that includes every bordered and plain button label.
+- **A status color is a fill, never a foreground.** Status text is `--color-text-primary`.
-- **Accent is a fill, not a label.** `#1E9E6A` is 3.41:1 on white — fine for fills, icons, borders and large text, never for a word. Accent text is `--color-accent-text`, or `--color-accent-text-on-tint` on grouped and tinted surfaces.
-- **A status color is a fill, never a foreground.** Status text is `--color-text-primary`; the tint and border carry the meaning.
-
-Two deviations are documented and accepted at `colors.md` rule 3: filled prominent and destructive buttons ship at 3.41:1 and 3.55:1, on the condition that their label stays ≥ 17px semibold and small sizes use a tinted variant instead.
+The one accepted deviation is the filled button (`colors.md` rule 3).
## Focus
-Every interactive element takes `--shadow-focus-ring` on `:focus-visible`. Never remove focus styling without replacing it. A focus indicator owes 3:1 under SC 1.4.11 — it is not decoration, and it cannot be a tint. Mechanics in `materials.md` → Focus.
+Every interactive element shows a visible focus indicator that owes 3:1 (SC 1.4.11). Web: `--shadow-focus-ring` on `:focus-visible`, plus a transparent outline for forced-colors mode. App: the system indicator. Never remove one without replacing it (`materials.md` → Focus).
## Hit targets
-44×44pt minimum (`--hit-target-min`) for anything interactive, including bare icon buttons whose glyph is smaller than the target.
+44×44pt (`--hit-target-min`) for anything interactive, including small buttons, icon buttons and inline links in nav bars and footers.
## Never color alone
-Transfer direction is carried by the arrow glyph **and** the word "Sending" / "Receiving" — never by color or position (`components.md` → TransferProgress). Same for every status: the title states the outcome in words, the tint and glyph only repeat it.
+Direction is the arrow **and** the word "Sending" or "Receiving". Every status is stated in words; tint and glyph only repeat it. A glyph that is the only carrier of a status (HistoryRow) has an accessibility label.
## Icons and labels
-Web glyphs are always `aria-hidden="true"`; the adjacent label carries the meaning. That is what makes a faint status glyph acceptable and what makes the label mandatory. App glyphs size by text style so they track Dynamic Type. See `iconography.md`.
+Web glyphs are `aria-hidden="true"`; the adjacent label carries the meaning. App glyphs scale with Dynamic Type (`iconography.md` → Rendering).
## Dynamic Type
-In the app use text styles, never a hardcoded point size, and verify at the largest accessibility size. The single literal size in the scale (`--type-code-hero`) still scales, through `@ScaledMetric`.
+Text styles, never a fixed point size; glyph sizes through text styles or `@ScaledMetric`. Verify at the largest accessibility size: nothing clips, glyph frames take minimum widths.
## Reduced motion
-`prefers-reduced-motion` / `.accessibilityReduceMotion`: drop `--press-scale`, drop the indeterminate slide. Keep every state change — instant, not removed. See `motion.md` → Motion.
+No press scale, no indeterminate slide, every state change instant rather than removed (`motion.md` → Motion).
## Reduced transparency
-`prefers-reduced-transparency` / `.accessibilityReduceTransparency`: all three glass materials collapse to `solid`. `tokens.css` handles this at the token level, so a component consuming `--color-surface-glass` needs no branch of its own. See `materials.md` → Reduced transparency.
+Glass collapses to solid (`materials.md` → Reduced transparency).
## Nothing is hover-only
-No hover-only buttons, no hover-only labels, no hover-to-reveal remove controls. Touch users have no pointer and keyboard users never trigger hover, so anything reachable only that way is unreachable for them. Hover may restate, never reveal. See `platforms.md` → Hover.
+Touch and keyboard users never hover. Hover may restate, never reveal (`platforms.md` → Hover).
## Input is never drag-only
-Every DropZone ships beside a real file-picker button. Drag-and-drop is unavailable to keyboard and to most assistive tech, so it can never be the only way to add files (`components.md` → DropZone).
+Every DropZone ships beside a real file-picker button.
+
+## Announcements
+
+Error banners and terminal failures are announced to VoiceOver; progress is not announced on every tick.
diff --git a/design/brand.md b/design/brand.md
index 338dd6c7..1dadc28b 100644
--- a/design/brand.md
+++ b/design/brand.md
@@ -1,48 +1,103 @@
# Brand
+The brandbook. Anyone making something that carries the CrocApp name (a screen, a page, a release image, a store listing, a post) starts here. The detailed rules live in the files this one points at.
+
## Positioning
-CrocApp is a free, open-source, native SwiftUI GUI for [croc](https://github.com/schollz/croc). Unofficial and unaffiliated — never imply endorsement by croc's author. Always attribute croc (MIT, Zack Scholl) on the landing page, the docs site and the About screen.
+CrocApp is a free, open-source, native app for iPhone, iPad and Mac that sends files, folders and text to another device with a short code phrase. It is built on [croc](https://github.com/schollz/croc) by Zack Scholl, and is unofficial and unaffiliated: never imply endorsement by croc's author.
+
+Approved boilerplate, used verbatim:
+
+| Length | Text |
+|---|---|
+| Name | CrocApp |
+| Subtitle (≤ 30) | Send files with a code phrase |
+| One line | Send files, folders and text to another device with a short code phrase. Free, open source and end-to-end encrypted. |
+| Long | the App Store description in `docs/knowledge/app-store-listing.md` |
## Personality
**Trustworthy, private, fast, calm.** In that order when they conflict.
-- Calm over exciting: no countdown urgency, no confetti, no exclamation marks.
-- Plain language over jargon: "end-to-end encrypted", not "PAKE-derived symmetric channel" — the detail belongs on the "How it works" screen, one tap deeper.
-- Never overstate the security claim. What is true: transfers are end-to-end encrypted, the code phrase authenticates both sides, and the relay only ever sees ciphertext. Do not extend past that.
-- Direction is always explicit. "Sending" / "Receiving" as words, never colour or position alone.
-- Errors state what happened and what to do, in one line, without blame: "The other side declined the transfer."
+- Calm over exciting: no urgency, no confetti, no exclamation marks.
+- Plain language over jargon: "end-to-end encrypted", not "PAKE-derived symmetric channel". The detail lives on How It Works, one tap deeper.
+- Never overstate security. Claims come only from the approved list in `content.md` → Claims.
+- Direction is always explicit: "Sending" and "Receiving" as words, never color or position alone.
+- Nothing is accepted without an explicit yes, unless the user turned on auto-accept.
+- Errors say what happened and what to do, in one line, without blame: "The other device declined the transfer."
+
+Copy mechanics (case, terminology, numbers) are `content.md`.
+
+## Logo
+
+**The app icon is the logo.** There is no separate logotype.
+
+- Source: `assets/CrocAppIcon.icon` (Icon Composer). The mascot's body, belly and outline layers sit on a mint gradient, with dark and tinted variants. `app/CrocApp/AppIcon.icon` is the copy Xcode builds; keep the two identical (`assets/AGENTS.md`).
+- The icon's gradient is the one gradient in the brand, because Icon Composer's Liquid Glass material is built from it. No other surface takes a gradient.
+- Show the icon only as the platform renders it, in its system mask. Never redraw, recolor, rotate, crop, outline or add a shadow to it, never put type or a badge on it, and never set it on a busy photo.
+- Clear space around the icon: at least a quarter of its width. Smallest size: 16px (the favicon).
+- **Wordmark**: the word "CrocApp" set in the system font (`--font-sans`, `--weight-bold`) in `--color-text-primary`. It sits beside the icon or the mascot, never inside it. Social cards pair it with the accent `lock` glyph.
+
+## Mascot
+
+The croc character in `assets/mascot.png` (1024px source, never edited, never served).
-## Accent
+- A personality accent, not a UI element. Allowed: the landing footer and 404 page, the docs navbar mark, web empty states, social cards. **Never inside the app**, and never in a transfer flow.
+- Never recolor, flip, crop, stretch or add effects. It carries its own outline, so it works on both themes.
+- Smallest size: 32px. On web it renders from the one derivative, `web/landing/assets/img/mascot-web.webp` (128px), at `--mascot-size-web` or the navbar height.
+- Re-cut the derivative from the source rather than editing it: `sips -Z 128` then `cwebp -q 88 -alpha_q 100 -m 6`. The docs site copies the same file; there is no second derivative.
-Deep croc green, `#1E9E6A` (light) / `#2DC585` (dark). One accent, no secondary brand hue. Status colors are Apple's system semantics and are not brand colors — do not repaint success green to match the accent, the difference is the point.
+## Color at a glance
-Accent is for: primary action fills, active/selected state, links, file-type glyphs, progress fills. Accent is not for: large background washes, gradients, decorative shapes.
+One accent: croc green, `#1E9E6A` light and `#2DC585` dark. No secondary brand hue. Status colors are Apple's system semantics, not brand colors: never repaint success green to match the accent, because the difference is the point. Everything else is Apple's neutral ladder.
-**No gradients.** A teal-to-green gradient or a serif headline means a generated mockup has drifted off-system; re-do it against the tokens rather than editing the drift.
+Accent is for primary actions, selection, links, file-type glyphs and progress. Never for large washes, gradients or decoration. Full palette, aliases and contrast: `colors.md`.
+
+## Type at a glance
+
+SF Pro and SF Mono through the system font, never a shipped font file, never a serif. The code phrase is always mono. A serif headline or a teal-to-green gradient means a generated mockup has drifted off-system: redo it against the tokens. Scale: `typography.md`.
+
+## Imagery
+
+- **Screenshots** come only from `scripts/capture-screenshots.sh`, in light and dark pairs, never hand-edited, cropped or retouched (`docs/knowledge/screenshots.md`). Pages show them unframed; we never draw a device bezel of our own.
+- **Social cards** are hand-made, in the layout below, and do not change without changing both.
+- **Glyphs** are SF Symbols in the app and Lucide on the web (`iconography.md`). No illustration style beyond the mascot.
## Assets
| Asset | Path | Use |
|---|---|---|
-| App icon source | `assets/CrocAppIcon.icon` | Icon Composer source. `app/CrocApp/AppIcon.icon` is the copy Xcode builds; keep the two identical |
-| Screenshots | `assets/screenshots/{mac,iphone,ipad}/-{light,dark}.png` | landing hero, README, docs pages. Regenerated only by `scripts/capture-screenshots.sh` — never hand-edited or hand-cropped |
-| App Store screenshots | `assets/screenshots/app-store//NN--{light,dark}.png` | App Store Connect uploads, written by the same script |
-| Social card | `web/landing/assets/img/og.jpg` | Open Graph / link previews. Hand-made on purpose, not derived from the tokens |
-| GitHub social preview | `assets/github-social-preview.png` | 1280×640, uploaded by hand in the repo's Settings → Social preview. Same layout and mascot as the social card, in dark values (`--bg-secondary`, `--label-1`, `--label-2-web`, `--green-400`), with the repo URL instead of the site |
-| Mascot | `assets/mascot.png` | source, 1024px. Never served |
-| Mascot, web | `web/landing/assets/img/mascot-web.webp` | 128px derivative — landing page, empty states on web, docs 404 |
-| Disk image background | `assets/dmg-background.tiff` | 660×400 at 1x and 2x, dark, the macOS DMG's Finder window. Spec in `components.md` → DiskImage |
+| App icon | `assets/CrocAppIcon.icon` | the logo; the app builds `app/CrocApp/AppIcon.icon` |
+| Mascot | `assets/mascot.png` | source for every mascot derivative |
+| Mascot, web | `web/landing/assets/img/mascot-web.webp` | landing, docs navbar, web empty states |
+| Screenshots | `assets/screenshots/{mac,iphone,ipad}/-{light,dark}.png` | README, docs, landing hero |
+| App Store screenshots | `assets/screenshots/app-store//NN--{light,dark}.png` | App Store Connect |
+| Social card | `web/landing/assets/img/og.jpg` | 1200×630, light: wordmark, accent `lock`, headline, the site URL, mascot on the trailing side |
+| GitHub social preview | `assets/github-social-preview.png` | 1280×640, the same layout in dark values (`--bg-secondary`, `--label-1`, `--label-2-web`, `--green-400`) with the repo URL; uploaded by hand in the repo settings |
+| Disk image background | `assets/dmg-background.tiff` | `components.md` → DiskImage |
+| App Store badges | `web/landing/assets/img/app-store.svg`, `mac-app-store.svg` | Apple's artwork, below |
-The mascot is a personality accent, not a UI element — it never appears inside the app's transfer flows.
+## Naming
-The web derivative exists because the 1024px source is far too heavy to draw a 64px mark. Re-cut it from the source rather than editing it: `sips -Z 128` then `cwebp -q 88 -alpha_q 100 -m 6`.
+"CrocApp": one word, capital C and A. Never "Croc App", "Crocapp" or "CrocAPP". "croc", the CLI, is always lowercase. Bundle ID `com.bakirgdev.CrocApp`; share extension `com.bakirgdev.CrocApp.CrocShare`.
-## Naming
+Apple product names follow Apple's rules: singular, never possessive, never all caps, never abbreviated. "CrocApp for iPhone, iPad and Mac", not "the iPhone app". "App Store" with a capital A and S, and only "the" before it.
+
+## Attribution and legal
+
+| Where | Text |
+|---|---|
+| Landing footer, docs footer, About | "Built on croc by Zack Scholl (MIT). CrocApp is unofficial and not affiliated with croc." |
+| Wherever an App Store badge appears | "Apple, the Apple logo, iPhone, iPad, Mac and App Store are trademarks of Apple Inc., registered in the U.S. and other countries and regions." |
+| License | MIT, copyright holder exactly as in `LICENSE`: "Bakir Gracić" |
+| Lucide sprite | its source comment names Lucide and its ISC license |
-"CrocApp", one word, capital C and A. Bundle ID `com.bakirgdev.CrocApp`; share extension `com.bakirgdev.CrocApp.CrocShare`. "croc" (the CLI) stays lowercase, always. Never write "Croc App" or "CrocAPP".
+## App Store badges
-## Web surfaces
+From Apple's [marketing guidelines](https://developer.apple.com/app-store/marketing/guidelines/) (checked 2026-10-17):
-The landing page and the docs site carry the brand through the same tokens, so the marketing surface and the product look like one thing. How that is applied — page fill, width caps, theming, glass — is `web.md`.
+- Use only Apple's badge artwork. Never modify, recolor, invert, angle or animate it; the gray border of the black badge is part of the artwork.
+- The black badge is preferred, in both themes. Use Apple's white badge only where the black one is visually heavy, and never when another platform's badge sits beside it.
+- Minimum height 40px onscreen (`--store-badge-height-web`). Clear space at least a quarter of the badge height, with nothing inside it.
+- The App Store badge comes first in any row of badges and sits in a subordinate position to the page's own message.
+- Never translate "App Store" or build a localized badge; Apple supplies the localizations.
diff --git a/design/colors.md b/design/colors.md
index 0bcf4f75..52532d8f 100644
--- a/design/colors.md
+++ b/design/colors.md
@@ -1,8 +1,8 @@
# Colors
-Apple system color model (iOS 26 / macOS 26) plus a croc-green brand scale. Base palette holds literal values; **semantic aliases are what you consume**. Light theme on `:root`, dark under `[data-theme="dark"]`.
+Apple system color model (iOS 26, macOS 26) plus a croc-green brand scale. The base palette holds literal values; **semantic aliases are what you consume**. Every themed token is one `light-dark()` pair (`web.md` → Theming); the app reads the same pairs from the asset catalog (`swiftui.md` → Colors).
-## Brand — croc green
+## Brand: croc green
Theme-independent literals. The accent alias swaps which step it points at. The scale starts at 200 because nothing in the system needs a lighter opaque green: washes are `--color-accent-tint`, which is translucent.
@@ -32,7 +32,6 @@ Theme-independent literals. The accent alias swaps which step it points at. The
| Token | Light | Dark |
|---|---|---|
-| `--ink` | `#000000` | `#FFFFFF` |
| `--paper` | `#FFFFFF` | `#000000` |
| `--label-1` | `rgba(0,0,0,1)` | `rgba(255,255,255,1)` |
| `--label-2` | `rgba(60,60,67,0.60)` | `rgba(235,235,245,0.60)` |
@@ -56,7 +55,7 @@ Theme-independent literals. The accent alias swaps which step it points at. The
| `--bg-grouped` | `#F2F2F7` | `#000000` | systemGroupedBackground |
| `--bg-grouped-elevated` | `#FFFFFF` | `#1C1C1E` | secondarySystemGroupedBackground |
-Page default is `--bg-grouped`; cards sit on `--bg-grouped-elevated`.
+The app's page fill is `--bg-grouped` with cards on `--bg-grouped-elevated`. The web's page fill is `--bg-base` (`web.md` → Page fill).
## Glass fills
@@ -71,7 +70,7 @@ Pair each with the matching `--blur-*` and `--glass-saturate` (see `materials.md
| `--glass-border` | `rgba(255,255,255,0.60)` | `rgba(255,255,255,0.14)` |
| `--glass-highlight` | `rgba(255,255,255,0.75)` | `rgba(255,255,255,0.10)` |
-## Semantic aliases — consume these
+## Semantic aliases: consume these
| Alias | Light | Dark |
|---|---|---|
@@ -86,7 +85,7 @@ Pair each with the matching `--blur-*` and `--glass-saturate` (see `materials.md
| `--color-text-secondary` | `--label-2-web` | `--label-2-web` |
| `--color-text-tertiary` | `--label-3` | `--label-3` |
| `--color-text-quaternary` | `--label-4` | `--label-4` |
-| `--color-text-link` | `--green-700` | `--green-400` |
+| `--color-text-link` | `--color-accent-text` | `--color-accent-text` |
| `--color-status-success` | `--sys-green` | `--sys-green` |
| `--color-status-warning` | `--sys-orange` | `--sys-orange` |
| `--color-status-error` | `--sys-red` | `--sys-red` |
@@ -105,43 +104,41 @@ Pair each with the matching `--blur-*` and `--glass-saturate` (see `materials.md
Status → meaning in CrocApp: **success** = transfer complete, E2E trust badge. **warning** = overwrite conflict, auto-accept enabled. **error** = failed / declined / blocked-unsafe. **info** = local-network-denied, relay notes, "how it works".
-## Theming
-
-Light values sit on `:root`, dark under `[data-theme="dark"]`. The web mechanism (`light-dark()`, `color-scheme`, the pre-paint script) is `web.md` → Theming; the app gets it from the asset catalog, `swiftui.md` → Colors.
-
## Contrast (verified, WCAG 2.2)
-Every row is recomputed from `tokens.css` by `scripts/verify-contrast.sh`, which also asserts the rules below as invariants. Run it after touching any color. **Light is the constrained theme — check it first.**
+Every row is recomputed from `tokens.css` by `scripts/verify-contrast.sh`, which also asserts the rules below as invariants, the alias rules (4 and 6, the dark accent collapse) and the two bands of `--shadow-focus-ring` (7). Run it after touching any color. **Light is the constrained theme: check it first.**
| Pair | Ratio | Verdict |
|---|---|---|
| `--color-accent` on base / grouped / its own tint | 3.41 / 3.06 / 2.99 | UI + large text only; never a letter |
-| `--color-accent-text` `#178055` on base | 4.94 | AA body |
-| `--color-accent-text` on grouped / accent tint | 4.42 / 4.32 | fails body — use the step below |
+| `--color-accent-text` `#178055` on base / card | 4.94 / 4.94 | AA body |
+| `--color-accent-text` on grouped / accent tint | 4.42 / 4.32 | fails body: use the step below |
| `--color-accent-text-on-tint` `#116243` on base / grouped / accent tint | 7.36 / 6.60 / 6.45 | AA body everywhere |
-| `--label-2` 0.60 on base | 3.44 | fails body — Apple's own secondaryLabel |
+| `--label-2` 0.60 on base | 3.44 | fails body: Apple's own secondaryLabel |
| `--label-2-web` 0.75 on base / grouped | 5.16 / 4.85 | AA body |
+| `--color-text-secondary` (`--label-2-web`) on accent tint | 4.78 | AA body, 0.28 of headroom |
| `--label-3` on base | 1.73 | decorative fill only |
| `--color-accent` band of `--shadow-focus-ring` on base / on the accent fill | 3.41 / 3.41 | AA non-text, rule 7 |
+| `--glass-tint-accent` composited on base | 1.14 | not a focus indicator, rule 7 |
| `--color-on-accent` on `--color-accent` | 3.41 | filled-button deviation, rule 3 |
| `--color-on-status` on `--color-status-error` | 3.55 | filled-button deviation, rule 3 |
| `--color-status-success` / `-warning` / `-error` on its own tint | 1.98 / 1.96 / 2.94 | nothing |
| `--color-status-success` / `-warning` / `-error` / `-info` on base | 2.22 / 2.20 / 3.55 / 4.02 | fill and glyph only |
-Dark, for the record: accent 9.44 on base, 7.65 on card, 7.72 on its own tint; `--color-on-accent` 7.19 on the accent; `--color-on-status` 3.41 on `--color-status-error`, the same rule-3 deviation as light; `--label-2` 6.36 on base; every status color ≥ 4.91 on its own tint. Nothing else in dark needs a substitute, which is why all three accent-text aliases collapse to `--green-400` there.
+Dark, for the record: accent 9.44 on base, 7.65 on card, 7.72 on its own tint; `--color-on-accent` 7.19 on the accent; `--color-on-status` 3.41 on `--color-status-error`, the same rule-3 deviation as light; `--label-2` 6.36 on base; every status color on its own tint: success 8.05, warning 7.93, error 5.25, info 4.92 (all AA body). Nothing else in dark needs a substitute, which is why all three accent-text aliases collapse to `--green-400` there.
### Rules
-1. **Accent as text is not accent as a fill.** Icons, fills, borders and progress bars keep `--color-accent` — they owe 3:1 and clear it. Anything with a letter in it takes `--color-accent-text` on `--color-surface-base` / `--color-surface-card`, and `--color-accent-text-on-tint` on every other light surface: `--color-accent-tint`, and `--color-surface-grouped`, which is the landing page's default fill. `--green-800` is the only accent step that clears AA everywhere in light.
+1. **Accent as text is not accent as a fill.** Icons, fills, borders and progress bars keep `--color-accent`: they owe 3:1 and clear it. Anything with a letter in it takes `--color-accent-text` on `--color-surface-base` / `--color-surface-card`, and `--color-accent-text-on-tint` on every other light surface: `--color-accent-tint`, and `--color-surface-grouped`, which is the app's page fill. In the app that means a `.bordered` or plain button's label too, never the inherited tint (`swiftui.md` → Colors). `--green-800` is the only accent step that clears AA everywhere in light.
-2. **A status color is a fill, never a foreground.** In light it fails on its own tint (1.96 - 2.94) and three of four fail on base. Status *text* — banner titles, tinted-destructive labels, trust-badge labels — is `--color-text-primary`; the tint and border carry the semantic. Status *glyphs* may stay in the kind color: they are `aria-hidden` and always redundant with an adjacent label (`iconography.md`), which makes them decorative, so 1.4.11 does not bind them. They do read faint in light — that is why the label, not the glyph, has to carry the meaning. Never "improve" a banner by tinting its body copy.
+2. **A status color is a fill, never a foreground.** In light it fails on its own tint (1.96 - 2.94) and three of four fail on base. Status *text* (banner titles, tinted-destructive labels, trust-badge labels, the app's destructive button labels) is `--color-text-primary`; the tint and border carry the semantic. Status *glyphs* may stay in the kind color: they are `aria-hidden` and always redundant with an adjacent label (`iconography.md`), which makes them decorative, so 1.4.11 does not bind them. They do read faint in light, which is why the label, not the glyph, has to carry the meaning. Never "improve" a banner by tinting its body copy.
-3. **Filled prominent and destructive buttons ship at 3.41:1 and 3.55:1.** Both clear 3:1, neither clears 4.5:1. Accepted, because it is what Apple's own filled controls measure, on two conditions: the label stays ≥ 17px semibold and never shrinks, and small sizes use a tinted variant instead of a filled one. Their ink is `--color-on-accent` and `--color-on-status` — the only two places a knocked-out white label is allowed, and neither is a raw literal.
+3. **Filled prominent and destructive buttons ship at 3.41:1 and 3.55:1.** Both clear 3:1, neither clears 4.5:1. Accepted, because it is what Apple's own filled controls measure, on two conditions: the label stays ≥ 17px semibold and never shrinks, and small sizes use a tinted variant instead of a filled one. Their ink is `--color-on-accent` and `--color-on-status`: the only two places a knocked-out white label is allowed, and neither is a raw literal.
-4. **`--color-text-secondary` is not `--label-2` in light.** Apple's secondaryLabel (0.60) is 3.44:1 on white, below AA for anything under large text — which is most of a web page. `--label-2` keeps the true system value so the app stays visually native; the alias resolves to `--label-2-web` (0.75). An app view consuming these tokens directly wants `--label-2`.
+4. **`--color-text-secondary` is not `--label-2` in light.** Apple's secondaryLabel (0.60) is 3.44:1 on white, below AA for anything under large text, which is most of a web page. `--label-2` keeps the true system value so the app stays visually native; the alias resolves to `--label-2-web` (0.75). An app view consuming these tokens directly wants `--label-2`.
-5. **`--color-text-tertiary` is not a text color and not a UI-boundary color.** 1.73:1. Decorative fills only. Anything a sighted user has to *see* — disclosure chevrons, remove controls, empty-state glyphs — takes secondary or better.
+5. **`--color-text-tertiary` is not a text color and not a UI-boundary color.** 1.73:1. Decorative fills only. Anything a sighted user has to *see* (disclosure chevrons, remove controls, empty-state glyphs) takes secondary or better.
-6. **`--color-text-link` is `--green-700`, not the accent**, and follows rule 1: on `--color-surface-grouped` a link needs `--green-800`, or it needs to sit on a card.
+6. **`--color-text-link` is `--color-accent-text`, not the accent**, and follows rule 1: a link on `--color-surface-grouped` or a tint takes `--color-accent-text-on-tint`, or sits on a card.
-7. **The focus ring owes 3:1 and is not decoration.** It is two 2px bands — `--color-surface-base` inside, `--color-accent` outside — precisely so one of them always has a 3.41:1 edge against whatever it surrounds, including the accent-filled button. A tint cannot do this job: `--glass-tint-accent` at 12% composites to 1.14:1 on base, which is how the ring shipped invisible before 2026-07-27. Non-text contrast (1.4.11) binds focus indicators; a calm-looking ring that nobody can see is a failure, not a style.
+7. **The focus ring owes 3:1 and is not decoration.** It is two 2px bands, `--color-surface-base` inside and `--color-accent` outside, precisely so one of them always has a 3.41:1 edge against whatever it surrounds, including the accent-filled button. A tint cannot do this job: `--glass-tint-accent` at 12% composites to 1.14:1 on base, which is how the ring shipped invisible before 2026-07-27. Non-text contrast (1.4.11) binds focus indicators; a calm-looking ring that nobody can see is a failure, not a style.
diff --git a/design/components.md b/design/components.md
index 30fe9966..7575bcbb 100644
--- a/design/components.md
+++ b/design/components.md
@@ -1,39 +1,40 @@
# Components
-These primitives cover every screen. Measurements are exact — they come from the design system's component source, not from eyeballing a render. Anything not listed as a token is a component constant; keep it in the component, not scattered at call sites.
+Every component on every surface. Two kinds:
-Composition order on a screen: page (`--color-surface-grouped`) → `GlassCard` → grouped rows / controls → buttons.
+- **System controls.** Apple ships them, so the app uses them and this file says only which one and what we set (`AGENTS.md` rule 2). Their geometry is Apple's.
+- **Drawn components.** No system control does the job, so the measurements here are exact. They come from this file, not from eyeballing a render; keep them as constants in the component, never scattered at call sites.
-Every foreground here obeys the seven rules in `colors.md` → Contrast. Two of them decide most of this file: **accent never labels anything** (use `--color-accent-text`, or `--color-accent-text-on-tint` on grouped and on tint), and **a status color is a fill, never a foreground** (status *text* is `--color-text-primary`; the tint carries the meaning).
+Composition in the app: page (`--color-surface-grouped`), then cards and grouped lists, then controls, then buttons. On the web the page is `--color-surface-base` (`web.md` → Page fill).
+
+Every foreground obeys `colors.md` → Contrast. Two rules decide most of this file: **accent never labels anything** (labels take `--color-accent-text`, or `--color-accent-text-on-tint` on grouped and tint), and **a status color is a fill, never a foreground** (status text is `--color-text-primary`; the tint carries the meaning).
---
## States
-Every interactive element supports the same six states. A component that needs a seventh needs a reason written down.
+Every interactive element has the same six states. A component that needs a seventh needs a reason written down.
-| State | What changes | Timing |
+| State | Web | App |
|---|---|---|
-| rest | the variant's own values | — |
-| hover | the variant's **pressed background**, nothing else. No scale, no lift, no shadow change | `--dur-fast` `--ease-standard` |
-| pressed | hover, plus `scale(var(--press-scale))` | `--dur-fast` `--ease-out` |
-| focus | `--shadow-focus-ring`, replacing the variant's own shadow | none |
-| disabled | `--opacity-disabled` on controls, `--opacity-disabled-container` on rows and fields, no pointer | none |
-| loading | see below | — |
-
-**Hover is pointer-only** and never exists on touch. Nothing may be discoverable only on hover: no hover-only buttons, no hover-to-reveal remove controls. See `platforms.md` → Hover.
+| rest | the variant's values | the variant's values |
+| hover | the variant's pressed background, nothing else: no scale, lift, color or shadow change. `--dur-fast` `--ease-standard`, inside `@media (hover: hover)` | the system's pointer effect (macOS, iPad with a pointer) |
+| pressed | hover plus `scale(var(--press-scale-web))`, `--dur-fast` `--ease-out` | the system style's own pressed state |
+| focus | `--shadow-focus-ring`, replacing the variant's shadow | the system focus indicator |
+| disabled | `--opacity-disabled-container` on rows and fields | `.disabled()` on controls; rows and fields that dim as one take `--opacity-disabled-container` |
+| loading | below | below |
-**Two disabled opacities, on purpose.** A control dims to 0.4 because it is one object. A row or a field dims to 0.5 because it is a composite whose subtitle and separator would otherwise disappear before its title does.
+Hover is pointer-only and nothing may be discoverable only on hover (`platforms.md` → Hover). A text link may also underline on hover; that restates, it does not reveal.
-**Loading.** No skeletons anywhere in this system. Work with a known extent shows `ProgressBar`; work without one shows the indeterminate bar. A button that is working keeps its label and its width, swaps its leading glyph for a spinner, and goes disabled. It never collapses to a bare spinner and never changes size, because a control that resizes under the pointer is a control you cannot click twice.
+**Loading.** No skeletons. Known extent shows a determinate progress bar; unknown extent shows an indeterminate one. A working button keeps its label and width, swaps its leading glyph for a spinner, and goes disabled. It never collapses to a bare spinner and never resizes under the pointer.
---
-## Button
+## Shared: drawn on web, system-styled in the app
-Capsule, Apple prominent style. Icon before and/or after the label.
+### Button
-**Sizes**
+Capsule, Apple prominent style, icon before and/or after the label.
| Size | Height | Padding | Font | Gap | Icon |
|---|---|---|---|---|---|
@@ -41,317 +42,202 @@ Capsule, Apple prominent style. Icon before and/or after the label.
| medium | `--control-height-md` 38 | `0 16px` | `--type-callout` 16 | 6 | 18 |
| small | `--control-height-sm` 30 | `0 12px` | `--type-footnote` 13 | 5 | 15 |
-Always `--weight-semibold`, `--tracking-headline`, `line-height: 1`, `--radius-capsule`, `white-space: nowrap`.
-
-**Variants**
+Always `--weight-semibold`, `--tracking-headline`, `line-height: 1`, `--radius-capsule`, no wrapping. A bare small button still gets a `--hit-target-min` hit area.
-| Variant | Background | Foreground | Border | Pressed |
+| Variant | Background | Label | Border | Pressed |
|---|---|---|---|---|
-| `prominent` | `--color-accent` | `--color-on-accent` | transparent | `--color-accent-pressed` |
-| `secondary` | `--color-accent-tint` | `--color-accent-text-on-tint` | transparent | `color-mix(in srgb, var(--color-accent) 12%, var(--color-accent-tint))` |
-| `destructive` | `--color-status-error` | `--color-on-status` | transparent | `color-mix(in srgb, var(--color-status-error) 82%, black)` |
+| `prominent` | `--color-accent` | `--color-on-accent` | none | `--color-accent-pressed` |
+| `secondary` | `--color-accent-tint` | `--color-accent-text-on-tint` | none | `color-mix(in srgb, var(--color-accent) 12%, var(--color-accent-tint))` |
+| `destructive` | `--color-status-error` | `--color-on-status` | none | `color-mix(in srgb, var(--color-status-error) 82%, black)` |
| `destructive-tinted` | `--color-status-error-tint` | `--color-text-primary` | 22% error | `color-mix(in srgb, var(--color-status-error) 12%, var(--color-status-error-tint))` |
-| `glass` | `--color-surface-glass` + regular blur | `--color-accent-text` | `--color-glass-border` | `--color-surface-glass-thick` |
-| `plain` | transparent | `--color-accent-text` | transparent | `--color-fill-tertiary` |
-
-`prominent`, `destructive` and `glass` carry `--shadow-sm`.
-
-Foreground caveats, all from `colors.md`:
-
-- `plain` and `glass` sit on whatever is behind them. `--color-accent-text` only clears AA on `--color-surface-base` / `--color-surface-card`; on `--color-surface-grouped` the label must be `--color-accent-text-on-tint`.
-- `prominent` (3.41:1) and `destructive` (3.55:1) are the documented filled-button deviation. Label stays ≥ 17px semibold. **There is no `sm` filled button** — a small accent or destructive action uses `secondary` or `destructive-tinted`.
-- `destructive-tinted` cannot draw its label in the error color (2.94:1). The tint plus the 22% border carries "destructive"; the leading glyph may stay `--color-status-error`.
-
-States are the shared six above; the Pressed column feeds both hover and pressed.
-
-Screen usage: one `prominent` primary action per screen; `destructive` only for Decline / Cancel transfer / Clear history; `glass` for floating actions over content.
-
-The accept gate's two actions and its third, unlabelled exit are specced below.
-
----
-
-## GlassCard
+| `plain` | none | `--color-accent-text` (`-on-tint` on grouped; always `-on-tint` in the app) | none | `--color-fill-tertiary` |
-The default content container. Props: `material` (thin | regular | thick | solid), `tinted`, `radius` (defaults `lg`), `padding` (defaults `--space-6`), `elevated`.
-
-Border `1px --color-glass-border`, shadow `--shadow-glass` when elevated. `tinted` swaps the fill for `--color-accent-tint` — use it for the one card you want to read as "active/selected", not as decoration.
-
----
-
-## DropZone
-
-The empty send target: iPhone/iPad staging area, and the macOS full-window "Drop to send" overlay.
-
-Idle: `--radius-lg`, `2px` dashed `--color-separator`, fill `--color-fill-tertiary`, min height 168, centered column at gap `--space-4`. `upload` glyph at 44 stroke 1.5 in `--color-text-secondary`, title `--type-headline` primary, hint `--type-subhead` secondary.
-
-Drag-over: border becomes `2px` solid `--color-accent`, fill `--color-accent-tint`, glyph and title `--color-accent-text-on-tint`. Transition `--dur-fast` `--ease-out`; no scale, no bounce.
-
-macOS overlay: same states at full-window size over `--color-scrim`, `--radius-2xl`, `--color-surface-glass-thick` + `--blur-thick`. This is the one place `--radius-2xl` is used.
-
-**A drop target is not an input.** Every DropZone ships beside a real file-picker button — drag-and-drop is unavailable to keyboard and most assistive tech, so it can never be the only way to add files.
-
----
+`prominent` and `destructive` carry `--shadow-sm` on web.
-## SegmentedControl
+- `prominent` (3.41:1) and `destructive` (3.55:1) are the filled-button deviation (`colors.md` rule 3). **There is no small filled button**: a small accent or destructive action is `secondary` or `destructive-tinted`.
+- `destructive-tinted` never draws its label in the error color (2.94:1). The tint and border say "destructive"; the leading glyph may stay `--color-status-error`.
+- Usage: at most one `prominent` per screen. Filled `destructive` only for Decline (the accept gate) and confirming Clear History. Cancel transfer and Discard are `destructive-tinted`: they sit on screens the user watches for a while, and a calm screen does not shout. Everything else that is secondary is `secondary` or `plain`.
-iOS picker (e.g. Files | Text). Track: `--color-fill-tertiary`, `--radius-sm`, 2px inset padding, equal-width grid. Thumb: `--color-surface-base`, radius `calc(var(--radius-sm) - 2px)`, `--shadow-sm`, slides on `left` over `--dur-base` `--ease-out`.
+App: one modifier per variant in `app/CrocApp/Views/ButtonVariants.swift`, built on `.borderedProminent`, `.bordered` and `.borderless` (`swiftui.md` → Components). Views never style a button ad hoc, and a `Button(role: .destructive)` never ships with the default style, which paints its label in the status color.
-Segments: `--type-subhead` 15px, semibold when selected / medium otherwise, `--color-text-primary` vs `--color-text-secondary`, min height `--control-height-sm`, padding `6px --space-4`. A hairline `inset 1px 0 0 --color-separator` divides adjacent *unselected* segments only.
+### Card (GlassCard)
----
+The default content container. Web: `.card`, fill `--color-surface-card`, `--radius-lg`, padding `--space-6`, border `var(--border-hairline) solid var(--color-separator)`. In light, card and page are both white, so the border is the separation; in dark the tonal step does it. `.card--elevated` adds `--shadow-sm`. `.card--tinted` swaps the fill for `--color-accent-tint`: for the one card that reads as the answer, never as decoration.
-## CodeField
+App: grouped `List`/`Form` sections, or `.glassEffect()` on a floating surface. There is no hand-built card view.
-Monospaced entry for `word-word-word`, with a paste affordance.
+### TrustBadge
-Wrapper: height `--control-height-lg` 50, padding `0 6px 0 14px`, `--color-surface-base`, `--radius-md`, 1px border. Border color: `--color-separator` idle → `--color-accent` focused → `--color-status-error` invalid. Focused (and valid) adds `--shadow-focus-ring`. Disabled `--opacity-disabled-container`.
+End-to-end-encryption reassurance.
-Input: `--font-mono`, `--type-code-body` 15, `--tracking-code`, autocapitalize/autocorrect/spellcheck off.
+- `pill`: inline capsule, fill `--color-status-success-tint`, label `--color-text-primary`, padding `--pill-pad-block-web` / `--pill-pad-inline-start-web` `--pill-pad-inline-end-web`, `--type-footnote` semibold, `lock` at 14 in `--color-status-success`. On the landing hero, the Home header and the accept gate.
+- `full`: row card, `--radius-md`, success tint, 22% success border, a 34 solid-success circle holding a `--paper` `checkmark.shield.fill` at 19, label `--type-subhead` semibold plus an optional `--type-footnote` detail. Tops the How It Works screen.
-Paste button: height 38, padding `0 12px`, `--radius-sm`, `--color-accent-text-on-tint` on `--color-accent-tint`, `--type-footnote` semibold, `clipboard` glyph at 15.
+The pill's label is primary because success on its own tint is 1.98:1. The `full` variant inverts instead: a solid fill with the glyph knocked out. Copy is capped by `content.md` → Claims.
---
-## SettingsRow + Switch
-
-Inset-grouped list row. Types: `toggle`, `field` (right-aligned inline text field), `disclosure` (chevron), `value` (static trailing detail).
-
-Row: min height 52, padding `10px --space-5`, gap `--space-4`, `--color-surface-card`. `first`/`last` flags round the outer corners at `--radius-md`; every row but the last draws `inset 0 -0.5px 0 --color-separator`. Disabled `--opacity-disabled-container`.
-
-Leading icon tile: 29×29, radius 7, fill `iconTint` or `--color-accent`, `--color-on-accent` glyph at 17 / stroke 2.
-
-Title `--type-body` with `--tracking-body`, truncating. Subtitle `--type-footnote` `--color-text-secondary`. Trailing value `--type-body` secondary. Chevron `chevron-right` at 18, stroke 2.4, `--color-text-secondary` — it is the only signal that a row navigates, so it does not get tertiary (1.73:1).
+## Drawn in the app
-Switch: 51×31 capsule, 2px padding, 27px `--paper` knob with `--shadow-knob`. Track `--color-status-success` on, `--color-fill` off, `--dur-base`.
+### DropZone
----
+The empty send target: the Send screen's staging area, the menu bar window, and the macOS window overlay.
-## FileRow
+Idle: `--radius-lg`, 2pt dashed `--color-separator` stroke, fill `--color-fill-tertiary`, min height 168, centered column at gap `--space-4`: `square.and.arrow.up` at 44 in `--color-text-secondary`, title `--type-headline` primary, hint `--type-subhead` secondary.
-Send queue / incoming preview row. Min height 48, padding `8px --space-4`, gap `--space-3`, `--color-surface-card` when `grouped`, same first/last rounding + separator rules as SettingsRow.
+Drag-over: stroke 2pt solid `--color-accent`, fill `--color-accent-tint`, glyph and title `--color-accent-text-on-tint`. `--dur-fast` `--ease-out`; no scale.
-Type icon at 20 in `--color-accent`, mapped: `folder → folder`, `image → image`, `text → file-text`, `doc → file`.
+macOS overlay: the whole window takes `--color-scrim`, and a centered `.glassEffect()` card at `--radius-2xl` holds the glyph and "Drop to Send". The Send list's own drop target wins while the pointer is over it.
-Filename is **middle-truncated**: the last 8 characters (extension + tail) are pinned in a non-shrinking span so the extension always stays visible; the head ellipsises. Size label `--type-footnote` secondary, tabular figures. Remove control: `circle-x` at 20, `--color-text-secondary`, `aria-label="Remove "` — it is an interactive affordance, so tertiary is not available to it.
+**A drop target is not an input.** Every DropZone ships beside a real file-picker button.
----
+### CodeField
-## CodePhraseDisplay + QRFrame
+Monospaced entry for `word-word-word`, used everywhere a code phrase is typed, including a custom code on Send.
-The sender's "ready to send" state. Centered column, gap `--space-5`:
+Wrapper: height `--control-height-lg`, padding `0 6px 0 14px`, fill `--color-surface-base`, `--radius-md`, `--border-hairline` border: `--color-separator` idle, `--color-accent` focused, `--color-status-error` invalid. Disabled dims to `--opacity-disabled-container`.
-1. Eyebrow "READY TO SEND" — `--type-footnote`, semibold, `--tracking-eyebrow`, uppercase, secondary.
-2. The code — `--font-mono`, `--type-code-hero` 30/38, bold, `--tracking-code`, `user-select: all`, wraps on word break.
-3. Copy button — capsule, `--color-accent-text-on-tint` on `--color-accent-tint`, `--type-subhead` semibold, padding `--space-3 --space-5`; glyph flips `copy → check` and label `Copy Code → Copied` when copied.
-4. QR frame — default 168×168, white background (always white, both themes — scanners need it), `--radius-md`, 12px padding, `--shadow-md`, 1px `--color-separator` border. Payload is `croc://`.
-5. Optional caption — `--type-callout` secondary, max-width 320.
+Input: `--font-mono` at `--type-code-body`, `--tracking-code`, no autocapitalization, autocorrection or spell check. Trailing system `PasteButton` (it owns its glyph). Invalid shows a one-line `--type-footnote` message under the field, never the word "invalid".
----
+### CodePhraseDisplay and QRFrame
-## TransferProgress + ProgressBar
+The sender's waiting state. Centered column, gap `--space-5`:
-`ProgressBar`: height 6, `--radius-capsule`, track `--color-fill-tertiary`, fill `--color-accent`. Determinate animates width `--dur-base` `--ease-out`. Indeterminate: 40%-wide fill sliding `-40% → 100%` over 1.2s, infinite.
+1. Eyebrow "READY TO SEND": `--type-footnote`, semibold, `--tracking-eyebrow`, uppercase, secondary.
+2. The code: `--type-code-hero` bold mono, `--tracking-code`, selectable, wraps only between words. It may sit on a `.glassEffect()` pill at `--radius-xl`.
+3. Copy button, `secondary`: glyph `doc.on.doc` and label "Copy Code", flipping to `checkmark` and "Copied" for two seconds after a copy.
+4. QR frame: 168 square, always white (scanners need it), `--radius-md`, 12 padding, `--shadow-md`, hairline `--color-separator` border. Payload: the receive link `https://crocapp.dev/r/#` from `TransferLink.webURL(forCode:)`, which opens the app through the universal link or the browser receive page.
+5. Optional caption, `--type-callout` secondary, at most 320 wide.
-`TransferProgress` is the dual-bar block:
+The menu bar window shows the same code at `--type-title3` mono semibold, because its column is 300 wide.
-- **Direction header** — `arrow-up`/`arrow-down` at 18 stroke 2.4 in `--color-accent`, plus the literal word **Sending** or **Receiving** in `--type-headline` semibold. This is the fix for the old UI bleeding Send status onto the Receive screen: the word and the arrow both change, and neither is optional.
-- Current file: middle-truncated name (`--type-callout`), bar, caption row `File N of M` ← → `done / size`.
-- Overall: bar, caption row `Total done / size` ← → live speed in `--weight-medium` primary.
+### TransferProgress
-Captions are `--type-caption1` 12px, secondary, tabular figures.
+- **Direction header**: `arrow.up` or `arrow.down` at `--icon-size-sm` in `--color-accent`, plus the word **Sending** or **Receiving** in `--type-headline`. Both change together and neither is optional. A Send-as-link transfer says Sending and Receiving too.
+- Current file: middle-truncated name (`--type-callout`), bar, caption row `File N of M` and `done / size`.
+- Overall: bar, caption row `done / size` and the live speed in `--weight-medium` primary. Unknown or zero speed shows an em-dash placeholder.
----
+Captions `--type-caption1` secondary. Every number is tabular (`.monospacedDigit()`). Bars are system `ProgressView`, animated at `--dur-base` `--ease-out`, never backwards.
-## StatusBanner
+### StatusBanner
-Inline advisory. Padding `--space-4 --space-5`, gap `--space-3`, `--radius-md`, background = kind tint, border `1px color-mix(kind color 22%, transparent)`.
+Inline advisory. Padding `--space-4 --space-5`, gap `--space-3`, `--radius-md`, fill = kind tint, `--border-hairline` border at 22% of the kind color.
-| Kind | Color | Glyph | `role` |
+| Kind | Color | Glyph | Announced |
|---|---|---|---|
-| info | `--color-status-info` | `info` | status |
-| warning | `--color-status-warning` | `triangle-alert` | status |
-| error | `--color-status-error` | `circle-alert` | **alert** |
-| success | `--color-status-success` | `circle-check` | status |
-
-Leading glyph 19 stroke 2 in the kind color — decorative, per `colors.md` rule 2. Title `--type-subhead` semibold in **`--color-text-primary`**; body `--type-footnote` in `--color-text-secondary`. Optional trailing action slot.
-
-The title is not drawn in the kind color: on its own tint that measures 1.96 - 2.94:1 in light. The tint and the border are what say "warning", and the title says it in words.
-
----
-
-## StatusBlock
-
-Large centered result (transfer done / failed). 64px circle filled with the kind tint holding a 34px stroke-2 glyph in the kind color, then title `--type-title3` semibold primary, then detail `--type-callout` secondary. Padding `--space-6`, gap `--space-3`.
-
-The glyph is decorative (`colors.md` rule 2) — the title below it states the outcome in words, and never in color alone.
-
----
-
-## TrustBadge
-
-End-to-end-encryption reassurance.
-
-- `pill` (default): inline capsule, background `--color-status-success-tint`, label `--color-text-primary`, padding block `--pill-pad-block` / inline `--pill-pad-inline-start` `--pill-pad-inline-end`, `--type-footnote` semibold, `lock` glyph at `--pill-icon-size` stroke 2.2 in `--color-status-success`. Sits in the Home header and on transfer screens.
-- `full`: row card, `--radius-md`, success tint background, 22% success border, 34px solid-success circle with a `--paper` `shield-check` at 19, label `--type-subhead` semibold + optional `--type-footnote` detail. Used on the "How it works" trust screen.
+| info | `--color-status-info` | `info.circle` | no |
+| warning | `--color-status-warning` | `exclamationmark.triangle.fill` | no |
+| error | `--color-status-error` | `exclamationmark.circle.fill` | yes |
+| success | `--color-status-success` | `checkmark.circle` | no |
-The pill's label is primary, not success: success on its own tint is 1.98:1 in light. The `full` variant escapes this by inverting — a solid success fill with the glyph knocked out of it.
+Glyph at 19 in the kind color, decorative (`colors.md` rule 2). Title `--type-subhead` semibold **primary**; body `--type-footnote` secondary. Optional trailing action, `plain`.
-Claim only what is true: croc is PAKE-authenticated and end-to-end encrypted, and the relay sees ciphertext only. Do not extend the copy beyond that.
+### StatusBlock
----
-
-## EmptyState
-
-Mirrors `ContentUnavailableView`. Centered column, gap `--space-3`, padding `--space-9 --space-6` (`--space-6` when `compact`). Icon 44 (34 compact), stroke 1.5, `--color-text-secondary`. Title `--type-headline` semibold primary. Description `--type-subhead`, max-width 300, secondary. Optional action slot with `--space-3` top margin.
-
----
-
-## SheetHeader
+Large centered result (done, failed). A 64 circle in the kind tint holding a 34 glyph in the kind color, then the title `--type-title3` semibold primary, then detail `--type-callout` secondary. Padding `--space-6`, gap `--space-3`. The title states the outcome in words; the glyph only repeats it.
-iOS sheet toolbar: `1fr auto 1fr` grid — leading Cancel, centered title, trailing confirm. Min height 52, padding `--space-4 --space-5`, `--color-surface-glass-thick` + regular blur, bottom hairline `--color-separator`, top corners `--radius-xl`.
+### AcceptGate
-Title `--type-headline` semibold. Actions `--type-body` in `--color-accent-text`; confirm is semibold when prominent, `--color-text-primary` when destructive (the sheet's own copy names the consequence — a red word at 3.55:1 does not), `--color-text-tertiary` when disabled. Disabled is the one place tertiary is right: it is meant to read as unavailable.
+The incoming request. Not a sheet and not an alert: a phase of the transfer screen, so it cannot be dismissed by accident.
----
-
-## AcceptGate
-
-The incoming request. Not a sheet and not an alert: it is a phase of the transfer screen, so it cannot be dismissed by accident.
+Composition: direction header (Receiving), TrustBadge pill, the file list as `FileRow`s (no remove control), a `StatusBanner` for each of overwrite conflicts and blocked-unsafe items, then the actions: Decline `destructive`, Accept `prominent` (Keep Both and Replace when a name is taken). Nothing auto-advances and nothing is preselected.
-Composition: direction header, `TrustBadge` pill, the file list as `FileRow`s, a `StatusBanner` for each of overwrite conflicts and blocked-unsafe items, then the two actions. Decline is `destructive`, Accept is `prominent`. Nothing here auto-advances and nothing is preselected.
-
-**Two answers and one silence.** Declining and leaving are different acts and the design must not blur them:
+**Two answers and one silence.**
| Exit | What the other device learns | What we do |
|---|---|---|
-| Decline | that you said no, immediately | respond, then terminal phase |
+| Decline | that you said no | respond, then terminal phase |
| Accept | that you said yes | respond, then transfer |
-| Navigating away | nothing | respond with nothing. The request stays live |
+| Navigating away | nothing | nothing. The request stays live |
-Leaving the screen is the third exit and it is deliberately unlabelled, because a "Not now" button would promise a deferral the protocol cannot deliver. The sender is blocked on an answer; silence is simply the answer not arriving yet, which is also what happens if the phone stays in a pocket. We never synthesize a decline on the user's behalf, and the sender's own timeout is the only thing that ends the wait.
+There is no Cancel and no "Not now" here: either would answer the sender on the user's behalf or promise a deferral the protocol cannot deliver. The sender's own timeout is the only thing that ends the wait.
-Three rules make that safe:
+1. **Navigating away never responds**: not on backgrounding, not on a timeout of our own.
+2. **A live request is never lost.** While one is pending and the user is elsewhere, an `info` StatusBanner with a Review action sits on Home and returns to the gate. Persistent, not a toast.
+3. **The copy separates them.** Decline is worded as telling the other device no. Nothing describes leaving as an action.
-1. **Navigating away never responds.** Not accept, not decline, not on backgrounding, not on timeout of our own.
-2. **A live request is never lost.** While one is pending and the user is elsewhere, an `info` `StatusBanner` with an action slot sits on Home, naming the request and returning to the gate. Persistent, not a toast, and it does not go away on its own.
-3. **The copy separates them.** Decline is worded as telling the other device no. Nothing on this screen describes leaving as an action, because it is not one.
+Auto-accept, when the user turns it on in Settings, skips this gate; it is off by default on every platform (`platforms.md`).
-`brand.md`'s rule holds throughout: nothing is accepted without an explicit yes.
+### FileRow
----
+Send queue and accept-gate row. Min height 48, padding `--space-3 --space-4`, gap `--space-3`.
-## HistoryRow
+Type glyph at 20 in `--color-accent`: `folder`, `photo`, `doc.text`, `doc`. Filename **middle-truncated** with the last 8 characters pinned so the extension stays visible. Size `--type-footnote` secondary, tabular. Remove control (send queue only): `xmark.circle.fill` at 20, `--color-text-secondary`, labelled "Remove ", with a `--hit-target-min` hit area.
-One past transfer. Leading direction glyph at `--icon-size-md` (`arrow.up.circle` / `arrow.down.circle`), `--color-accent` when the transfer completed and `--color-text-secondary` otherwise, because a failed transfer is not an accent moment.
+### HistoryRow
-Title `--type-body`, middle-truncated, one line: the first filename, plus `+ N more` when there were others, or "Text snippet". Metadata row underneath at `--type-caption1` secondary, gap `--space-3`: relative date, size, then the code hint in `--font-mono`. Sizes and dates follow `content.md` → Numbers and Dates.
+One past transfer. Leading direction glyph at `--icon-size-md` (`arrow.up.circle`, `arrow.down.circle`): `--color-accent` when completed, `--color-text-secondary` otherwise.
-Trailing status glyph, `aria-label` carrying the status word because the glyph is the only thing that shows it:
+Title `--type-body`, middle-truncated: the first filename plus "+ N more", or "Text snippet". Metadata at `--type-caption1` secondary, gap `--space-3`: relative date, size, the code hint in mono. Formats per `content.md` → Numbers and Dates.
| Status | Glyph | Color |
|---|---|---|
| completed | `checkmark.circle.fill` | `--color-status-success` |
| failed | `xmark.circle.fill` | `--color-status-error` |
| cancelled | `slash.circle` | `--color-text-secondary` |
-| declined | `hand.raised.fill` | `--color-status-warning` |
+| declined | `hand.raised.fill` | `--color-status-error` |
-This is the one place a status color appears without an adjacent word, so the accessibility label is not optional (`colors.md` rule 2, `accessibility.md` → Never color alone).
+The status glyph is the one place a status color appears without an adjacent word, so its accessibility label (the status word) is mandatory. Actions: context menu everywhere, swipe on iOS (System controls → ContextMenu, SwipeActions). Empty list is an EmptyState with a Send and a Receive action.
-Row actions are `ContextMenu` on both platforms, plus swipe actions on iOS. Empty list is `EmptyState` with two actions.
+### OnboardingSheet
----
+First-run explainer: three points, then gone forever. Never a tour, never a pager.
-## OnboardingSheet
+Centered column, gap `--space-7`, padding `--space-8`, capped at `--content-max-width`. Mark glyph `arrow.left.arrow.right.circle.fill` at 56 (scaled with Dynamic Type) in `--color-accent`. Title `--type-title2`. Three bullets at gap `--space-5`: glyph at `--icon-size-lg` in `--color-accent`, `--type-headline` title, `--type-callout` secondary body, gap `--space-4`. Glyph frames take a minimum width, never a fixed one. One full-width large `prominent` button closes it. System permission prompts come after it dismisses, one at a time.
-First-run explainer, three points, then gone forever. **It is not a tour and never gains a pager.** Full detail lives on the How It Works screen, one tap deeper.
-
-Centered column, gap `--space-7`, padding `--space-8`, capped at `--content-max-width`. Mark glyph at 56 in `--color-accent`. Title `--type-title2`. Three bullets at gap `--space-5`, each a leading glyph at `--icon-size-lg` in `--color-accent` with a `--type-headline` title and a `--type-callout` secondary body, gap `--space-4`.
-
-Glyph frames use a **minimum** width, never a fixed one, or the glyph clips at accessibility text sizes.
-
-Closing action is one full-width `prominent` large button.
-
-Any system permission prompt is raised **after** this sheet dismisses, one at a time, never stacked behind it.
-
----
-
-## HowItWorksList
-
-The trust screen. Scrolling list of labelled paragraphs, gap `--space-6`, capped at `--content-max-width`. Each item: leading glyph at `--icon-size-lg`, title `--type-headline`, body `--type-callout` secondary, inner gap `--space-2`.
-
-No card, no tint, no accent wash. This screen is the one that has to read as plain and true, so it is typography on the page fill and nothing else. Claims are capped by `content.md` → Claims.
-
----
+### HowItWorksList
-## ScannerSheet
+The trust screen. TrustBadge `full` on top, then labelled paragraphs at gap `--space-6`, capped at `--content-max-width`: glyph at `--icon-size-lg`, title `--type-headline`, body `--type-callout` secondary, inner gap `--space-2`. No card, tint or accent wash beyond the badge: this screen has to read plain and true. Claims capped by `content.md` → Claims.
-QR capture, iOS only. Sheet with a cancel-only header (`SheetHeader`) and the live camera filling the body.
+### ScannerSheet
-Four states, four distinct copies, and they are not interchangeable:
+QR capture, iOS only: a sheet with a Cancel-only toolbar and the live camera.
| State | Body | Action |
|---|---|---|
-| authorized and supported | live scanner | — |
-| resolving permission | centered indeterminate `ProgressView` | — |
-| denied | `EmptyState`, camera glyph | "Open Settings" |
-| restricted, or unsupported hardware | `EmptyState`, camera glyph | **none** |
+| authorized and supported | live scanner | none |
+| resolving permission | indeterminate `ProgressView` | none |
+| denied | EmptyState, `video.slash` | "Open Settings" |
+| restricted, unsupported or failed to start | EmptyState, `video.slash` | none |
-Restricted gets no Settings button on purpose: the user cannot fix it there, and a button that does nothing is worse than no button (`content.md` → Errors).
+No Settings button where Settings cannot fix it (`content.md` → Errors).
----
-
-## SettingsForm
-
-macOS Settings scene (⌘,), a grouped `Form` in sections, minimum 480×420. Not a screen inside the main window.
-
-Default keyboard focus goes to a **real, visible button**, never to the first text field, or macOS opens the window with a blinking caret sitting in an address field.
-
-Trailing values truncate in the middle so the tail of a path stays readable.
-
----
-
-## Alert + ConfirmationDialog
-
-System presentations, never custom-drawn. Reach for them in exactly two cases: a destructive action that cannot be undone, and a failure the user must acknowledge before continuing. Everything else is a `StatusBanner`.
+### MenuBarQuickSend
-- Title is a question for a confirmation ("Clear all history?"), a statement for a failure.
-- Message is one sentence, sentence case, and says what will *not* happen as well as what will ("Removes the list only — received files stay where they are.").
-- The destructive choice takes the destructive role; the safe choice is the cancel role and is what Return selects.
-- Never more than two actions. Three means the screen needed a decision it did not make.
+macOS menu bar window, 300 wide: a compact DropZone (min height 96), the code at `--type-title3` mono, one progress bar with the direction header, and the same buttons as the main window. Everything it does, the main window does too.
----
-
-## ContextMenu + SwipeActions
+### DiskImage
-Always additive. Every action in a context menu or a swipe is reachable another way, because neither is discoverable and neither exists for VoiceOver users the way it does for pointer users.
+The macOS download's Finder window. Not an app surface, but the first CrocApp anyone sees.
-Destructive entries take the destructive role. A swipe's leading edge carries the constructive action and takes `--color-accent`; the trailing edge carries the destructive one.
+Window 660×400, icons 128, app at (165, 170), Applications alias at (495, 170). `scripts/build-dmg.sh` passes exactly these numbers to `create-dmg`.
----
-
-## Icon
-
-Rendering, sizes and stroke weights are `iconography.md`'s — this file only names which glyph a component uses.
-
----
-
-## DiskImage
-
-The macOS download's Finder window. Not an app surface, but it is the first CrocApp anyone sees, so it is specified here rather than improvised in a build script.
-
-Window 660×400, icons 128, app at (165, 170), Applications alias at (495, 170). `scripts/build-dmg.sh` passes exactly these numbers to `create-dmg`; changing one without changing the other leaves this file lying.
-
-Background `assets/dmg-background.tiff`, 660×400, a multi-representation TIFF holding a 1x and a 2x image (`tiffutil -cathidpicheck bg.png bg@2x.png -out dmg-background.tiff`), so it stays sharp on Retina. Fill `--bg-secondary` dark. All positions below are centers, measured from the top of the image, which Finder draws directly under the 32pt title bar:
+Background `assets/dmg-background.tiff`: a 1x and 2x multi-representation TIFF (`tiffutil -cathidpicheck bg.png bg@2x.png -out dmg-background.tiff`), fill `--bg-secondary` dark. Positions are centers, measured from the image top, which Finder draws under the 32pt title bar:
| Element | Spec | Center y |
|---|---|---|
| Title "CrocApp" | `--type-title3` semibold, `--label-1` dark | 44 |
-| Lucide `arrow-right`, midway between the icon slots | 44×44, stroke 1.5, `--label-2-web` dark | 170 |
+| Lucide `arrow-right`, midway between the icons | 44×44, stroke 1.5, `--label-2-web` dark | 170 |
| Label plates, one under each icon | 140×26, radius 13, `rgba(235,235,245,0.85)` | 254 |
| Hint "Drag CrocApp onto Applications" | `--type-footnote`, `--label-2-web` dark | 316 |
-**Dark values only, with label plates.** Finder shows one fixed image and does not swap it with the system appearance, so the usual both-themes rule cannot apply. With a background picture set, Finder draws icon labels black in both appearances (verified on macOS 27 in dark mode), and neither `create-dmg` nor Finder can change that color. The light plates exist only to keep those black labels readable on the dark fill. The plate center sits 84pt below the icon center, which is where Finder puts a 128pt icon's label.
+**Dark only, with label plates.** Finder shows one fixed image regardless of appearance, and with a background picture set it draws icon labels black in both appearances (verified on macOS 27). The plates keep those black labels readable on the dark fill; a plate center sits 84pt below its icon center, where Finder puts a 128pt icon's label. Keep everything inside the top 340pt: Finder clips at about 368pt under the title bar, 340 with the path bar on. No accent, gradient or mascot.
-Keep everything inside the top 340pt. Finder clips the window content to about 368pt under the title bar, and about 340pt when the user has the path bar turned on.
+---
-No accent, no gradient, no mascot: `brand.md` keeps the accent off large background surfaces, and a first-run install window is the calmest thing in the product.
+## System controls
+
+| Component | App | Notes |
+|---|---|---|
+| SegmentedControl | `Picker` + `.pickerStyle(.segmented)` | segment labels Title Case |
+| Toggle | `Toggle` | system tint; no custom switch |
+| Settings rows | `Form` + `.formStyle(.grouped)`, `LabeledContent`, `Toggle`, `Picker`, `NavigationLink` | trailing paths truncate in the middle |
+| SettingsForm | macOS Settings scene (⌘,), the same `Form` | default focus on a real visible button, never a text field. Advanced, Power and About are sections of it |
+| Sheet toolbar | `.toolbar` with `.cancellationAction` / `.confirmationAction` | a destructive confirm is labelled by the sheet's copy, not painted red |
+| ProgressBar | `ProgressView(value:total:)`; indeterminate `ProgressView()` | see TransferProgress |
+| EmptyState | `ContentUnavailableView` | glyph secondary, description one sentence |
+| Alert, ConfirmationDialog | system presentations | only for an irreversible destructive action or a failure the user must acknowledge; everything else is a StatusBanner. A confirmation title is a question ("Clear all history?"), a failure title a statement. One sentence of message saying what will and will not happen. Destructive choice takes the destructive role, the safe choice is the cancel role. Never more than two actions |
+| ContextMenu, SwipeActions | `.contextMenu`, `.swipeActions` | always additive: every action is reachable another way. An entry that removes the row takes the destructive role; one that only asks first (Revoke) takes `--color-status-error` as its tint instead, or SwiftUI animates the row away. A swipe's leading edge is constructive, its trailing edge destructive |
+| Share | `ShareLink` | |
+
+Glyphs, sizes and strokes for every component are `iconography.md`'s.
diff --git a/design/content.md b/design/content.md
index e1f52920..e29e11a0 100644
--- a/design/content.md
+++ b/design/content.md
@@ -1,71 +1,92 @@
# Content
-How the words are written. `brand.md` sets the voice; this sets the mechanics, and the mechanics are binding on the app, the landing page and the docs site.
+How the words are written. `brand.md` sets the voice; this sets the mechanics, binding on the app, the landing page, the docs site, the README and the App Store listing.
-Most of this product is text and one progress bar. Getting a byte count wrong is more visible than getting a shadow wrong.
+Most of this product is text and one progress bar. A wrong byte count is more visible than a wrong shadow.
## Capitalization
+One rule on every surface, so a page that names an app control spells it exactly as the app does:
+
| Kind | Case | Examples |
|---|---|---|
-| Anything that **acts or names a destination** — buttons, menu items, toolbar items, navigation titles, segment labels | Title Case | "Send Again", "Clear All", "Open Settings", "Scan Code", "History" |
-| Anything that **explains** — descriptions, hints, empty-state bodies, banner bodies, alert messages, errors, captions | Sentence case | "Finished sends and receives appear here." |
+| **Controls**: buttons, links styled as buttons, menu items, toolbar items, nav bar and footer links, navigation titles, segment labels, skip links | Title Case | "Send Again", "Clear All", "Open Settings", "How It Works", "View Source", "Skip to Content" |
+| **Content**: headings, page and document titles, descriptions, hints, empty-state bodies, banners, alerts, errors, captions, prose, the docs sidebar (it mirrors page titles) | Sentence case | "Security and privacy", "Finished sends and receives appear here." |
| The eyebrow above the code phrase | Uppercase | "READY TO SEND" |
-One uppercase label exists in the whole system and it is that eyebrow (`typography.md`). Nothing else is shouted.
+Title Case follows Apple's style: capitalize every word except articles, coordinating conjunctions and prepositions of three letters or fewer, unless first or last ("Skip to Content", "Send as Link"). The eyebrow is the only uppercase label in the system (`typography.md`).
+
+When prose names a control, quote its exact label in bold: tap **Copy Code**.
## Sentences
-- **One line, then stop.** A description that needs two sentences usually needs one shorter sentence.
+- **One line, then stop.** A description that needs two sentences usually needs one shorter one.
- **Second person, present tense.** "Enter it on the other device", not "The code should be entered".
-- **No terminal period on a fragment**, period on a full sentence. Empty-state bodies and alert messages are full sentences.
-- **Ellipsis means "this opens something".** "Change…" opens a picker. A button that acts immediately never takes one. Use the real character, not three dots.
+- **No period on a fragment**, a period on a full sentence. Empty-state bodies and alert messages are full sentences.
+- **Ellipsis means "this opens something"**: "Change…" opens a picker. A button that acts immediately never takes one. Use the `…` character.
+- **No exclamation marks**, anywhere.
+- **No internal IDs** (feature IDs like F12, requirement IDs like R40) in anything a user reads.
## Errors
State what happened and what to do, in one line, without blame.
-- "The other side declined the transfer." Not "Transfer failed (code 3)."
-- Never blame the user, never say "invalid", never say "oops".
-- No error codes, stack traces or croc CLI output in the primary line. If a detail helps, it goes in a secondary line at `--type-footnote`.
+- "The other device declined the transfer." Not "Transfer failed (code 3)."
+- Never blame the user, never "invalid", never "oops".
+- No error codes, stack traces or croc output in the primary line. A useful detail goes in a secondary `--type-footnote` line.
- If the user can fix it, the fix is in the sentence or in an adjacent button ("Open Settings").
-- If the user cannot fix it, say so plainly and do not offer a button that will not help. Camera *denied* offers Settings; camera *restricted* does not.
+- If the user cannot fix it, say so and offer no button that will not help.
## Numbers
| Thing | Format | Why |
|---|---|---|
-| File and transfer sizes | Decimal: KB, MB, GB. Never MiB | Matches Finder and the croc CLI |
-| Speed | Same unit family plus `/s` | "4.2 MB/s" |
-| Speed at rest or unknown | An em-dash placeholder, never "0 B/s" | A zero reads as a stall |
-| Progress percent | Integer, no decimals | |
-| Counts | "File 3 of 12", spelled with "of" | |
-| Every live readout | Tabular figures | `typography.md`; a reflowing speed counter is a bug |
+| File and transfer sizes | decimal kB, MB, GB, exactly as `.byteCount(style: .file)` writes them | matches Finder; never hand-format a size |
+| Speed | the same units plus `/s`: "4.2 MB/s" | |
+| Speed at rest or unknown | an em-dash placeholder, never "0 B/s" | a zero reads as a stall |
+| Exact technical thresholds | binary MiB or GiB: "128 MiB", "1 GiB" | croc defines these in binary; "128 MB" would be false and "134 MB" unrecognizable. Never for a measured size |
+| Progress percent | integer | |
+| Counts | "File 3 of 12"; inflected nouns ("1 file", "3 files"), never "file(s)" | |
+| Every live readout | tabular figures | a reflowing counter is a bug |
## Dates
-Relative inside a week ("yesterday", "3 days ago"), short absolute after. Never a raw timestamp in the UI.
+Relative inside a week ("yesterday", "3 days ago"), short absolute after. Never a raw timestamp.
## The code phrase
-- Always lowercase, always `--font-mono`, always `--tracking-code`.
-- Never auto-capitalized, autocorrected or spell-checked.
-- Wraps only at a group boundary, never mid-word.
-- Called a **code phrase** on first mention in a screen, then "the code". Never "password", never "key", never "PIN".
+- Always lowercase, mono, `--tracking-code`. Never autocapitalized, autocorrected or spell-checked.
+- Wraps only between words.
+- Called the **code phrase** on first mention on a screen or page, then "the code".
## Terminology
| Use | Not |
|---|---|
-| code phrase | password, key, PIN, passphrase |
-| the other device | peer, remote, client, recipient's machine |
-| transfer | upload, download |
-| Sending / Receiving | outgoing / incoming, TX / RX |
-| relay | server, cloud |
-| end-to-end encrypted | secure, military-grade, bank-level |
+| code phrase | password, key, PIN, passphrase, secret, transfer code |
+| the other device | peer, remote, client, receiver, the other side |
+| transfer, send, receive | upload, download (see Send as link) |
+| Sending, Receiving | outgoing, incoming, TX, RX, Uploading, Downloading |
+| relay | server, cloud (for the relay) |
+| link server | server, cloud, upload server |
+| direct connection, local network | peer-to-peer, P2P, LAN peer |
+| end-to-end encrypted (always hyphenated) | end to end encrypted, secure, military-grade, bank-level |
| croc | Croc, CROC |
| CrocApp | Croc App, CrocAPP |
+"Password" is right only for the relay password, a real password a user types for their own relay. Explaining PAKE ("password-authenticated key exchange") on How It Works is a protocol name, not a name for the code phrase.
+
+**Send as link** (stored transfers) is still sending: "Send as Link", "Sending", "Link ready". The link server is the croc project's server, or one you run, that holds the encrypted files. The one place "download" appears is as the noun for one fetch of a link: "Downloads allowed: 5", "2 of 5 downloads used".
+
## Claims
-The security claim is capped at three true statements (`brand.md`): transfers are end-to-end encrypted, the code phrase authenticates both sides, the relay only ever sees ciphertext. Do not extend past that, in any surface, including marketing copy.
+The security claim is an approved list. Each statement is true as written, under its condition; nothing outside the list appears on any surface, marketing included.
+
+1. Transfers are end-to-end encrypted.
+2. The code phrase authenticates both devices (PAKE).
+3. The relay only ever sees ciphertext.
+4. Send as link: files are encrypted on your device before they reach the link server, and the key travels in the link's `#` fragment, which browsers never send to a server.
+5. When the two devices connect over the local network, the data stays on that network.
+6. CrocApp has no accounts, analytics, ads or tracking, and history stays on the device.
+
+Always paired with the caveat where security is discussed at length: CrocApp has not had a formal third-party security audit. Never "secure", "unhackable", "anonymous" or "zero-knowledge".
diff --git a/design/iconography.md b/design/iconography.md
index 9aefd3c5..6165f487 100644
--- a/design/iconography.md
+++ b/design/iconography.md
@@ -1,78 +1,87 @@
# Iconography
-**In the app: SF Symbols only.** No bundled icon font, no custom vector where a symbol exists. SF Symbols are Apple system glyphs and cannot ship to the web, so the design system and the web properties substitute [Lucide](https://lucide.dev) (MIT) — the closest rounded-outline match. The table below is the contract between the two.
+**In the app: SF Symbols only.** No icon font, no custom vector where a symbol exists. SF Symbols cannot ship to the web, so the web substitutes [Lucide](https://lucide.dev) (ISC license), the closest rounded-outline match. The tables below are the contract between the two. A glyph not in a table gets its row before it gets its code.
-## Mapping
+## Shared meanings
-| Meaning | Lucide key (web / design system) | SF Symbol (app) |
+| Meaning | Lucide (web) | SF Symbol (app) |
|---|---|---|
-| Send / outbound | `circle-arrow-up` | `arrow.up.circle` |
-| Receive / inbound | `circle-arrow-down` | `arrow.down.circle` |
-| Sending (progress header) | `arrow-up` | `arrow.up` |
-| Receiving (progress header) | `arrow-down` | `arrow.down` |
+| Send | `circle-arrow-up` | `arrow.up.circle` (`.fill` on the Home buttons) |
+| Receive | `circle-arrow-down` | `arrow.down.circle` (`.fill` on the Home buttons) |
+| Sending (direction header) | `arrow-up` | `arrow.up` |
+| Receiving (direction header) | `arrow-down` | `arrow.down` |
| Folder | `folder` | `folder` |
| Generic file | `file` | `doc` |
| Text payload | `file-text` | `doc.text` |
| Image | `image` | `photo` |
-| Remove item | `circle-x` | `xmark.circle.fill` |
| Success | `circle-check` | `checkmark.circle` |
-| Download / install (web only) | `download` | — |
-| Confirm / copied | `check` | `checkmark` |
-| Close / decline | `x` | `xmark` |
-| Add files | `plus` | `plus` |
-| QR code | `qr-code` | `qrcode` (scanner sheet: `qrcode.viewfinder`) |
+| Confirm, copied, feature-list bullet | `check` | `checkmark` |
| Copy | `copy` | `doc.on.doc` |
-| Paste | `clipboard` | `doc.on.clipboard` |
| Encrypted (pill) | `lock` | `lock.fill` |
-| Verified / trust (full badge) | `shield-check` | `checkmark.shield.fill` |
+| Verified (full trust badge) | `shield-check` | `checkmark.shield.fill` |
| Local network | `wifi` | `wifi` |
| Warning | `triangle-alert` | `exclamationmark.triangle.fill` |
| Error | `circle-alert` | `exclamationmark.circle.fill` |
| Info | `info` | `info.circle` |
-| Disclosure | `chevron-right` | `chevron.right` |
-| Share sheet | `share` | `square.and.arrow.up` |
-| Drop zone / upload | `upload` | `square.and.arrow.up` |
-| Drag-here hint (DMG background only) | `arrow-right` | — |
-| Speed / relay | `zap` | `bolt.fill` |
+| Disclosure | `chevron-right` | `chevron.right` (system-drawn in lists) |
+| Share | `share` | `square.and.arrow.up` |
+| Drop zone | `upload` | `square.and.arrow.up` |
| Settings | `settings` | `gearshape` |
-| Star / GitHub count (web only) | `star` | — |
-| Theme: to dark (web only) | `moon` | — |
-| Theme: to light (web only) | `sun` | — |
-| Sponsor / support (web only) | `heart` | — |
-App-only screens have no web call site yet, so the Lucide column stays empty until one appears. Pick the match then, in this table, before writing the markup.
+## Web only
-| Meaning | Lucide key (web / design system) | SF Symbol (app) |
-|---|---|---|
-| History | — | `clock.arrow.circlepath` |
-| Transfer completed (history status) | — | `checkmark.circle.fill` |
-| Transfer failed (history status) | — | `xmark.circle.fill` |
-| Transfer cancelled (history status) | — | `slash.circle` |
-| Transfer declined (history status) | — | `hand.raised.fill` |
-| Code phrase (concept) | — | `key.fill` |
-| PAKE / strong key from short code | — | `lock.shield.fill` |
-| Relay | — | `antenna.radiowaves.left.and.right` |
-| Works anywhere | — | `globe` |
-| App mark in the onboarding sheet | — | `arrow.left.arrow.right.circle.fill` |
-| Camera unavailable / denied / restricted | — | `video.slash` |
-
-`xmark.circle.fill` does double duty: Remove item, and the failed history status. That is deliberate. It is never both on one screen, and the history use carries an accessibility label naming the status (`components.md` → HistoryRow).
-
-**Lucide 1.x has no brand icons** — `github.svg` was removed. The landing page's GitHub link uses `star` plus the word "GitHub" rather than the Octocat mark, which also sidesteps the trademark question. Do not reintroduce a brand glyph from an older Lucide version.
-
-**Use the canonical `circle-*` names above, not the legacy `*-circle` spellings.** `alert-circle`, `x-circle`, `arrow-up-circle` and `arrow-down-circle` still ship as byte-identical aliases, so either name renders correctly today — but `check-circle` is **not** an alias. It is a distinct glyph: an open arc (`M21.801 10A10 10 0 1 1 17 3.335`) with an oversized check breaking out of it. Reaching for it when you want the closed circle-with-tick silently draws the wrong mark. `circle-check` is the true match for `checkmark.circle`.
-
-Verified against lucide-static 1.27.0 (2007 icons), 2026-07-26.
+| Meaning | Lucide |
+|---|---|
+| Download, install | `download` |
+| Speed, native performance | `zap` |
+| Star, GitHub count | `star` |
+| Theme: to dark | `moon` |
+| Theme: to light | `sun` |
+| Sponsor, support | `heart` |
+| Drag-here hint (DMG background) | `arrow-right` |
-On the web the page inlines one `` sprite and references it with `