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 @@

CI - Latest release - Platforms - MIT License + Latest release + Platforms + MIT License Website

## 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 ``, rather than repeating path data at every call site. Same glyphs, same rules; it just stops ~30 copies of the same `` from shipping. +## App only -Check each name in the SF Symbols app before first use, and prefer `.fill` variants inside colored tiles / circles, outline variants standalone. If a screen needs a glyph that is not here, add the row before adding the code. +| Meaning | SF Symbol | +|---|---| +| Remove item | `xmark.circle.fill` | +| Scan a QR code | `qrcode.viewfinder` | +| Send as link | `link.circle.fill` | +| Receive from a link | `link` | +| The other device connected | `person.crop.circle.badge.checkmark` | +| How It Works (toolbar) | `lock.shield` | +| PAKE: a strong key from a short code | `lock.shield.fill` | +| Code phrase (concept) | `key.fill` | +| Relay | `antenna.radiowaves.left.and.right` | +| Direct path | `point.3.connected.trianglepath.dotted` | +| Works anywhere | `globe` | +| 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` | +| Advanced settings | `slider.horizontal.3` | +| App mark (onboarding) | `arrow.left.arrow.right.circle.fill` | +| Menu bar extra | `arrow.up.arrow.down.circle` | +| Camera denied, restricted or unavailable | `video.slash` | + +`xmark.circle.fill` is both Remove item and the failed status on purpose: never on one screen together, and the history use carries an accessibility label naming the status. + +`lock.shield` and `lock.shield.fill` differ on purpose: the outline is a toolbar button, the fill sits in a How It Works row. + +## Lucide rules + +- **No brand icons.** Lucide 1.x removed them (`github.svg` is gone). The GitHub link uses `star` plus the word "GitHub", never the Octocat mark. +- **Canonical `circle-*` names, never the legacy `*-circle` spellings.** Most legacy names are byte-identical aliases, but `check-circle` is a different glyph (an open arc with an oversized tick). `circle-check` is the match for `checkmark.circle`. +- The page inlines one `` sprite and references it with ``; the package is never bundled. The sprite comment names the Lucide version it was copied from. + +Names verified against lucide-static 1.59.0 on 2026-10-17. SF Symbol names are checked in the SF Symbols app before first use. ## Rendering -**Web (Lucide):** 24×24 viewBox, `fill: none`, `stroke: currentColor`, `stroke-linecap`/`linejoin: round`. Default size 22, default stroke width 1.8; use 2 – 2.4 for glyphs under 20px so they keep weight. Always `aria-hidden="true"` — the adjacent label carries the meaning. +**Web (Lucide):** 24×24 viewBox, `fill: none`, `stroke: currentColor`, round caps and joins. Always `aria-hidden="true"`: the adjacent label carries the meaning. Glyphs under 20px take stroke 2 or more so they keep weight. -Stroke weights are tokens, and unitless: `stroke-width` scales with the viewBox, so a unit on any of these breaks every glyph that uses it. +Stroke weights are unitless tokens: `stroke-width` scales with the viewBox, so a unit breaks every glyph. | Token | Value | |---|---| @@ -82,20 +91,17 @@ Stroke weights are tokens, and unitless: `stroke-width` scales with the viewBox, | `--icon-stroke-heavy` | 2.2 | | `--icon-stroke-heaviest` | 2.4 | -**App (SF Symbols):** size by text style so glyphs track Dynamic Type (`.imageScale`, or `Image(systemName:).font(.headline)`), `.symbolRenderingMode(.hierarchical)` for tinted tiles, `.monochrome` inline. Tint with `--color-accent`'s Swift equivalent, never a hardcoded green. +**App (SF Symbols):** size by text style so glyphs track Dynamic Type (`.font(.headline)`, `.imageScale`), or with `@ScaledMetric` where a component specifies a point size. Never a fixed `.font(.system(size:))`. `.symbolRenderingMode(.hierarchical)` in tinted tiles, monochrome inline. `.fill` variants inside colored circles, outlines standalone. Tint through the color aliases, never a raw color. ## Sizes in use -| Context | Size | Stroke (web) | +| Context | Size | Web stroke | |---|---|---| -| Button large / medium / small | 20 / 18 / 15 | default | -| Row leading icon (settings tile) | 17 | 2 | -| File row type icon | 20 | default | -| Remove control | 20 | default | -| Banner leading glyph | 19 | 2 | -| TrustBadge pill | 14 | 2.2 | -| TrustBadge full circle | 19 | 2 | -| StatusBlock circle | 34 | 2 | -| Direction header | 18 | 2.4 | -| Disclosure chevron | 18 | 2.4 | -| EmptyState | 44 (34 compact) | 1.5 | +| Button large / medium / small | 20 / 18 / 15 | `--icon-stroke` / `-medium` / `-heavy` | +| FileRow type glyph, remove control | 20 | `--icon-stroke` | +| StatusBanner glyph | 19 | `--icon-stroke-medium` | +| TrustBadge pill / full | 14 / 19 | `--icon-stroke-heavy` / `-medium` | +| StatusBlock | 34 | `--icon-stroke-medium` | +| Direction header | 17 | `--icon-stroke-heaviest` | +| Disclosure chevron | 17 | `--icon-stroke-heaviest` | +| DropZone, EmptyState | 44 (34 compact) | `--icon-stroke-light` | diff --git a/design/materials.md b/design/materials.md index 7006d680..0507fc36 100644 --- a/design/materials.md +++ b/design/materials.md @@ -4,41 +4,38 @@ How a surface looks and how it sits. What changes over time is `motion.md`. ## Liquid Glass -Three material weights. Each pairs a fill (`colors.md`) with a blur strength and a fixed saturation boost. +In the app, glass is `.glassEffect()` and nothing else (`swiftui.md` → Materials). Never hand-roll `.ultraThinMaterial` plus a shadow to imitate the CSS: the native effect is the target and the CSS is the approximation. + +The web approximation has three weights. Each pairs a fill (`colors.md` → Glass fills) with a blur and a fixed saturation boost: | Material | Fill | Blur | Where | |---|---|---|---| -| thin | `--color-surface-glass-thin` | `--blur-thin` 12px | light overlays, floating chrome | -| regular | `--color-surface-glass` | `--blur-regular` 24px | default panel, glass buttons | -| thick | `--color-surface-glass-thick` | `--blur-thick` 40px | sheet headers, toolbars over content | -| solid | `--color-surface-card` | — | fallback with no translucency | +| thin | `--color-surface-glass-thin` | `--blur-thin` 12px | floating chrome | +| regular | `--color-surface-glass` | `--blur-regular` 24px | the sticky nav, glass buttons | +| thick | `--color-surface-glass-thick` | `--blur-thick` 40px | bars over content | +| solid | `--color-surface-card` | none | the default on a flat page | `--glass-saturate: 180%`. -Web recipe: - ```css background: var(--color-surface-glass); backdrop-filter: blur(var(--blur-regular)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: blur(var(--blur-regular)) saturate(var(--glass-saturate)); -border: 1px solid var(--color-glass-border); -border-radius: var(--radius-lg); +border: var(--border-hairline) solid var(--color-glass-border); box-shadow: var(--shadow-glass); ``` -In SwiftUI this is `.glassEffect()` — do **not** hand-roll `.ultraThinMaterial` + shadow to imitate the CSS. The CSS is the approximation, the native effect is the target. Anything that renders behind glass must have enough contrast on its own; glass is decoration, never the thing carrying legibility. - -Glass needs something to blur. On a flat single-color background it reads as a slightly tinted rectangle — that is the expected look on the landing page, so prefer the `solid` material there unless there is real imagery behind. +Glass needs something to blur. On a flat single-color page it reads as a tinted rectangle, so the web uses `solid` unless real content scrolls behind. Anything on glass must be legible on its own: glass is decoration, never the thing carrying legibility. ## Reduced transparency -`prefers-reduced-transparency: reduce` / `.accessibilityReduceTransparency` collapses all three glass materials to `solid`: blur to 0, saturation to 100%, fill to `--color-surface-card`, border to `--color-separator`. `tokens.css` does this at the token level, so a component that consumes `--color-surface-glass` needs no branch of its own. +`prefers-reduced-transparency: reduce` and `.accessibilityReduceTransparency` collapse every glass material to `solid`: blur 0, saturation 100%, fill `--color-surface-card`, border `--color-separator`. `tokens.css` does this at the token level, so a component consuming `--color-surface-glass` needs no branch of its own; `.glassEffect()` does it natively. -This is safe precisely because glass is decoration — nothing in the system relies on it for legibility or for state. If a surface stops being readable when it turns solid, the surface was wrong, not the media query. +This is safe because nothing relies on glass for legibility or state. If a surface stops being readable when it turns solid, the surface was wrong. ## Elevation -Geometry is theme-independent; only the shadow colors flip, which is why each token is one declaration with `light-dark()` alphas rather than a pair. +Geometry is theme-independent; only the shadow alphas flip, which is why each token is one declaration with `light-dark()` alphas. | Token | Geometry | Light α | Dark α | |---|---|---|---| @@ -46,17 +43,16 @@ Geometry is theme-independent; only the shadow colors flip, which is why each to | `--shadow-md` | `0 4px 16px`, `0 1px 3px` | .08, .05 | .50, .40 | | `--shadow-lg` | `0 12px 40px`, `0 4px 12px` | .14, .08 | .60, .50 | | `--shadow-glass` | `0 8px 32px` + `inset 0 1px 0 var(--glass-highlight)` | .12 | .55 | -| `--shadow-knob` | `0 3px 8px`, `0 1px 1px` | .15, .16 | .50, .40 | -| `--shadow-focus-ring` | `0 0 0 2px var(--color-surface-base)`, `0 0 0 4px var(--color-accent)` | — | same | +| `--shadow-focus-ring` | `0 0 0 2px var(--color-surface-base)`, `0 0 0 4px var(--color-accent)` | n/a | n/a | -The inset highlight in `--shadow-glass` is the top edge-light that sells the material. Keep it. `--shadow-knob` is the switch knob's, and flips like every other shadow here: the knob is `--paper`, which is black in dark, and a 15%-black shadow under a black knob on a dark track is nothing at all. +The inset highlight in `--shadow-glass` is the top edge-light that sells the material; keep it. -Depth ladder: page → card (`sm`/`glass`) → sheet (`lg`). Three levels, no more. +Depth ladder: page, then card (`sm` or `glass`), then sheet (`lg`). Three levels, no more. System controls and sheets in the app carry their own depth; the app adds a shadow only where it draws the surface itself (the QR frame takes `--shadow-md`). -**In dark, height comes from the surface, not the shadow.** The page is black and cards step up to `#1C1C1E`; that tonal step is what reads as elevation. The shadow alphas rise in dark only so an edge stays visible against a black page, never to carry the height on their own. If something looks flat in dark, raise its surface a step or give it a separator. Do not add a shadow, and never add a shadow that exists only in dark. +**In dark, height comes from the surface, not the shadow.** The page is black and cards step up to `#1C1C1E`; that tonal step reads as elevation. The dark alphas rise only so an edge stays visible against black. If something looks flat in dark, raise its surface a step or give it a separator; never add a shadow that exists only in dark. ## Focus -`:focus-visible` clears the native outline and applies `--shadow-focus-ring` — a 4px ring in two 2px bands: `--color-surface-base` inside, `--color-accent` outside. Interactive glass and prominent buttons swap their own shadow for the ring while focused. +On web, `:focus-visible` replaces the native outline with `--shadow-focus-ring`: two 2px bands, `--color-surface-base` inside and `--color-accent` outside, so one band always has a 3.41:1 edge against whatever it surrounds (`colors.md` rule 7). A focused element swaps its own shadow for the ring. A transparent `outline` stays underneath so forced-colors mode, which drops box shadows, still draws an indicator. -**Two bands, not one halo, and never a tint.** Why, and the 1.14:1 measurement that forced it: `colors.md` rule 7. +In the app the system draws focus (`swiftui.md` → Focus). Never remove a focus indicator without replacing it. diff --git a/design/motion.md b/design/motion.md index 125768de..e2d4f3f8 100644 --- a/design/motion.md +++ b/design/motion.md @@ -6,45 +6,42 @@ How the interface changes and how it answers back. What a surface is made of is | Token | Value | Use | |---|---|---| -| `--ease-standard` | `cubic-bezier(0.4, 0, 0.2, 1)` | color/background changes | -| `--ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | entrances, position, progress fills | -| `--dur-fast` | 120ms | press feedback, hover, focus | -| `--dur-base` | 220ms | segment thumb, toggle, progress | -| `--dur-slow` | 360ms | sheet / screen transitions | -| `--press-scale` | 0.97 | buttons shrink slightly when pressed | +| `--ease-standard` | `cubic-bezier(0.4, 0, 0.2, 1)` | color and background changes, hover | +| `--ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | entrances, cross-fades, position, progress fills | +| `--dur-fast` | 120ms | press feedback, hover, drag-over | +| `--dur-base` | 220ms | phase cross-fade, progress | +| `--dur-slow` | 360ms | the slow-connection hint | +| `--press-scale-web` | 0.97 | web buttons shrink slightly when pressed | -Progress bars animate width at `--dur-base` `--ease-out`. The indeterminate bar is a 40%-wide fill sliding left `-40% → 100%` over 1.2s on `--ease-standard`, infinite. +The app takes press feedback from the system button styles and adds no scale of its own, which is why the press scale is web-only. -Under `prefers-reduced-motion` / `.accessibilityReduceMotion`: drop `--press-scale`, drop the indeterminate slide (hold a static 40% fill or a pulsing opacity), keep every state change instant rather than removed. `tokens.css` zeroes the durations and the press scale; the looping animation is the component's own to gate. +**No springs, no bounce, no overshoot.** Neither curve passes y = 1. Skills and samples that reach for `.spring` or a bouncy curve are overruled here: a transfer screen that performs is a transfer screen you do not trust. -## Choreography - -A transfer is a phase machine, and the transitions between phases are the product. `idle → starting → waiting / connecting → confirmSend | incoming → transferring → done | failed`. +Under `prefers-reduced-motion` and `.accessibilityReduceMotion`: no press scale, no indeterminate slide (hold a static fill), and every state change stays, instant rather than removed. `tokens.css` zeroes the durations and the press scale; a looping animation is its component's own to gate. In SwiftUI, pass `nil` instead of an animation when reduce motion is on. -Three rules cover all of it: +## Choreography -1. **The phase container cross-fades; its contents do not animate in separately.** One `--dur-base` `--ease-out` opacity transition on the block that swaps. No stagger, no cascade, no springs. A transfer screen that performs is a transfer screen you do not trust. -2. **What persists must not move.** The direction header, the trust badge and the code phrase keep their position across every phase that shows them. Only the block below them changes. A code phrase that jumps when the transfer starts is the single worst thing this screen can do, because it is the thing the user is reading aloud. -3. **Terminal phases arrive, they do not celebrate.** `done` and `failed` cross-fade in at `--dur-base` like any other phase. The `StatusBlock` glyph does not pop, spin or bounce. There is no overshoot easing in this system, and `brand.md` already rules out confetti. +A transfer is a phase machine, and the transitions between phases are the product: `idle → starting → waiting / connecting → confirmSend | incoming → transferring → done | failed | stored`. -Progress fills animate width at `--dur-base` `--ease-out` and **never animate backwards**. If a total revises downward, snap the value and keep the bar moving forward. +1. **The phase container cross-fades; its contents do not animate in separately.** One `--dur-base` `--ease-out` opacity transition on the block that swaps. No stagger, no cascade. +2. **What persists must not move.** The direction header, the trust badge and the code phrase keep their position across every phase that shows them. A code phrase that jumps when the transfer starts is the worst thing this screen can do, because it is what the user is reading aloud. +3. **Terminal phases arrive, they do not celebrate.** `done` and `failed` cross-fade in like any other phase. The StatusBlock glyph does not pop, spin or bounce, and there is no confetti (`brand.md` → Personality). -Entering `waiting` is the one moment worth a beat: the code phrase appears with the container cross-fade, and nothing else on screen changes at the same time. +Progress fills animate width at `--dur-base` `--ease-out` and **never move backwards**. If a total revises downward, snap and keep moving forward. -The "taking longer than usual" hint fades in at `--dur-slow`, well after the delay that triggers it, so it reads as information rather than as an alarm. +The "taking longer than usual" hint fades in at `--dur-slow`, after the delay that triggers it, so it reads as information rather than alarm. ## Haptics and sound -**No sound, ever.** This app never plays audio. A file transfer that beeps in a meeting is a file transfer people turn off. +**No sound, ever.** A file transfer that beeps in a meeting is a file transfer people turn off. -Haptics are iPhone only. iPad's are inconsistent across models and macOS has none, so haptics are always additive: every event below is already visible and already announced. +Haptics are iPhone only: iPad's are inconsistent across models and macOS has none. They are always additive: every event below is already visible and announced. | Event | Feedback | |---|---| | transfer completed | `.success` | -| transfer failed, declined, or blocked | `.error` | +| transfer failed, declined by the other device, or blocked | `.error` | | incoming request arrives (the accept gate) | `.warning` | | code phrase copied, code scanned | `.selection` | -| everything else | none | -Four events, and no more without a reason written down. Nothing fires on progress, on phase changes inside a running transfer, on button presses, or on navigation: the press scale is the button's feedback. Respect the system haptic setting rather than adding a switch of our own. +Four events, and no more without a reason written down. Nothing fires on progress, phase changes inside a running transfer, button presses or navigation, so a Cancel or Decline the user tapped ends silently. The system haptic setting is respected; there is no switch of our own. diff --git a/design/platforms.md b/design/platforms.md index 06b21324..249c001d 100644 --- a/design/platforms.md +++ b/design/platforms.md @@ -1,55 +1,53 @@ # Platforms -iPhone, iPad and macOS run the same tokens and the same components. This file is the short list of what legitimately differs. `swiftui.md` says which API to reach for; this says which layout and which input model to design against. +iPhone, iPad, macOS and the two websites run the same tokens and components. This is the short list of what legitimately differs. `swiftui.md` and `web.md` say which API; this says which layout and input model. -Rule of thumb: **density, chrome and input change. Nothing else does.** A screen that needs a different component on macOS is a screen that was designed wrong on iPhone. +Rule of thumb: **density, chrome and input change. Nothing else does.** A screen that needs a different component on macOS was designed wrong on iPhone. ## The column -`--content-max-width` (480px) is the content column on every platform, including macOS and iPad. Extra width becomes margin, never wider text. A 480px column on a 13" display looks deliberate; a 900px settings row does not. - -Sheets cap at `--sheet-max-width` (540px). +`--content-max-width` (480px) is the app's content column on every device. Extra width becomes margin, never wider text. The websites use the web caps instead (`spacing-layout.md` → Web layout). ## iPhone -The baseline every screen is designed at. Compact width, touch only, no hover, no pointer, system sheets full-height with detents. +The baseline every screen is designed at: compact width, touch only, no hover, sheets full height with detents. The only device with haptics (`motion.md`). ## iPad -Same layout as iPhone, centred, with the column cap doing the work. Three differences: - -- **Menu bar.** `AppCommands` reaches iPad through the hardware-keyboard menu bar, so every File-menu action must have a keyboard-reachable equivalent. -- **Pointer.** A trackpad or a Magic Keyboard gives iPad a pointer, so hover applies (below) and pointer effects are allowed on bare icon buttons. -- **Sheets present as form sheets** in regular width rather than full height. The content inside does not change. +The iPhone layout, centered, with the column cap doing the work. -Nothing in the app may require a pointer, because most iPads do not have one. +- **Menu bar.** `AppCommands` reaches iPad through the hardware-keyboard menu bar, so every menu action has a keyboard-reachable equivalent. +- **Pointer.** A trackpad gives iPad hover (below) and pointer effects on bare icon buttons. Nothing may require a pointer, because most iPads have none. +- **Sheets** present as form sheets in regular width. Their content does not change. ## macOS -- **Window.** Main window opens at 480×780 and is resizable from its content minimum. Settings opens at 560×700 with a 480×420 floor. No sidebar, no split view, no tab bar: this app has one job and one column. -- **Settings is a `Form`**, `.formStyle(.grouped)`, in the standard Settings scene (⌘,). It is not a screen inside the window. -- **Drop is window-wide.** The whole window is a drop target, with the full-window "Drop to send" overlay (`components.md` → DropZone). The Send list's own drop target wins while the pointer is over it. -- **No camera, no QR scan, no local-network prompt.** Those iOS affordances are absent, not disabled, and no macOS screen shows a placeholder for them. -- **Menu bar is a real surface.** Anything the toolbar can do, the menu can do, with a shortcut. -- **Right-click exists.** Context menus are additive shortcuts, never the only path to an action. +- **Windows.** Main window opens at 560×700, minimum 480×560. Settings opens at 480×780, minimum 480×420. No sidebar, split view or tab bar: one job, one column. +- **Settings** is the Settings scene (⌘,), a grouped `Form` (`components.md` → System controls). +- **Menu bar extra.** A 300-wide window for quick sends (`components.md` → MenuBarQuickSend). +- **Drop is window-wide**, with the overlay (`components.md` → DropZone). +- **No camera, no QR scan, no local-network prompt.** Absent, not disabled, and no placeholder. +- **Menu bar is a real surface.** Anything the toolbar can do, the menu can do, with a shortcut. Right-click menus are additive shortcuts. -## Hover +## Web -Hover exists on macOS, on iPad with a pointer, and on both websites. It never exists on touch. +Landing page and docs site: a pointer and a keyboard, both themes following the OS, page-width layout (`web.md`). No haptics, no glass by default. + +## Hover -**Hover applies the element's pressed background, and nothing else.** No scale, no lift, no shadow change, no movement. Pressed is then hover plus `--press-scale`. Transition `--dur-fast` `--ease-standard`. +Hover exists on macOS, iPad with a pointer, and the web. Never on touch. -Two hard rules: +**Hover applies the element's pressed background and nothing else**: no scale, lift, color or shadow change. On web, wrap hover styles in `@media (hover: hover)` so a tap never leaves one stuck. -1. **Nothing is only discoverable on hover.** No hover-only buttons, no hover-only labels, no hover-to-reveal remove controls. Touch users and keyboard users get the same affordances at rest. -2. **Hover is not focus.** A hovered element does not take `--shadow-focus-ring`; that belongs to `:focus-visible` alone. +1. **Nothing is only discoverable on hover.** Touch and keyboard users get the same affordances at rest. +2. **Hover is not focus.** A hovered element does not take the focus ring. ## What must not diverge | Never platform-specific | |---| -| The accept gate. Auto-accept stays off on every platform unless the user turns it on | -| Direction wording. "Sending" / "Receiving" as words, everywhere | +| The accept gate. Auto-accept is off by default everywhere and only the user turns it on | +| Direction wording: "Sending" and "Receiving" as words | | The code phrase format and its mono treatment | | Contrast rules and the accessibility floor | | Token values. A platform may pick a different API, never a different number | diff --git a/design/spacing-layout.md b/design/spacing-layout.md index 1624d3c9..4806505c 100644 --- a/design/spacing-layout.md +++ b/design/spacing-layout.md @@ -18,103 +18,98 @@ | `--space-10` | 48px | | `--space-11` | 64px | -Common usage: screen horizontal padding `--space-5`, card padding `--space-6`, stack gap between unrelated blocks `--space-6`/`--space-7`, gap inside a row `--space-3`/`--space-4`. +Common usage: screen padding `--space-5`, card padding `--space-6`, gap between unrelated blocks `--space-6` or `--space-7`, gap inside a row `--space-3` or `--space-4`. In SwiftUI a bare `.padding()` is the system default, not a token: write `.padding(Spacing.space5)`. ## Radii -iOS 26 continuous corners. **Concentric rule: outer radius = inner radius + the gap between them.** A 12px-padded card at `--radius-lg` (20) holds children at ~8 — round down to `--radius-xs`/`--radius-sm` rather than inventing a value. +iOS 26 continuous corners. **Concentric rule: outer radius = inner radius + the gap between them.** A 12px-padded card at `--radius-lg` (20) holds children at about 8: round down to `--radius-xs` or `--radius-sm` rather than inventing a value. | Token | Value | Applies to | |---|---|---| | `--radius-xs` | 6px | inner chips, nested fills | -| `--radius-sm` | 10px | segmented control track, small tinted buttons | -| `--radius-md` | 14px | fields, list rows, banners, QR frame | -| `--radius-lg` | 20px | glass cards / panels | -| `--radius-xl` | 28px | sheets, large containers | -| `--radius-2xl` | 36px | full-screen glass surfaces | -| `--radius-capsule` | 999px | prominent buttons, badges, progress bars, switch | +| `--radius-sm` | 10px | small tinted buttons, the paste button | +| `--radius-md` | 14px | fields, banners, list cards, QR frame | +| `--radius-lg` | 20px | cards, the drop zone | +| `--radius-xl` | 28px | the code phrase pill, large containers | +| `--radius-2xl` | 36px | the macOS drop overlay card | +| `--radius-capsule` | 999px | buttons, badges, progress bars | -On Apple platforms use `.rect(cornerRadius:style: .continuous)` — never the default circular style, it visibly mismatches system chrome. +On Apple platforms always use `style: .continuous` (`.rect(cornerRadius:style:)`, `RoundedRectangle(cornerRadius:style:)`). The circular style visibly mismatches system chrome. -## Layout caps +## Layout cap | Token | Value | Meaning | |---|---|---| -| `--content-max-width` | 480px | content column cap; centered on wider viewports | -| `--sheet-max-width` | 540px | sheet / modal cap | +| `--content-max-width` | 480px | the app's content column on every device; extra width becomes margin | -Every platform keeps this column cap; extra width becomes margin. What legitimately differs per platform is `platforms.md`. +What legitimately differs per device is `platforms.md`. -## Web layout (landing + docs only) +## Control sizing + +| Token | Value | Meaning | +|---|---|---| +| `--control-height-lg` | 50px | `.controlSize(.large)` buttons, the code field | +| `--control-height-md` | 38px | medium buttons, the paste button | +| `--control-height-sm` | 30px | small buttons | +| `--hit-target-min` | 44px | HIG minimum target: the floor for anything interactive | +| `--icon-size-sm` | 17px | inline and row icons | +| `--icon-size-md` | 22px | default icon | +| `--icon-size-lg` | 28px | prominent icons | + +Component constants (StatusBlock circle 64, TrustBadge full circle 34, QR frame 168, file row 48, drop zone 168) are specced in `components.md`. The app keeps the ones shared by more than one view in the `ComponentMetrics` enum and the rest private to their view; neither is a licence to invent a number at a call site. + +## Web layout (landing and docs only) -Never used by the app. `--content-max-width` is a phone-width column; a marketing or docs page needs a page-width container, a readable prose measure, and rhythm that scales with the viewport. Both ends of every `clamp()` sit on the 4pt grid. +`--content-max-width` is a phone-width column. A web page needs a page container, a prose measure, and rhythm that scales with the viewport. Both ends of every `clamp()` sit on the 4pt grid. | Token | Value | Meaning | |---|---|---| -| `--content-max-width-wide` | 1280px | hero, full-bleed rows, screenshot galleries | +| `--content-max-width-wide-web` | 1280px | hero, full-bleed rows, screenshot galleries | | `--content-max-width-web` | 1080px | default section container | -| `--content-max-width-prose` | 680px | paragraph measure (~75ch) | +| `--content-max-width-prose-web` | 680px | paragraph measure (about 75ch) | | `--gutter-web` | `clamp(20px, 5vw, 48px)` | page horizontal padding | | `--section-pad-web` | `clamp(64px, 9vw, 112px)` | vertical gap between sections | | `--stack-web` | `clamp(32px, 4vw, 48px)` | gap between blocks inside a section | -| `--store-badge-height` | 40px | Apple's stated minimum for a supplied App Store badge | -| `--step-marker-size` | 32px | numbered step marker in a How-it-works list | +| `--nav-height-web` | 56px | landing sticky nav; clears `--hit-target-min` with room either side | +| `--grid-min-card-web` | 232px | `auto-fit` column floor: 4-up at 1080, 2-up at 768, 1-up at 375. The largest grid value that fits four columns in `--content-max-width-web` (984 usable, minus 3 × 16 gap = 936, ÷ 4 = 234) | +| `--store-badge-height-web` | 40px | Apple's minimum onscreen badge height (`brand.md` → App Store badges) | +| `--step-marker-size-web` | 32px | numbered marker in a How it works list | | `--mascot-size-web` | 64px | mascot in a footer or empty state | -| `--phone-shot-max-width-web` | 260px | a phone screenshot inline in docs prose. `--content-max-width` is the app's own column and is the cap for a screenshot that *is* the figure (`web.md` → Width); a portrait phone shot dropped between paragraphs is more than twice as tall as it is wide, so at 480 it pushes the prose around it off the screen | +| `--phone-shot-max-width-web` | 260px | a phone screenshot inline in docs prose. A portrait shot at 480 is over twice as tall as wide and pushes the prose off screen | -### Breakpoints - -Three, and no more without a reason written down. **These are literals by necessity** — a custom property does not resolve inside a media condition, so the numbers appear in the stylesheet and this table is the only place they are defined. `rem`, not `px`, so they respond to the user's font size. - -| Width | What changes | -|---|---| -| `30rem` | Nav collapses the GitHub label to its glyph and count | -| `48rem` | Nav keeps only its primary anchor; the rest drop | -| `64rem` | Hero splits into copy + mockup columns | - -### Stacking order +### Web component metrics -Two layers, both tokenized. Anything that wants a third needs a reason first. +The same numbers `components.md` specifies, as tokens because web CSS may not hold a raw px. -| Token | Value | Layer | +| Token | Value | From | |---|---|---| -| `--z-nav` | 2 | sticky bar, above page content | -| `--z-skip-link` | 3 | above the bar it exists to jump past | +| `--btn-pad-x-lg-web` / `-md-` / `-sm-` | 22px / 16px / 12px | Button size table | +| `--btn-gap-lg-web` / `-md-` / `-sm-` | 8px / 6px / 5px | Button size table | +| `--btn-icon-lg-web` / `-md-` / `-sm-` | 20px / 18px / 15px | Button size table, `iconography.md` | +| `--pill-pad-block-web` | 5px | TrustBadge pill | +| `--pill-pad-inline-start-web` / `-end-` | 9px / 11px | TrustBadge pill. Asymmetric: the glyph sits tighter to the leading edge | +| `--pill-icon-size-web` | 14px | TrustBadge pill | -## Control sizing +### Breakpoints -| Token | Value | Meaning | -|---|---|---| -| `--control-height-lg` | 50px | `.controlSize(.large)` buttons, code field | -| `--control-height-md` | 38px | medium buttons, inline paste button | -| `--control-height-sm` | 30px | small buttons, segment min height | -| `--hit-target-min` | 44px | HIG minimum tap target — floor for anything interactive | -| `--icon-size-sm` | 17px | inline / row icons | -| `--icon-size-md` | 22px | default icon | -| `--icon-size-lg` | 28px | prominent icons | +Three, and no more without a reason written down. They are literals in the stylesheet because a custom property does not resolve inside a media condition, so this table is the only place they are defined. `rem`, so they follow the user's font size. -These are not tokens, they are component constants, specced in `components.md`: settings row min 52, file row min 48, sheet header min 52, switch 51×31, settings row icon tile 29×29 at radius 7, StatusBlock glyph circle 64, TrustBadge full glyph circle 34, QR frame default 168. +| Width | What changes | +|---|---| +| `30rem` | nav collapses the GitHub label to its glyph and count | +| `48rem` | nav keeps only its primary anchor | +| `64rem` | hero splits into copy and screenshot columns | -The app collects them in one `ComponentMetrics` enum rather than repeating them at call sites, and the web side tokenizes the subset it needs (below). Both are the same numbers; neither is a licence to invent a new one at a call site. +The docs theme (Docusaurus) collapses its navbar at its own breakpoint; that one is the theme's, not ours. -## Component metrics tokenized for web (landing + docs only) +### Stacking order -Same numbers `components.md` already specifies, promoted to tokens because the web side forbids raw px and a component constant has to be *nameable* to be usable there. SwiftUI keeps them as constants inside the component — **do not use these tokens in the app.** +Two layers. Anything that wants a third needs a reason first. -| Token | Value | From | +| Token | Value | Layer | |---|---|---| -| `--btn-pad-x-lg` / `-md` / `-sm` | 22px / 16px / 12px | Button size table | -| `--btn-gap-lg` / `-md` / `-sm` | 8px / 6px / 5px | Button size table | -| `--btn-icon-lg` / `-md` / `-sm` | 20px / 18px / 15px | Button size table, `iconography.md` | -| `--row-min-file` | 48px | FileRow min height | -| `--progress-height` | 6px | ProgressBar track and fill | -| `--pill-pad-block` | 5px | TrustBadge pill | -| `--pill-pad-inline-start` / `-end` | 9px / 11px | TrustBadge pill — asymmetric, the glyph sits tighter to the leading edge | -| `--pill-icon-size` | 14px | TrustBadge pill | -| `--nav-height-web` | 56px | landing sticky nav; clears `--hit-target-min` 44 with `--space-2` breathing room either side | -| `--grid-min-card` | 232px | `auto-fit` column floor: 4-up at 1080, 2-up at 768, 1-up at 375. Largest 4pt-grid value that still fits four columns in `--content-max-width-web` (984 usable − 3×16 gap = 936; 936 ÷ 4 = 234) | - -`--btn-pad-x-md`, `--btn-pad-x-sm` and `--btn-gap-lg` are aliases of `--space-5`, `--space-4` and `--space-3`. They exist so a call site reads as button geometry rather than as an arbitrary grid step. +| `--z-nav-web` | 2 | sticky bar, above page content | +| `--z-skip-link-web` | 3 | above the bar it jumps past | ## Borders @@ -123,10 +118,8 @@ Same numbers `components.md` already specifies, promoted to tokens because the w | `--border-hairline` | 1px | | `--border-hairline-half` | 0.5px | -Grouped lists separate rows with `inset 0 -0.5px 0 var(--color-separator)` (a half-pixel inset shadow, not a border) so the row corners stay clean; the last row omits it. - -Focus is a 4px halo (`--shadow-focus-ring`), not a border — see `materials.md`. +Focus is a ring (`--shadow-focus-ring`, `materials.md` → Focus), never a border. ## Writing direction -Every spec names a leading and a trailing edge, never a left and a right. Nothing in this system is laid out in physical directions. The CSS side of that is `web.md` → Writing direction. +Every spec names a leading and a trailing edge, never left and right. The CSS side is `web.md` → Writing direction; SwiftUI is leading/trailing by construction. diff --git a/design/swiftui.md b/design/swiftui.md index 10f5ea2a..ffee1aef 100644 --- a/design/swiftui.md +++ b/design/swiftui.md @@ -1,39 +1,41 @@ -# Token → SwiftUI mapping +# Token to SwiftUI -How the tokens translate to the app. The app side of this is live: `app/CrocApp/Support/DesignTokens.swift` holds the `Spacing`, `Radius`, `BorderWidth`, `ControlHeight`, `IconSize`, `LayoutCap`, `ComponentMetrics`, `Motion` and `Shadow` enums plus the `Color` aliases, and `app/CrocApp/Assets.xcassets` holds the color sets. Both are hand-synced mirrors of this directory — change a value here and change it there in the same commit. +How the tokens reach the app. `app/CrocApp/Support/DesignTokens.swift` holds the constants and color aliases; `app/CrocApp/Assets.xcassets` holds the color sets. `scripts/verify-design.sh` checks both against `tokens.css`. -The iOS 26 APIs below were checked against Apple's documentation on 2026-07-26; re-check anything that fails to compile rather than working around it, since these are new enough to still move. +The iOS 26 APIs here were checked against Apple's documentation on 2026-07-26. If one fails to compile, re-check the docs rather than working around it. ## Colors -An asset-catalog **Color Set per semantic alias**, with Any/Dark appearances filled from `colors.md`. Where a system semantic already matches a token exactly, the alias routes to the system semantic instead of duplicating a color set. Never reference a `--green-*` step from a view — views use the aliases only. +One color set per semantic alias that has no exact system equivalent; a system semantic otherwise. Views use the `Color` aliases in `DesignTokens.swift`, never a color set name, a `--green-*` step or a raw `.green`/`.red`/`.orange`/`.blue`. | Token | App | |---|---| -| `--color-accent` | `AccentColor` color set (`#1E9E6A` / `#2DC585`); reach it via `.tint()` and `Color.accentColor` | -| `--color-accent-pressed` | color set; only needed for custom button styles | -| `--color-accent-text` | color set (`#178055` / `#2DC585`) | -| `--color-accent-text-on-tint` | color set (`#116243` / `#2DC585`) | -| `--color-on-accent` | color set (`#FFFFFF` / `#05271A`) | -| `--color-on-status` | `.white` — the same in both themes, so it needs no color set | -| `--color-text-primary/secondary/tertiary` | `.primary` / `.secondary` / `Color(.tertiaryLabel)` — prefer the system semantics over custom sets | -| `--color-status-success/warning/error/info` | `.green` / `.orange` / `.red` / `.blue` (system semantics already match the token values) | -| `--color-status-*-tint` | the status color at `.opacity(0.14)` light / `0.18` dark, or a color set | -| `--color-surface-grouped` | `Color(.systemGroupedBackground)` (iOS); on macOS use `.windowBackground` | -| `--color-surface-card` | `Color(.secondarySystemGroupedBackground)` (iOS); macOS `.background(.regularMaterial)` or a color set | -| `--color-surface-base` | `Color(.systemBackground)` (iOS); macOS has no "base" surface distinct from the window background, so a bordered control's own fill takes `.textBackgroundColor` | -| `--color-separator` | `Color(.separator)` / the `Divider` default | -| `--color-scrim` | `.black.opacity(0.25 / 0.5)` | - -`Color(.systemGroupedBackground)` and friends are UIKit-backed and iOS-only — cross-platform views need a color set or an `#if os()` split. `DesignTokens.swift` is the one file that carries that split; keep it there. - -**`Color.accentColor` is a fill, not a label color.** `colors.md` rule 1 binds the app exactly as it binds the web: `.foregroundStyle(.tint)` on a word is 3.41:1 in the light theme and fails. Text that has to read as accent — a `.bordered` button's label, a plain-style action, a link — takes the `--color-accent-text` set, or `--color-accent-text-on-tint` on a grouped background or an accent tint. Both collapse to `#2DC585` in dark, so this only ever costs anything in light. - -`.secondary` is `--label-2` (0.60), not the web alias `--label-2-web` (0.75). Deliberate — `colors.md` rule 4. +| `--color-accent` | `AccentColor` set; `Color.accentFill` (also the global tint) | +| `--color-accent-pressed` | `ColorAccentPressed` set; `Color.accentPressed` | +| `--color-accent-tint` | `ColorAccentTint` set; `Color.accentTint` | +| `--color-accent-text`, `--color-text-link` | `ColorAccentText` set; `Color.accentText` | +| `--color-accent-text-on-tint` | `ColorAccentTextOnTint` set; `Color.accentTextOnTint` | +| `--color-on-accent` | `ColorOnAccent` set; `Color.onAccent` | +| `--color-on-status` | `Color.onStatus` (`.white` in both themes) | +| `--color-text-primary/secondary/tertiary` | `.primary` / `.secondary` / tertiary label, via `Color.textPrimary/Secondary/Tertiary` | +| `--color-status-success/warning/error/info` | `Color.statusSuccess/Warning/Error/Info` (system green, orange, red, blue match the tokens) | +| `--color-status-*-tint` | `ColorStatus*Tint` sets; `Color.status*Tint` | +| `--color-surface-grouped` | `Color.surfaceGrouped` (iOS grouped background, macOS window background) | +| `--color-surface-card` | `Color.surfaceCard` | +| `--color-surface-base` | `Color.surfaceBase` | +| `--color-separator` | `Color.separatorToken` | +| `--color-scrim` | `ColorScrim` set; `Color.scrim` | +| `--paper` | `ColorPaper` set; `Color.paper` (a glyph knocked out of a solid fill) | + +UIKit/AppKit-backed colors need an `#if os()` split; `DesignTokens.swift` is the one file that carries it. + +**`Color.accentFill` is a fill, not a label color** (`colors.md` rule 1). `AccentColor` is the global tint, so every `.bordered`, `.borderless` and plain button label inherits it at 3.41:1 unless the button variant sets the label. That is why buttons go through the variants below and never style themselves. + +`.secondary` is `--label-2` (0.60), not the web's `--label-2-web` (0.75): deliberate, `colors.md` rule 4. ## Typography -The type scale **is** `Font.TextStyle`, so use the styles and inherit Dynamic Type for free: +The type scale is `Font.TextStyle`: | Token | App | |---|---| @@ -45,109 +47,84 @@ The type scale **is** `Font.TextStyle`, so use the styles and inherit Dynamic Ty | `--type-subhead` | `.subheadline` | | `--type-footnote` | `.footnote` | | `--type-caption1/2` | `.caption` / `.caption2` | -| `--type-code-hero` | `.system(size: 30, weight: .bold, design: .monospaced)` — the one place a literal size is acceptable, and it must still scale (`@ScaledMetric`) | -| `--type-code-body` | `.body.monospaced()` | +| `--type-code-hero` | `.codeHeroTextStyle()`: 30pt bold monospaced through `@ScaledMetric` | +| `--type-code-body` | `.subheadline.monospaced()` | +| `--tracking-code`, `--tracking-eyebrow` | `.tracking(Tracking.code)`, `.tracking(Tracking.eyebrow)` | | tabular figures | `.monospacedDigit()` | -Never set `.font(.system(size:))` for anything in the scale. Never bundle a font file. +Never a fixed `.font(.system(size:))`: text takes a style, and a glyph size a component specifies goes through `@ScaledMetric`. Never bundle a font. -## Spacing and shape +## Spacing, shape, layout | Token | App | |---|---| -| `--space-*` | a `Spacing` enum of `CGFloat` constants mirroring the 4pt scale | -| `--radius-md/lg/xl` | `RoundedRectangle(cornerRadius:, style: .continuous)`, or `ConcentricRectangle` for nested corners | -| `--radius-capsule` | `Capsule()` / `.buttonBorderShape(.capsule)` | -| `--control-height-lg` | `.controlSize(.large)` | -| `--hit-target-min` | `.frame(minWidth: 44, minHeight: 44)` on bare icon buttons | -| `--content-max-width` | `.frame(maxWidth: 480)` on the content column | - -Always `.continuous` corners. The default circular style visibly mismatches system chrome. - -`ConcentricRectangle` enforces the concentric rule from `spacing-layout.md` for free — corners resolve against the container instead of being computed by hand: - -```swift -ConcentricRectangle(uniformTopCorners: .fixed(24.0), uniformBottomCorners: .concentric) -``` - -Corner styles are `.concentric`, `.concentric(minimum:)` and `.fixed(_:)`. A custom container has to declare `.containerShape(_:)` with a `RoundedRectangularShape` for the resolution to have anything to work from; without one the computed radius can collapse to zero, which is what `.concentric(minimum:)` guards against. +| `--space-*` | `Spacing.space1` … `Spacing.space11` | +| `--radius-*` | `Radius.xs` … `Radius.xxl` (`--radius-2xl`), always `style: .continuous` | +| `--radius-capsule` | `Capsule()`, `.buttonBorderShape(.capsule)` | +| `--border-hairline(-half)` | `BorderWidth.hairline`, `.hairlineHalf` | +| `--control-height-*`, `--hit-target-min` | `ControlHeight.large/medium/small`, `.hitTargetMin`; large buttons take `.controlSize(.large)` | +| `--icon-size-*` | `IconSize.small/medium/large` | +| `--content-max-width` | `LayoutCap.contentMaxWidth` | +| `--opacity-disabled-container` | `Opacity.disabledContainer` | +| component constants | `ComponentMetrics` when shared by two views, otherwise a private constant in the view | ## Materials -`--color-surface-glass*` + `--blur-*` collapse to **`.glassEffect()`**: +Glass is `.glassEffect(_:in:)`; several glass surfaces share a `GlassEffectContainer` so they render and morph as one. `.accessibilityReduceTransparency` is handled by the system effect. Shadows: only drawn surfaces take one, through `.shadowMd()` (`materials.md` → Elevation). -```swift -func glassEffect(_ glass: Glass = .regular, in shape: some Shape = DefaultGlassEffectShape()) -> some View -``` +## Focus -`Glass` carries the variants (`.regular`, `.clear`). Multiple glass surfaces belong inside a **`GlassEffectContainer`** — it renders them as one shape set, which is both faster and what lets them morph into each other. Never hand-roll the CSS recipe with `.ultraThinMaterial` plus a shadow (`materials.md` → Liquid Glass). +The system draws focus on both platforms. `--shadow-focus-ring` is a web construct. A field's focus is its caret plus the accent border (`components.md` → CodeField). -`.accessibilityReduceTransparency` falls back to `--color-surface-card`, not to a weaker blur (`materials.md` → Reduced transparency). - -Shadows: `--shadow-sm/md/lg` map to `.shadow(radius:y:)` with the same geometry, but prefer the material's own depth where one exists. `Shadow` in `DesignTokens.swift` currently carries only `md`; `sm`, `lg` and `knob` are still literal at their call sites, and `--shadow-focus-ring` has no Swift form at all (see below). +## Motion -## Focus in the app +| Token | App | +|---|---| +| `--dur-fast/base/slow` | `Motion.durationFast/Base/Slow` | +| `--ease-out` | `Motion.easeOut(_:)` = `.timingCurve(0.16, 1, 0.3, 1, duration:)` | +| `--ease-standard` | `Motion.easeStandard(_:)` = `.timingCurve(0.4, 0, 0.2, 1, duration:)` | -`--shadow-focus-ring` is a `box-shadow` construct and does **not** bind SwiftUI. On iOS a field's focus is its caret plus the accent border (`components.md` → CodeField); on macOS the system draws its own focus ring and overriding it is wrong. The app's obligation is the one in `accessibility.md`: a visible focus indicator that is never removed. The two-band ring is how the web meets that, not how the app does. +Phase changes animate with `Motion.easeOut(Motion.durationBase)` on the swapping container and `.transition(.opacity)`. Read `@Environment(\.accessibilityReduceMotion)` and pass `nil` when it is on. No `.spring`, no `.bouncy`, no `.snappy` (`motion.md`). -## What the app deliberately does not mirror +Haptics: `.sensoryFeedback(_:trigger:)`, iPhone only (`UIDevice.current.userInterfaceIdiom == .phone`), the four events in `motion.md` → Haptics and sound. No audio. -Not every token has a Swift counterpart, and the gaps are decisions, not drift: +## Components -| Token group | Why not | +| Design | SwiftUI | |---|---| -| `--tracking-*` | text styles carry Apple's own optical tracking. Setting `.tracking()` on a system font fights the platform. The exception is `--tracking-eyebrow`, which is real letter-spacing on an uppercase label | -| `--icon-stroke-*` | SF Symbols have weights, not strokes. Size by text style, weight by `.symbolRenderingMode` | -| `--*-web`, `--btn-*`, `--pill-*`, `--nav-height-web`, `--grid-min-card`, `--z-*` | web-only by construction. `spacing-layout.md` says so | -| `--blur-*`, `--glass-*` | `.glassEffect()` owns these; there is nothing to set | +| Button `prominent` | `.buttonStyle(.borderedProminent)`, `.buttonBorderShape(.capsule)`, label `Color.onAccent` | +| Button `secondary` | `.buttonStyle(.bordered)`, tint `Color.accentFill`, label `Color.accentTextOnTint` | +| Button `destructive` | `.buttonStyle(.borderedProminent)`, tint `Color.statusError`, label `Color.onStatus` | +| Button `destructive-tinted` | `.buttonStyle(.bordered)`, tint `Color.statusError`, label `Color.textPrimary` | +| Button `plain` | `.buttonStyle(.borderless)`, label `Color.accentTextOnTint`, which clears AA on every app surface (grouped page and cards alike) | -## States +The variants live in `app/CrocApp/Views/ButtonVariants.swift` as one `View` modifier (`.buttonVariant(.prominent)`), and the label color is set on the label content so the system style cannot override it. Large primary actions add `.controlSize(.large)`. A `role: .destructive` button always takes `destructive` or `destructive-tinted`. -| Token | App | -|---|---| -| `--opacity-disabled` 0.4 | the default `.disabled()` dimming; only override where SwiftUI does not dim for you | -| `--opacity-disabled-container` 0.5 | `.opacity()` on a row or field that dims as a whole | -| hover | `.onHover` on macOS and iPad-with-pointer; apply the pressed background, never a scale. Bare icon buttons may take `.hoverEffect()` | -| loading | keep the label and the frame, swap the leading glyph for `ProgressView()`, `.disabled(true)`. No skeletons | +Everything else is mapped in `components.md`: drawn components name their measurements, system controls name their API. -## Haptics +## Allowed literals -`.sensoryFeedback(_:trigger:)`, iPhone only, four events, all additive to something already visible. The table is in `motion.md` → Haptics and sound. The app plays no audio at all. +`DesignTokens.swift`, the color sets, and these, each next to the spec it comes from: -## Components +- component constants named in `components.md` (12 QR padding, 22% banner border, 168 drop zone), as private `let`s or `ComponentMetrics`; +- `Color.white` for the QR frame, which is white in both themes; +- window sizes from `platforms.md` in the scene declarations. -| Design system | SwiftUI | -|---|---| -| Button `prominent` | `.buttonStyle(.borderedProminent)` `.controlSize(.large)` `.buttonBorderShape(.capsule)`; `.glassProminent` where appropriate | -| Button `secondary` | `.buttonStyle(.bordered)` + `.tint(.accentColor)`; label takes `--color-accent-text-on-tint` | -| Button `destructive` | `.buttonStyle(.borderedProminent)` + `.tint(.red)`, or `Button(role: .destructive)` | -| Button `glass` | `.buttonStyle(.glass)` | -| DropZone | `.dropDestination(for:)` + a visible file-picker button beside it | -| Button `plain` | `.buttonStyle(.plain)` / `.borderless` | -| GlassCard | container + `.glassEffect()` + `.padding(20)` | -| SegmentedControl | `Picker` + `.pickerStyle(.segmented)` | -| CodeField | `TextField` + `.textFieldStyle(.plain)` inside a bordered container; `.monospaced()`, `.textInputAutocapitalization(.never)`, `.autocorrectionDisabled()`; trailing `PasteButton` | -| SettingsRow | `List`/`Form` rows, `.listStyle(.insetGrouped)` on iOS, `Form` + `.formStyle(.grouped)` on macOS | -| Switch | `Toggle` (`.tint(.green)` to match the token) | -| FileRow | `List` row, `.truncationMode(.middle)` for the filename | -| ProgressBar | `ProgressView(value:total:)` `.progressViewStyle(.linear)`; indeterminate = `ProgressView()` | -| StatusBanner | custom view; `.accessibilityAddTraits(.isStaticText)` and an announcement for the error case | -| StatusBlock | custom centered view | -| EmptyState | `ContentUnavailableView` | -| SheetHeader | `.toolbar` with `ToolbarItem(placement: .cancellationAction / .confirmationAction)` | -| TrustBadge | custom capsule / row | +## Not mirrored -## Motion +These tokens have no Swift form on purpose. `scripts/verify-design.sh` skips them and everything with a `web` name segment: -| Token | App | +| Tokens | Why | |---|---| -| `--dur-fast` 120ms | `.easeOut(duration: 0.12)` | -| `--dur-base` 220ms | `.easeOut(duration: 0.22)` or `.spring(duration: 0.22, bounce: 0.15)` | -| `--dur-slow` 360ms | system sheet/navigation transitions — do not override | -| `--press-scale` 0.97 | `.scaleEffect(isPressed ? 0.97 : 1)` inside a custom `ButtonStyle` | - -Gate the press scale and any looping animation on `@Environment(\.accessibilityReduceMotion)`. +| `--green-*`, `--sys-*`, `--label-*`, `--fill-*`, `--bg-*`, `--separator*` | base palette: views take aliases, which route to system semantics or color sets | +| `--glass-*`, `--blur-*`, `--color-surface-glass*`, `--color-glass-border`, `--shadow-glass` | `.glassEffect()` owns them | +| `--shadow-sm`, `--shadow-lg`, `--shadow-focus-ring` | system controls carry their own depth and focus | +| `--type-*` sizes, line-heights and weights, `--weight-*`, `--font-*` | `Font.TextStyle` is the scale | +| `--tracking-*` except code and eyebrow | text styles carry Apple's optical tracking; `.tracking()` on a system style fights it | +| `--icon-stroke-*` | SF Symbols have weights, not strokes | +| `--color-text-quaternary`, `--color-fill*` | system semantics, used through the system controls | +| `--color-text-primary/secondary/tertiary`, `--color-text-link`, `--color-surface-*`, `--color-separator`, `--color-status-*` (not the tints), `--color-on-status` | mirrored by name as `Color` aliases above, but their values come from the OS (or `.white`), so there is no number to compare | ## Constraints carried from the app -Work driven by this directory is cosmetic. It must not touch: the accept-gate flow (never auto-accept), the direction-unambiguous transfer screen, the prompt-pipe event handling, or the phase state machine. Read `docs/knowledge/app-ui-architecture.md` before editing any view that observes `TransferController`. +Design work here is cosmetic. It must not change what the transfer does: the accept gate never auto-accepts unless the user turned it on, direction stays unambiguous, and the prompt-pipe handling and phase machine stay as they are. Read `docs/knowledge/app-ui-architecture.md` before editing any view that observes `TransferController`. diff --git a/design/tokens.css b/design/tokens.css index d351e31c..99d30556 100644 --- a/design/tokens.css +++ b/design/tokens.css @@ -1,15 +1,15 @@ -/* CrocApp design tokens — hand-written web mirror. Canonical values and the +/* CrocApp design tokens: hand-written web mirror. Canonical values and the rules behind them live in design/*.md; start at design/AGENTS.md. Themed values are light-dark() pairs, so dark is not a second block: :root declares `light dark` and follows the OS, [data-theme] pins it. - No @font-face — Apple's system fonts are not redistributable. */ + No @font-face: Apple's system fonts are not redistributable. */ :root { color-scheme: light dark; - /* Brand: croc green. Theme-independent literals — the aliases pick a step. */ + /* Brand: croc green. Theme-independent literals; the aliases pick a step. */ --green-200: #97DBBD; --green-300: #57C598; --green-400: #2DC585; @@ -26,7 +26,6 @@ --sys-blue: light-dark(#007AFF, #0A84FF); /* Labels, fills, separators */ - --ink: light-dark(#000000, #FFFFFF); --paper: light-dark(#FFFFFF, #000000); --label-1: light-dark(rgba(0,0,0,1), rgba(255,255,255,1)); --label-2: light-dark(rgba(60,60,67,0.60), rgba(235,235,245,0.60)); @@ -56,7 +55,7 @@ --glass-border: light-dark(rgba(255,255,255,0.60), rgba(255,255,255,0.14)); --glass-highlight: light-dark(rgba(255,255,255,0.75), rgba(255,255,255,0.10)); - /* Semantic aliases — consume these */ + /* Semantic aliases: consume these */ --color-accent: light-dark(var(--green-600), var(--green-400)); --color-accent-pressed: light-dark(var(--green-700), var(--green-500)); --color-accent-tint: var(--glass-tint-accent); @@ -71,7 +70,7 @@ --color-text-secondary: var(--label-2-web); --color-text-tertiary: var(--label-3); --color-text-quaternary: var(--label-4); - --color-text-link: light-dark(var(--green-700), var(--green-400)); + --color-text-link: var(--color-accent-text); --color-status-success: var(--sys-green); --color-status-warning: var(--sys-orange); @@ -122,7 +121,7 @@ --type-code-hero-size: 30px; --type-code-hero-lh: 38px; --type-code-body-size: 15px; --type-code-body-lh: 22px; - /* Web display scale (landing + docs) — never used by the app. Unitless + /* Web display scale (landing + docs), never used by the app. Unitless line-heights. typography.md → Web display scale. */ --type-web-hero-size: clamp(40px, 6.2vw, 68px); --type-web-hero-lh: 1.06; @@ -169,14 +168,13 @@ --radius-2xl: 36px; --radius-capsule: 999px; - /* Layout — app */ + /* Layout: app */ --content-max-width: 480px; - --sheet-max-width: 540px; - /* Layout — web only (landing + docs). Both clamp() ends sit on the 4pt grid. */ - --content-max-width-wide: 1280px; + /* Layout: web only (landing + docs). Both clamp() ends sit on the 4pt grid. */ + --content-max-width-wide-web: 1280px; --content-max-width-web: 1080px; - --content-max-width-prose: 680px; + --content-max-width-prose-web: 680px; --gutter-web: clamp(20px, 5vw, 48px); --section-pad-web: clamp(64px, 9vw, 112px); --stack-web: clamp(32px, 4vw, 48px); @@ -190,7 +188,7 @@ --icon-size-md: 22px; --icon-size-lg: 28px; - /* Lucide stroke weights. Unitless on purpose — a unit here breaks every + /* Lucide stroke weights. Unitless on purpose: a unit here breaks every glyph, since stroke-width scales with the viewBox. iconography.md. */ --icon-stroke-light: 1.5; --icon-stroke: 1.8; @@ -199,46 +197,42 @@ --icon-stroke-heaviest: 2.4; /* Stacking order. Two layers is the whole scale. spacing-layout.md. */ - --z-nav: 2; - --z-skip-link: 3; + --z-nav-web: 2; + --z-skip-link-web: 3; --border-hairline: 1px; --border-hairline-half: 0.5px; - /* Two disabled opacities: a control is one object, a row is a composite whose - subtitle would vanish before its title. components.md -> States. */ - --opacity-disabled: 0.4; + /* A row or field dims as one composite; controls use the platform's own + disabled look. components.md -> States. */ --opacity-disabled-container: 0.5; - /* Component metrics, tokenized for web only — styles.css cannot hold a raw - px the way a SwiftUI component can. Do not use these in the app. */ - --btn-pad-x-lg: 22px; - --btn-pad-x-md: var(--space-5); - --btn-pad-x-sm: var(--space-4); - --btn-gap-lg: var(--space-3); - --btn-gap-md: 6px; - --btn-gap-sm: 5px; - --btn-icon-lg: 20px; - --btn-icon-md: 18px; - --btn-icon-sm: 15px; - - --row-min-file: 48px; - --progress-height: 6px; - - --pill-pad-block: 5px; - --pill-pad-inline-start: 9px; - --pill-pad-inline-end: 11px; - --pill-icon-size: 14px; - - /* Apple's stated minimum for the supplied App Store badge, not a control height. */ - --store-badge-height: 40px; - - --step-marker-size: 32px; + /* Component metrics, web only: styles.css cannot hold a raw px the way a + SwiftUI component can. components.md is the source of each number. */ + --btn-pad-x-lg-web: 22px; + --btn-pad-x-md-web: var(--space-5); + --btn-pad-x-sm-web: var(--space-4); + --btn-gap-lg-web: var(--space-3); + --btn-gap-md-web: 6px; + --btn-gap-sm-web: 5px; + --btn-icon-lg-web: 20px; + --btn-icon-md-web: 18px; + --btn-icon-sm-web: 15px; + + --pill-pad-block-web: 5px; + --pill-pad-inline-start-web: 9px; + --pill-pad-inline-end-web: 11px; + --pill-icon-size-web: 14px; + + /* Apple's minimum onscreen App Store badge height. brand.md. */ + --store-badge-height-web: 40px; + + --step-marker-size-web: 32px; --mascot-size-web: 64px; --phone-shot-max-width-web: 260px; --nav-height-web: 56px; /* auto-fit column floor: 4-up at 1080, 2-up at 768. spacing-layout.md. */ - --grid-min-card: 232px; + --grid-min-card-web: 232px; /* Glass blur */ --blur-thin: 12px; @@ -246,7 +240,7 @@ --blur-thick: 40px; --glass-saturate: 180%; - /* Elevation. Geometry is theme-independent so only the colours flip. */ + /* Elevation. Geometry is theme-independent so only the colors flip. */ --shadow-sm: 0 1px 2px light-dark(rgba(0,0,0,0.06), rgba(0,0,0,0.40)), 0 1px 1px light-dark(rgba(0,0,0,0.04), rgba(0,0,0,0.30)); --shadow-md: 0 4px 16px light-dark(rgba(0,0,0,0.08), rgba(0,0,0,0.50)), @@ -256,8 +250,6 @@ /* The inset highlight is the top edge-light that sells the material. Keep it. */ --shadow-glass: 0 8px 32px light-dark(rgba(0,0,0,0.12), rgba(0,0,0,0.55)), inset 0 1px 0 var(--glass-highlight); - --shadow-knob: 0 3px 8px light-dark(rgba(0,0,0,0.15), rgba(0,0,0,0.50)), - 0 1px 1px light-dark(rgba(0,0,0,0.16), rgba(0,0,0,0.40)); /* Two solid bands, never a tint: whatever the ring lands on, one band has a 3.41:1 edge against it. Do not collapse it. colors.md rule 7. */ --shadow-focus-ring: 0 0 0 2px var(--color-surface-base), @@ -269,7 +261,7 @@ --dur-fast: 120ms; --dur-base: 220ms; --dur-slow: 360ms; - --press-scale: 0.97; + --press-scale-web: 0.97; } [data-theme="light"] { color-scheme: light; } @@ -288,7 +280,7 @@ body { line-height: var(--type-body-lh); letter-spacing: var(--tracking-body); color: var(--color-text-primary); - background: var(--color-surface-grouped); + background: var(--color-surface-base); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } @@ -296,30 +288,28 @@ body { code, kbd, pre, samp { font-family: var(--font-mono); } a { color: var(--color-text-link); text-decoration: none; } -a:hover { text-decoration: underline; text-underline-offset: var(--space-1); } -a:active { opacity: 0.7; } +@media (hover: hover) { + a:hover { text-decoration: underline; text-underline-offset: var(--space-1); } +} -:focus-visible { outline: none; box-shadow: var(--shadow-focus-ring); } +/* The transparent outline is what forced-colors mode draws, since it drops box-shadow. */ +:focus-visible { outline: 2px solid transparent; box-shadow: var(--shadow-focus-ring); } ::selection { background: var(--color-accent-tint); } -::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar { width: var(--space-3); height: var(--space-3); } ::-webkit-scrollbar-thumb { background: var(--color-fill); border-radius: var(--radius-capsule); } ::-webkit-scrollbar-track { background: transparent; } -/* Type helpers — the tokens above stay the source of truth */ +/* Type helpers: the tokens above stay the source of truth */ -.t-large-title { font: var(--type-large-title-weight) var(--type-large-title-size)/var(--type-large-title-lh) var(--font-sans); letter-spacing: var(--tracking-large-title); } -.t-title2 { font: var(--type-title2-weight) var(--type-title2-size)/var(--type-title2-lh) var(--font-sans); letter-spacing: var(--tracking-title2); } -.t-title3 { font: var(--type-title3-weight) var(--type-title3-size)/var(--type-title3-lh) var(--font-sans); } .t-headline { font: var(--type-headline-weight) var(--type-headline-size)/var(--type-headline-lh) var(--font-sans); letter-spacing: var(--tracking-headline); } .t-body { font: var(--type-body-weight) var(--type-body-size)/var(--type-body-lh) var(--font-sans); letter-spacing: var(--tracking-body); } .t-callout { font: var(--type-callout-weight) var(--type-callout-size)/var(--type-callout-lh) var(--font-sans); } .t-footnote { font: var(--type-footnote-weight) var(--type-footnote-size)/var(--type-footnote-lh) var(--font-sans); } -.t-caption { font: var(--type-caption1-weight) var(--type-caption1-size)/var(--type-caption1-lh) var(--font-sans); } .t-code { font: var(--weight-medium) var(--type-code-body-size)/var(--type-code-body-lh) var(--font-mono); letter-spacing: var(--tracking-code); } -/* Longhand, not `font:` — its size/line-height slot does not reliably survive +/* Longhand, not `font:`: its size/line-height slot does not reliably survive a clamp() substituted through var(). */ .t-web-hero { font-family: var(--font-sans); font-size: var(--type-web-hero-size); line-height: var(--type-web-hero-lh); font-weight: var(--type-web-hero-weight); letter-spacing: var(--tracking-web-hero); } .t-web-section { font-family: var(--font-sans); font-size: var(--type-web-section-size); line-height: var(--type-web-section-lh); font-weight: var(--type-web-section-weight); letter-spacing: var(--tracking-web-section); } @@ -327,7 +317,7 @@ a:active { opacity: 0.7; } .t-web-lead { font-family: var(--font-sans); font-size: var(--type-web-lead-size); line-height: var(--type-web-lead-lh); font-weight: var(--type-web-lead-weight); letter-spacing: var(--tracking-web-lead); } @media (prefers-reduced-motion: reduce) { - :root { --press-scale: 1; --dur-fast: 0ms; --dur-base: 0ms; --dur-slow: 0ms; } + :root { --press-scale-web: 1; --dur-fast: 0ms; --dur-base: 0ms; --dur-slow: 0ms; } } /* Glass is decoration; legibility never depends on it. materials.md. */ diff --git a/design/typography.md b/design/typography.md index 6e2a8140..21a4fbc1 100644 --- a/design/typography.md +++ b/design/typography.md @@ -1,6 +1,6 @@ # Typography -SF Pro for everything; SF Mono for code phrases and text payloads. Names mirror `UIFont.TextStyle` so the app can use `Font.system(.headline)` etc. directly and inherit Dynamic Type. +SF Pro for everything; SF Mono for code phrases and text payloads. The app scale is `Font.TextStyle`, so the app uses the styles directly and inherits Dynamic Type (`swiftui.md` → Typography). ## Families @@ -49,7 +49,7 @@ Each has three custom properties: `--type--size`, `-lh`, `-weight`. | Token | Size | Line-height | Used for | |---|---|---|---| | `--type-code-hero` | 30 | 38 | the sender's code phrase (bold, `user-select: all`) | -| `--type-code-body` | 15 | 22 | code input field, text payloads | +| `--type-code-body` | 15 | 22 | code input field, text payloads (app: `.subheadline.monospaced()`, which is 15pt and scales) | ## Web display scale (landing + docs only) @@ -62,7 +62,7 @@ Never used by the app. The app scale stops at 34px because that is a phone scree | `--type-web-sub` | `clamp(20px, 2.4vw, 26px)` | 1.3 | 600 | `--tracking-web-section` | H3 / card title | | `--type-web-lead` | `clamp(17px, 1.6vw, 21px)` | 1.5 | 400 | `--tracking-web-lead` `-0.2px` | hero deck, section intro | -Body copy below lead size stays on the app scale (`--type-body` and down) — the two scales meet there deliberately, so a web page and an app screen read as the same voice. +Body copy below lead size stays on the app scale (`--type-body` and down). The two scales meet there deliberately, so a web page and an app screen read as the same voice. Helper classes: `.t-web-hero` `.t-web-section` `.t-web-sub` `.t-web-lead`. These are written longhand in `tokens.css` rather than with the `font:` shorthand, because the shorthand's `size/line-height` slot does not reliably survive a `clamp()` substituted through `var()`. @@ -83,7 +83,7 @@ SF tightens optically at large sizes; these mirror Apple's metrics. Caption sizes and below take no tracking at all, which is why there is no token for it. -`--tracking-eyebrow` is the uppercase footnote label above a code phrase ("READY TO SEND", `components.md` → CodePhraseDisplay). Uppercase needs the extra letter-spacing that lowercase does not. +`--tracking-eyebrow` is for the uppercase footnote label above a code phrase ("READY TO SEND", `components.md` → CodePhraseDisplay). Uppercase needs the extra letter-spacing that lowercase does not. Positive tracking on the code phrase is deliberate: it separates the `word-word-word` groups so a phrase can be read aloud or transcribed without ambiguity. @@ -91,13 +91,13 @@ Positive tracking on the code phrase is deliberate: it separates the `word-word- Tokens are the source of truth; these are shorthands defined in `tokens.css`: -`.t-large-title` `.t-title2` `.t-title3` `.t-headline` `.t-body` `.t-callout` `.t-footnote` `.t-caption` `.t-code` +`.t-headline` `.t-body` `.t-callout` `.t-footnote` `.t-code` -`--type-title1` and `--type-caption2` have no helper: neither has a web call site. The tokens exist because the app scale is `Font.TextStyle` and must stay complete. +`--type-large-title`, `--type-title1`, `--type-title2`, `--type-title3`, `--type-caption1` and `--type-caption2` have no helper: none has a web call site. The tokens exist because the app scale is `Font.TextStyle` and must stay complete. ## Rules -- Uppercase only for the small eyebrow label above the code phrase (`--type-footnote`, weight 600, `letter-spacing: 0.6px`). Nowhere else. +- Uppercase only for the eyebrow label above the code phrase (`--type-footnote`, `--weight-semibold`, `--tracking-eyebrow`). Nowhere else. - No serif, anywhere. A serif headline is the usual first sign that a generated mockup has left this system. -- Line length capped by `--content-max-width` (480px) in the app, `--content-max-width-prose` (680px) on web — not by a character count. +- Line length is capped by `--content-max-width` (480px) in the app and `--content-max-width-prose-web` (680px) on web, not by a character count. - In the app, respect Dynamic Type. See `accessibility.md` → Dynamic Type. diff --git a/design/web.md b/design/web.md index 13d6032d..96f0bc3e 100644 --- a/design/web.md +++ b/design/web.md @@ -1,65 +1,64 @@ -# Web +# Token to web -How the tokens apply to the landing page and the docs site. `swiftui.md` is the app counterpart. No values are defined here — this file says which token a web surface takes, and what web has to do that the app does not. +How the tokens apply to the landing page (`web/landing`) and the docs site (`web/docs`). `swiftui.md` is the app counterpart. No values are defined here: this file says which token a web surface takes and what web does that the app does not. ## What these surfaces are -Web pages, not an app shell. Keep the Apple type scale, the colors and the 4pt grid; drop the device chrome. Both sites ship both themes and follow the OS preference by default. - -They consume `tokens.css`, which is why the marketing surface and the product look like one thing. Never edit a copy — `design/tokens.css` is the only committed one (`AGENTS.md` → Three mirrors). +Web pages, not an app shell. Keep the Apple type scale, the colors and the 4pt grid; drop the device chrome. Both sites ship both themes and follow the OS by default. They consume `tokens.css` (never a copy, `AGENTS.md` → Mirrors and checks). ## Page fill -Page is `--color-surface-base`, **not** the app's `--color-surface-grouped`. Link text is 4.94:1 on base and 4.42:1 on grouped, and a web page runs links through its prose. Cards carry the tonal step instead. +`--color-surface-base`, which `tokens.css` sets on `body`. Not the app's grouped fill: links are 4.94:1 on base and 4.42:1 on grouped, and a web page runs links through its prose. Cards are `--color-surface-card` with a hairline border (`components.md` → Card), because in light the two fills are the same white. ## Width -`--content-max-width` (480px) is the app's phone column and is not a page container. Use the web caps in `spacing-layout.md` → Web layout: - | Content | Cap | |---|---| -| hero, full-bleed rows, screenshot galleries | `--content-max-width-wide` | +| hero, full-bleed rows, screenshot galleries | `--content-max-width-wide-web` | | default section | `--content-max-width-web` | -| paragraphs | `--content-max-width-prose` | +| paragraphs | `--content-max-width-prose-web` | +| a phone screenshot inline in docs prose | `--phone-shot-max-width-web` | -**One exception, and it is narrow:** an element that *depicts* the app — the landing page's CSS-built Send screen, a docs screenshot frame — takes `--content-max-width`, because it is drawing the phone column rather than laying out a page. The row it sits in still takes a web cap. If the element is not a picture of the app, this does not apply. +One narrow exception: an element that depicts the app (a hero screenshot) takes `--content-max-width`, because it draws the phone column. The row it sits in still takes a web cap. ## Theming -Every token is a `light-dark()` pair resolved by `color-scheme`. `:root` declares `light dark`, so an untouched page follows the OS; `[data-theme="dark"]` / `[data-theme="light"]` pin `color-scheme` and win in both directions. +Every themed token is a `light-dark()` pair resolved by `color-scheme`. `:root` declares `light dark`, so an untouched page follows the OS; `[data-theme="light"]` and `[data-theme="dark"]` pin it. -A consuming page sets `data-theme` from a small inline script **before first paint** — that is the whole integration, and it is what avoids a flash of the wrong theme. Nothing else is required, and no second `prefers-color-scheme` block of token values is needed. +The landing page sets `data-theme` from `localStorage` in an inline script before first paint, accepting only `light` or `dark`; the docs site gets the same from Docusaurus. The theme toggle also rewrites ``, whose two literals are `--bg-base` light and dark (`#FFFFFF`, `#000000`). ## Type -Hero, section headings, card titles and leads take the web display scale (`typography.md` → Web display scale), via `.t-web-hero` `.t-web-section` `.t-web-sub` `.t-web-lead`. Everything at body size and below stays on the app scale — the two scales meet at lead size deliberately, so a web page and an app screen read as the same voice. +Hero, section headings, card titles and leads take the web display scale through `.t-web-hero`, `.t-web-section`, `.t-web-sub`, `.t-web-lead` (on docs, Infima's heading variables point at the same tokens). Everything at body size and below stays on the app scale. ## Glass -Glass needs something to blur. On a flat single-color background it reads as a slightly tinted rectangle, so prefer the `solid` material on these pages unless there is real imagery behind. The CSS `backdrop-filter` recipe in `materials.md` is an approximation of `.glassEffect()`, never a target of its own. +The sticky nav is the only glass, because content scrolls under it. Everything else is `solid` (`materials.md`). -## Hover +## Hover and focus -Hover exists here, because a web page has a pointer. It applies the element's pressed background and nothing else: no scale, no lift, no shadow change. Nothing may be discoverable only on hover. Full rule in `platforms.md` → Hover, states table in `components.md` → States. +Hover is the pressed background only, inside `@media (hover: hover)` (`platforms.md` → Hover). Focus is `--shadow-focus-ring` from `tokens.css`, never restyled per component. ## Icons -Lucide, because SF Symbols cannot ship to the web. The page inlines one `` sprite and references it with `` rather than repeating path data at every call site. Always `aria-hidden="true"`. Names, sizes and stroke tokens are `iconography.md`'s. +Lucide, inlined as one `` sprite referenced with ``, `aria-hidden="true"`. Names, sizes and strokes are `iconography.md`'s. The docs theme draws its own few chrome glyphs (theme toggle, external-link arrow); those are the theme's. -## Breakpoints and layers +## Docs site (Docusaurus) -Three breakpoints, two stacking layers, both tabled in `spacing-layout.md`. Breakpoint values are literals in the stylesheet because a custom property does not resolve inside a media condition — that table is the only place they are defined. +`web/docs/src/css/custom.css` maps Infima's variables onto tokens, so the theme renders in our colors: the primary ladder onto the accent aliases, grays onto fills and separators, admonitions onto the status tints (text stays primary), headings onto the web scale, and the code theme onto tokens. Nothing in it is a literal. The mascot is the navbar mark (`brand.md` → Mascot). ## Writing direction -Logical properties everywhere: `padding-inline`, `margin-block`, `inset-inline-start`, `border-start-start-radius`. Nothing here is laid out in physical directions, so an RTL locale is a `dir="rtl"` away. +Logical properties everywhere: `padding-inline`, `margin-block`, `inset-inline-start`, `border-block-end`, `max-inline-size`. An RTL locale is a `dir="rtl"` away. ## The only literals web may write -Rule 1 is otherwise absolute — no raw hex, px or duration. - | Literal | Why | |---|---| -| breakpoint widths in a media condition | `var()` does not resolve there | -| `` | browser chrome, cannot take a custom property | -| component metrics | already tokenized as `--btn-*`, `--pill-*`, `--row-min-file`, `--nav-height-web`, `--grid-min-card`; use those, not the numbers behind them | +| breakpoint widths in a media condition | `var()` does not resolve there (`spacing-layout.md` → Breakpoints) | +| the two `theme-color` meta values | browser chrome cannot take a custom property | +| `0` (any unit), `100%`, `100svh`, `1px` inside a visually-hidden utility, `90deg` rotation of a chevron | geometry, not design values | +| `50%` inside a visually-hidden utility (`clip-path: inset(50%)`) | geometry of the clip; any other `50%` (centering, `translate`) is a token or a logical-property layout | +| `12%`, `22%`, `82%` and `black` inside `color-mix()` | the Button state specs in `components.md` → Button (and the 22% status border); outside `color-mix()` every other percentage and every named color (`white`, `red`, …) is a finding | + +Everything else, including component metrics, is a token (`--btn-*-web`, `--pill-*-web`, …). `scripts/verify-design.sh` enforces this. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index ae04ea64..862e0a5d 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -8,7 +8,7 @@ Terms a new contributor or a fresh AI session hits in this repo and cannot infer |---|---| | `--ask` | croc flag requiring confirmation on both sides before a transfer starts, not just the receiver's normal accept prompt; bridged into the engine via the same fd0 pipe trick as receive (`docs/knowledge/crocmobile-bridge.md`). | | classic mode | croc's `--classic` flag, the only mode where `--code` accepts a custom secret directly; insecure by design and deliberately unsupported here (F39, `docs/knowledge/features.md`). | -| code phrase / secret | the `word-word-word` string (three random words from the EFF short wordlist) both sides use to find each other and derive the session key; custom via `CROC_SECRET`, min 6 chars (`docs/knowledge/what-is-croc.md`). | +| code phrase / secret | the `word-word-word` string (three random words from the EFF short wordlist) both devices use to find each other and derive the session key; custom via `CROC_SECRET`, min 6 chars (`docs/knowledge/what-is-croc.md`). "Secret" is croc's internal term for it: the UI never says it, only "code phrase" (`design/content.md` → Terminology). | | compression | croc DEFLATE-compresses (HuffmanOnly) file data by default; `--no-compress` disables it (F15). | | `CROC_SECRET` | env var for a custom code phrase; the only way non-classic croc accepts one, on both send and receive (`docs/knowledge/what-is-croc.md`). | | curve | the elliptic curve PAKE runs over, default P-256 (`--curve`, `crocmobile.Options.Curve`). | diff --git a/docs/knowledge/app-store-listing.md b/docs/knowledge/app-store-listing.md index b3129145..59acfd2d 100644 --- a/docs/knowledge/app-store-listing.md +++ b/docs/knowledge/app-store-listing.md @@ -15,10 +15,10 @@ Limits come from Apple's App Store Connect Help (URLs in "Sources"). Two are byt | Name | 30 characters (min 2) | 7 | App information page | | Subtitle | 30 characters | 29 | App information page | | Promotional text | 170 characters | 152 | Platform version information page | -| Description | 4000 characters | 2930 | Platform version information page | +| Description | 4000 characters | 2932 | Platform version information page | | Keywords | 100 **bytes** (each keyword more than two characters) | 98 | Platform version information page | -| What's New | 4000 characters | 1495 | Platform version information page | -| App Review notes | 4000 **bytes** | 3289 | Platform version information page | +| What's New | 4000 characters | 1436 | Platform version information page | +| App Review notes | 4000 **bytes** | 3318 | Platform version information page | | Support URL, Marketing URL, Privacy Policy URL | no limit stated; full URL with protocol | n/a | Platform version information page, App information page | | Copyright | no limit stated; year first, then the holder; Apple adds the copyright symbol | n/a | Platform version information page | @@ -43,7 +43,7 @@ Send files with a code phrase Editable without a new build or review. ```text -Free, open source, no account. Send files, folders or text to another device with a short code phrase, end to end encrypted. An unofficial app for croc. +Free, open source, no account. Send files, folders or text to another device with a short code phrase, end-to-end encrypted. An unofficial app for croc. ``` ## Description @@ -54,10 +54,10 @@ CrocApp sends files, folders and text from one device to another with a short co HOW IT WORKS 1. Choose what to send: files, a folder, or some text. 2. CrocApp shows a code phrase and a QR code. Read it out, message it, or let the other person scan it. -3. The other side enters the phrase, sees the list of files, and accepts. The transfer starts. +3. The other device enters the phrase, sees the list of files, and accepts. The transfer starts. PRIVATE BY DESIGN -Transfers are end to end encrypted. The code phrase authenticates both sides, and a relay that passes the data along only ever sees ciphertext. CrocApp has no accounts, no analytics, no ads and no tracking, and your transfer history stays on your device. CrocApp has not had a formal third-party security audit. +Transfers are end-to-end encrypted. The code phrase authenticates both devices, and a relay that passes the data along only ever sees ciphertext. CrocApp has no accounts, no analytics, no ads and no tracking, and your transfer history stays on your device. CrocApp has not had a formal third-party security audit. FREE AND OPEN SOURCE CrocApp is free: no purchases, no subscription, no tiers. The source code is public under the MIT license at github.com/bakirgdev/CrocApp. @@ -70,14 +70,14 @@ WHAT YOU CAN DO - Receive with a code phrase, a scanned QR code, or a link. Opening a link only fills in the code; nothing connects until you tap Receive. - See the file list before you accept, and choose Keep Both or Replace when a name is already taken. - Watch progress and speed, cancel at any time, and resume an interrupted transfer. -- Send as a link: upload encrypted files for the receiver to download later, with a lifetime of 1 hour, 1 day or 7 days and a limit on downloads (up to 100 files and 1 GiB). You can revoke the link from History. +- Send as Link: share encrypted files for someone who is not online, with a lifetime of 1 hour, 1 day or 7 days and a limit on downloads (up to 100 files and 1 GiB). You can revoke the link from History. - Use your own relay, force local-network-only transfers, or route through a proxy, from Settings. - iPhone and iPad: send from any app with the share sheet, follow a transfer in a Live Activity, find received files in the Files app, and use Shortcuts and Siri actions. - Mac: drag and drop, a menu bar window for quick sends, and Shortcuts actions. - Light and dark appearance, Dynamic Type and VoiceOver. WHAT TO KNOW -By default CrocApp uses the public relays run by the croc project to connect two devices, and the croc project's server at getcroc.com for links. Your files are encrypted before they leave your device. You can point CrocApp at relays and a link server you run yourself. Nearby transfers over the local network depend on what your network and device allow. +By default CrocApp uses the public relays run by the croc project to connect two devices, and the croc project's link server at getcroc.com for links. Your files are encrypted before they leave your device. You can point CrocApp at relays and a link server you run yourself. Nearby transfers over the local network depend on what your network and device allow. CrocApp is an independent project. It is unofficial and not affiliated with or endorsed by croc or its author. croc is MIT licensed. Apple, iPhone, iPad, Mac and Siri are trademarks of Apple Inc. @@ -86,10 +86,10 @@ Free to use, with no account. Questions and bug reports: https://crocapp.dev/sup ## Keywords -Comma separated, no spaces. Words already in the name or subtitle (`crocapp`, `send`, `files`, `code`, `phrase`) and the category (`utilities`) are left out because Apple already indexes them. `croc` is a deliberate keep: the name is one token, and the protocol name is what people search. `airdrop` is left out on purpose (another company's trademark). +Comma separated, no spaces. Words already in the name or subtitle (`crocapp`, `send`, `files`, `code`, `phrase`) and the category (`utilities`) are left out because Apple already indexes them. `croc` is a deliberate keep: the name is one token, and the protocol name is what people search. `airdrop` is left out on purpose (another company's trademark). `secure`, `p2p` and `peer` are search terms people type; keywords are never shown, so `design/content.md` → Terminology, which binds displayed copy, does not apply to them. ```text -croc,transfer,share,encrypted,private,qr,p2p,secure,wormhole,folder,lan,nearby,e2ee,drop,peer,text +croc,transfer,share,encrypted,secure,private,qr,p2p,wormhole,folder,lan,nearby,e2ee,drop,peer,text ``` ## What's New in 1.0.0 @@ -101,16 +101,16 @@ CrocApp 1.0 is the first App Store release. New - iPhone, iPad and Mac from one App Store listing. -- Send as link: upload encrypted files to a server and share a link or QR code, so the receiver can download later. You choose the lifetime and the number of downloads, and can revoke the link from History. +- Send as Link: share encrypted files by link or QR code with someone who is not online. You choose the lifetime and the number of downloads, and can revoke the link from History. - Links: opening a CrocApp link, a crocapp.dev/r/ link or a getcroc.com link fills in the code on the Receive screen. Nothing connects until you tap Receive. - A Keep Both or Replace choice when a received file has the same name as one you already have. -- Advanced settings: proxies, encryption curve, file hash, upload speed limit, ports and more. The defaults suit most people. +- Advanced settings: proxies, encryption curve, file hash, send speed limit, ports and more. The defaults suit most people. - Mac menu bar quick send, and Shortcuts and Siri actions for sending and receiving. - iPad File menu: Show Receive Folder in Files (Shift Command R). Changed - The transfer engine is now croc 11.5.4. A device running croc 10 can no longer transfer with CrocApp; it needs croc 11 or newer. -- Errors say what went wrong: a wrong code, a refused transfer, a lost connection, a peer on an older croc, or no public relay available. +- Errors say what went wrong: a wrong code, a refused transfer, a lost connection, a device on an older croc, or no public relay available. - History keeps less of a code than before, and never the whole code. - Every screen works at the largest text sizes and with VoiceOver. - The QR code on the sender's screen now opens CrocApp, or a page that explains what to do when the app is missing. @@ -127,7 +127,7 @@ CrocApp is free and open source. It is unofficial and not affiliated with croc o | Privacy Policy URL | `https://crocapp.dev/privacy/` | | Primary category | Utilities (matches `LSApplicationCategoryType` `public.app-category.utilities` in the build settings, verified locally) | | Secondary category | Productivity (optional; a judgement call, drop it if unsure). Unknown whether the iOS and macOS platforms of one record share categories; check the App Information page shows both. | -| Copyright | `2026 Bakir Gračić` (the holder in `LICENSE` is "Bakir Gračić", with the accent; Apple adds the symbol) | +| Copyright | `2026 Bakir Gracić` (the holder in `LICENSE` is "Bakir Gracić", with the accent; Apple adds the symbol) | | Price | Free (USD 0), no In-App Purchases | | Version | 1.0.0 (`app/Config/Version.xcconfig`, `MARKETING_VERSION`); attach the iOS and macOS builds that processed in TestFlight | | Privacy choices URL | leave empty (optional per Apple) | @@ -195,33 +195,33 @@ Sending arbitrary files to another person is not content the app hosts or shows. ## Review notes -Paste into App Review Information, Notes (4000 bytes; this block is 3289). Contact name, phone (international format with a plus sign) and email are yours to fill in; they are not in this repo. +Paste into App Review Information, Notes (4000 bytes; this block is 3306). Contact name, phone (international format with a plus sign) and email are yours to fill in; they are not in this repo. ```text -CrocApp is a free, open-source file-transfer app: it sends files, folders and text to another device with a short code phrase, end to end encrypted. It is a native front end for the open-source croc tool (https://github.com/schollz/croc, MIT, by Zack Scholl). CrocApp is unofficial and unaffiliated with croc or its author; the croc maintainer was informed by email on 2026-10-14. Source: https://github.com/bakirgdev/CrocApp +CrocApp is a free, open-source file-transfer app: it sends files, folders and text to another device with a short code phrase, end-to-end encrypted. It is a native front end for the open-source croc tool (https://github.com/schollz/croc, MIT, by Zack Scholl). CrocApp is unofficial and unaffiliated with croc or its author; the croc maintainer was informed by email on 2026-10-14. Source: https://github.com/bakirgdev/CrocApp ACCOUNT: none. There is no login, sign-up, purchase or subscription, so there is no demo account. Sign-in required is off. -NETWORK: transfers connect two devices through croc's public relay pool (1.getcroc.com to 4.getcroc.com, TCP port 9009), which only forwards ciphertext. Links ("Send as link", optional) use https://getcroc.com. The croc maintainer runs both, not CrocApp. Settings lets a user pick their own relay and link server. No analytics, ads or developer-side servers. If your network blocks outbound port 9009 the app reports "No public relay answered"; please test on an open network. +NETWORK: transfers connect two devices through croc's public relay pool (1.getcroc.com to 4.getcroc.com, TCP port 9009), which only forwards ciphertext. Links ("Send as Link", optional) use https://getcroc.com. The croc maintainer runs both, not CrocApp. Settings lets a user pick their own relay and link server. No analytics, ads or developer-side servers. If your network blocks outbound port 9009 the app reports "No public relay answered"; please test on an open network. PERMISSIONS (first use, both optional): -- Local network: "CrocApp uses the local network to discover nearby devices for direct, faster transfers." Used only to find a peer on the same network. Denying it leaves relay transfers working. -- Camera (iPhone and iPad only): "CrocApp uses the camera to scan QR transfer codes." Used only on Receive, Scan QR Code. Typing the code works without it. +- Local network: "CrocApp uses the local network to discover nearby devices for direct, faster transfers." Used only to find another device on the same network. Denying it leaves relay transfers working. +- Camera (iPhone and iPad only): "CrocApp uses the camera to scan the QR code for a code phrase." Used only on Receive, Scan QR Code. Typing the code works without it. -TEST WITHOUT A SECOND DEVICE: use croc's own browser client at https://getcroc.com as the peer. Use a browser on your computer. For the macOS build, use a browser on the same Mac. Use the default settings. +TEST WITHOUT A SECOND DEVICE: use croc's own browser client at https://getcroc.com as the other device. Use a browser on your computer. For the macOS build, use a browser on the same Mac. Use the default settings. 1. App sends, browser receives. a. In CrocApp tap Send, Add Files (or type text), then tap Send. A code phrase like cape-junky-widow and a QR code appear. - b. In the browser open https://getcroc.com, choose Receive, enter the code, and accept. The file downloads in the browser. + b. In the browser open https://getcroc.com, choose Receive, enter the code, and accept. The browser saves the file. c. Or tap Share Link in CrocApp, open that https://crocapp.dev/r/ link in a browser, and tap "Receive in your browser". 2. Browser sends, app receives. a. At https://getcroc.com choose Send, choose a file, and note the code the page shows. b. In CrocApp tap Receive, type the code (or paste the getcroc.com link; the app keeps only the code), and tap Receive. - c. The incoming file list appears. Tap Accept. The file is saved to the receive folder (on iPhone and iPad, On My iPhone, CrocApp, in the Files app). + c. The file list appears. Tap Accept. The file is saved to the receive folder (on iPhone and iPad, On My iPhone, CrocApp, in the Files app). 3. Link flow: tap the link https://crocapp.dev/r/#cape-junky-widow in Notes or Messages with CrocApp installed (iOS does not open the app for a URL typed into Safari), or open that page in a browser and press Open in CrocApp. CrocApp opens on Receive with the code filled in and does not connect until you tap Receive. -4. Optional, Send as link: in Send choose files, turn on "Send as link", tap Upload. A link and QR code appear. Open the link in a browser to download. In History, Revoke Link makes it stop working. +4. Optional, Send as link: in Send choose files, turn on "Send as Link", then tap Send as Link. A link and QR code appear. Open the link in a browser to get the file. In History, Revoke Link makes it stop working. -The browser UI at getcroc.com is the croc project's and may change wording; the flow stays: one side shows a code, the other enters it. +The browser UI at getcroc.com is the croc project's and may change wording; the flow stays: one device shows a code, the other enters it. Please keep CrocApp in the foreground during a transfer on iPhone and iPad. A transfer can continue briefly in the background through a system Live Activity. diff --git a/docs/knowledge/app-ui-architecture.md b/docs/knowledge/app-ui-architecture.md index 2abbfcd9..2c2f7c5b 100644 --- a/docs/knowledge/app-ui-architecture.md +++ b/docs/knowledge/app-ui-architecture.md @@ -10,11 +10,12 @@ Engine contract: `crocmobile-bridge.md`. Open defects: `../known-issues.md`. CrocAppApp owns @State TransferController + AppSettings + OutputFolderStore + HistoryStore + LocalNetworkChecker + AppRouter.shared → .environment on WindowGroup content - (macOS Settings scene gets only outputFolder + settings — no router/controller/localNetwork) + (macOS Settings scene gets only outputFolder + settings, no router/controller/localNetwork) ContentView → HomeView (sole NavigationStack, path: $router.path, value-based links) → SendView | ReceiveView | SettingsScreen | HistoryView | HowItWorksView each transfer screen: controller.isActive ? TransferStatusView() : input form TransferStatusView renders every non-idle phase; IncomingRequestView = accept gate +Shared pieces (Views/): ButtonVariants, DropZone, CodePhraseDisplay, DirectionHeader, ForwardProgressBar, PhoneHaptics; Support/Inflected.swift QRCodeView (cross-platform gen) / QRScannerView.swift (whole file #if os(iOS), VisionKit) ``` @@ -59,21 +60,21 @@ Construction order in `CrocAppApp.init` matters: `AppSettings` first, then `Tran Relay strings use `""` to mean "croc default" (the `TextField` prompt reads "Public relay pool") and reach the engine as stored, password included. Go resolves them (`crocmobile/relay.go`): blank means croc's default, and with no custom relay and local-only off the relay comes from upstream's public pool by code. `relayKind` (`publicDefault` / `custom` / `localOnly`) is display-only and classifies as custom if either address is set to something other than `RelayDefaults` (CrocKit, read from Go). -`Views/PowerSettingsSections.swift` holds the shared `Form` sections — relay, connection (the Automatic / Relay only `Transport` picker, disabled while Local network only is on), transfer toggles, exclusions, confirmation with the auto-accept warning footer — embedded in both the macOS `SettingsView` (Settings scene, ⌘,) and the iOS `SettingsScreen` (gear toolbar → `Route.settings`). `TrustBadge(relay:)` appears in the waiting, transferring, and confirm-send views. `HowItWorksView` is the plain-language explainer; its facts come from `what-is-croc.md`. +`Views/PowerSettingsSections.swift` holds the shared `Form` sections — relay, connection (the Automatic / Relay Only `Transport` picker, disabled while Local Network Only is on), transfer toggles, exclusions, confirmation with the Auto-Accept Transfers warning footer — embedded in both the macOS `SettingsView` (Settings scene, ⌘,) and the iOS `SettingsScreen` (gear toolbar → `Route.settings`). `TrustBadge(relay:)` appears in the waiting, transferring, and confirm-send views. `HowItWorksView` is the plain-language explainer; its facts come from `what-is-croc.md`. **About and privacy link (R38).** `Views/AboutSection.swift` is the last `Form` section of the macOS General tab and the iOS `SettingsScreen`: version (`CFBundleShortVersionString` and `CFBundleVersion`), Privacy Policy and Support `Link`s. The URLs and the version string live in `Support/AppInfo.swift`; `AppInfoTests` pins their scheme, host and path. On macOS `AppCommands` replaces the `.appInfo` group with an "About CrocApp" button that calls `NSApplication.orderFrontStandardAboutPanel(options:)` with a linked "Privacy Policy" `.credits` string (the Direct build's `UpdateCommands` still attaches after that group). -`Phase.confirmSend` is the sender side of croc `--ask`: entered at `.connected` when `bothSidesConfirm` is on, progress-guarded like `.incoming`, accept is `respond(true)`, decline is `cancel()`. +`Phase.confirmSend` is the sender side of croc `--ask`: entered at `.connected` when `bothSidesConfirm` is on, progress-guarded like `.incoming`, accept is `respond(true)`, decline is `cancel()`; its Cancel button is `.destructiveTinted`. ### Stored transfers (R18-R21) `isStoredTransfer` marks a transfer that goes through a stored-transfer server instead of a croc peer; it reuses `run()`, `handle(_:)`, the `BGContinuedProcessingTask` wrap and the history record. - `startStoreSend(urls:downloads:lifetimeSeconds:)` uploads to `AppSettings.storedServer` (UserDefaults `settings.storedServer`, default `https://getcroc.com`). The engine sends `.progress`, then `.stored(receipt)` just before `.done`; the controller puts the receipt in the vault, keeps its id and expiry for the record, and turns `.done` into `Phase.stored(receipt)`. - `startStoreReceive(link:into:folderIsScoped:)` takes the same path as a live receive: `.incoming` with the conflict scan, then `respond(accept:policy:)`. Auto-accept never applies; the engine always waits. -- Views. `SendView`: a "Send as link" toggle (disabled with a reason line for text or a staged folder), "Link lasts" picker (1 hour / 1 day default / 7 days), a downloads stepper (1-100, default 1) and a server note; with it on, the custom code field is hidden and the button reads Upload. It checks `AppSettings.storedServerError` and blocks Upload on an invalid server. `TransferStatusView`: `Phase.stored(receipt)` renders `storedView` (Link ready, expiry and download count, QR, link text, Copy Link via `Clipboard.copy(_, thisDeviceOnly: true)`, Share Link, Done); the transferring heading reads Uploading / Downloading and shows a caption instead of `TrustBadge` when `isStoredTransfer`. `ReceiveView`: `router.pendingReceive` carrying `.stored(url)` sets `storedLink`, which replaces the code field with a "Download link from " row and a Clear button, and Receive calls `startStoreReceive`. `HistoryView`: stored rows add a "Link expires / expired / revoked" line, and Revoke Link (context menu; "Revoke" swipe on iOS) sits behind a confirmation dialog, with an alert on failure. `PowerSettingsSections`: a "Send as link" section with the Server field, inline `storedServerError` banner and a Reset button shown only when the value differs from the default. +- Views. `SendView`: a "Send as Link" toggle (disabled with a reason line for text or a staged folder), "Link Lasts" picker (1 hour / 1 day default / 7 days), a downloads stepper (1-100, default 1) and a server note; with it on, the custom code field is hidden and the primary button reads Send as Link (content.md → Terminology: still sending, never Upload). It checks `AppSettings.storedServerError` and blocks Send as Link on an invalid server. `TransferStatusView`: `Phase.stored(receipt)` renders `storedView` (Link ready, expiry and download count, QR, link text, Copy Link via `Clipboard.copy(_, thisDeviceOnly: true)`, Share Link, Done); `DirectionHeader` reads Sending / Receiving for stored transfers too (never Uploading / Downloading) and the view shows a caption instead of `TrustBadge` when `isStoredTransfer`. `ReceiveView`: `router.pendingReceive` carrying `.stored(url)` sets `storedLink`, which replaces the code field with a "Receive from Link" row and a Clear button, and Receive calls `startStoreReceive`. `HistoryView`: stored rows add a "Link expires / expired / revoked" line, and Revoke Link (context menu; "Revoke" swipe on iOS) sits behind a confirmation dialog, with an alert on failure. `PowerSettingsSections`: a "Send as link" section (header, sentence case) with the Link Server field, inline `storedServerError` banner and a Reset button shown only when the value differs from the default. - `AppSettings.storedServerError(_:)` accepts `https`, or `http` on loopback only, and nothing after the host. `TransferLink.parse` accepts a stored link only over `https`, or `http` on loopback, and the engine checks the id and key lengths. - `Clipboard.copy(_, thisDeviceOnly:)` sets `UIPasteboard` with `.localOnly` on iOS so a link (its fragment is the key) stays off Universal Clipboard; the macOS pasteboard has no such option, so Copy Link there is an ordinary copy. The Camera app does not open CrocApp for a stored link: the Associated Domains entitlement and the AASA file cover only `crocapp.dev/r/*`. Receiving in the app is Paste or Scan QR Code. -- A stored transfer cannot resume, so no failure copy offers it. A background expiry reads "Upload interrupted … Try again" (send) or "Download interrupted … Open the link again" (receive), concern 4. `friendlyMessage(…, stored: true)` names the transfer server for `relayUnreachable` and `connectionLost`, which is how a store call reports a network failure. +- A stored transfer cannot resume, so no failure copy offers it. A background expiry reads "Sending was interrupted … Try again" (send) or "Receiving was interrupted … Open the link again" (receive), concern 4. `friendlyMessage(…, stored: true)` names the transfer server for `relayUnreachable` and `connectionLost`, which is how a store call reports a network failure. - `revoke(_ record:)` (throws `EngineFailure`) calls `CrocEngine.revokeStored`, which needs no free engine slot, then drops the receipt and sets `revokedAt`. `canRevoke(_:)` is true for an unexpired, unrevoked record whose receipt this device still holds. `Models/StoredTransferVault.swift` keeps receipts (id, origin, upload token, link, expiry) in the Keychain, all in one item (`storedReceipts`, a JSON array), because `SecretStore` cannot list accounts. Expired receipts are purged when the vault opens (each launch). A failed Keychain write is logged, never lost silently, and leaves the receipts already stored untouched; the new receipt then lasts until the app quits. Harness runs and previews use `MemorySecretStore`. History never holds the token or link: a stored record's `codeHint` is the server host (`codeHint(storedLink:)`). @@ -99,6 +100,17 @@ Neither surface owns transfer state. Both drive the one `TransferController`, so - A shortcut does not answer the accept prompt (auto-accept applies as usual), and Keep Both or Replace follows the setting. - **iPad (R28).** `Views/AppCommands.swift` shows Send… (⌘1), Receive… (⌘2) and Show Receive Folder (⇧⌘R) in the File menu on both platforms. On iPadOS the last one opens `shareddocuments://` plus the percent-encoded receive-folder path with `UIApplication.open`, which is the Files app's scheme for a local folder. Not verified on a device. +## Transfer screen presentation + +The shared views route onto `design/`; a screen picks one instead of styling ad hoc. + +- `Views/ButtonVariants.swift`: `.buttonVariant(_:)` with `prominent`, `secondary`, `destructive`, `destructiveTinted`, `plain` (design/components.md → Button; design/swiftui.md → Components). A `PrimitiveButtonStyle` so the label color sits on the label itself. Disabled buttons get only the system dimming, never an extra opacity on the label. Filled `.destructive` is for Decline on the accept gate and the Clear History confirmation only; Cancel (waiting, transferring, confirm-send, menu bar) and Discard (`StagedFilesSheet`) are `.destructiveTinted`. +- `DropZone`, `CodePhraseDisplay` (eyebrow, code, Copy Code; `compact` for the 300-wide menu bar column), `DirectionHeader` (arrow plus Sending / Receiving), `ForwardProgressBar` (never moves backwards, `resetKey` snaps when the measured thing changes; design/motion.md → Choreography). `Support/Inflected.swift` resolves `^[3 file](inflect: true)` markup into a plain `String`. +- **Accept gate.** `.incoming` offers Decline only, no Cancel: Cancel would answer the sender on the user's behalf (`TransferStatusView.showsCancel` is false there; design/components.md → AcceptGate). Leaving the screen answers nothing and stays silent. `HomeView` shows a "Files are waiting for your answer" `StatusBanner` with Review (`router.path = [.receive]`) while the phase is `.incoming`, so a live request is never lost. +- **Phase cross-fade.** `TransferStatusView` animates on `phaseKind` (the phase case without its payload), not on progress ticks, so only a real phase change cross-fades; it is `nil` under Reduce Motion (design/motion.md → Choreography). +- **Haptics live in `ContentView`**, one `.phoneHaptic(trigger: TransferHaptic(controller.phase))` on the root. `TransferHaptic` maps a phase change to one of four events and everything else stays silent; iPhone only (design/motion.md → Haptics and sound). Views never fire their own. +- **Wording.** Status titles are sentence case ("Link ready"), controls Title Case ("Send as Link", "Local Network Only", "Auto-Accept Transfers"); section headers, footers and descriptions stay sentence case (design/content.md → Capitalization). A stored transfer still reads Sending / Receiving, never Uploading / Downloading, and "incoming" is not user-facing (design/content.md → Terminology). + ## History `Models/TransferHistory.swift` — `TransferRecord` is a SwiftData `@Model` (fields including `statusRaw` string storage for the enum). Stored uploads add optional `storedID`, `expiresAt` and `revokedAt`; optional so a store written by an older build opens without a migration step. `HistoryStore` is `@MainActor @Observable`, wraps `container.mainContext`, exposes `add` / `delete` / `clear` / `recordCount` / `markRevoked`. The container is in-memory when `AutoVerify.isHarnessRun`, and is injected twice: as `.environment(history)` and as `.modelContainer(history.container)`. diff --git a/docs/knowledge/docs-site.md b/docs/knowledge/docs-site.md index ceb66d82..15e5e9cc 100644 --- a/docs/knowledge/docs-site.md +++ b/docs/knowledge/docs-site.md @@ -10,12 +10,20 @@ pnpm --dir web/docs start `start` runs the `tokens` script first (`cp ../../design/tokens.css src/css/tokens.css`), then `docusaurus start`. `tokens.css` is gitignored, same pattern as `web/landing/tokens.css`: edit `design/tokens.css`, never `web/docs/src/css/tokens.css` directly, the copy is overwritten on every `start` or `build`. +The same `tokens` script copies `web/landing/assets/img/mascot-web.webp` to `static/img/mascot.webp` (gitignored), the navbar logo. It is the one web derivative of the mascot (`design/brand.md` → Mascot); never commit a second copy. + To build the full Pages artifact (both surfaces, what CI publishes) locally: ```bash scripts/assemble-site.sh # writes ./_site/, landing at /, docs at /docs/ ``` +## Content and styling rules + +- Page copy is bound by `design/content.md` (terminology, capitalization, the approved security claims) and `design/brand.md`. Headings and sidebar titles are sentence case; app controls are quoted in bold, Title Case, exactly as the app spells them. +- Theme styling lives in `web/docs/src/css/custom.css`, mapped onto `design/tokens.css` with no literals (`design/web.md` → Docs site). The code theme is `src/prismTheme.ts`, the same token mapping. The navbar collapses at Docusaurus's own 996px breakpoint, not ours, so do not set `display` on a `.navbar__item` outside a `min-width: 997px` media query. +- Screenshots come from `scripts/capture-screenshots.sh --copy-web` and go in as a light and dark pair on one line, each with a `#gh-light-mode-only` or `#gh-dark-mode-only` fragment (`custom.css` shows the one that matches the theme toggle). Docusaurus rewrites the paths to hashed ones, so the phone-width cap in `custom.css` keys off the alt text: an iPhone shot's alt must say "on iOS". + ## Adding a page New file under `web/docs/content/` (not `docs/`, that name is taken by the repository's own contributor docs). Frontmatter needs `title`, `description`, and `sidebar_position`. The sidebar is autogenerated from the directory tree, so nothing is ever added to `sidebars.ts`: order comes from `sidebar_position`, and a new directory needs a `_category_.json`. `routeBasePath: '/'`, so `content/guide/foo.md` serves at `/docs/guide/foo/`. diff --git a/docs/knowledge/tooling.md b/docs/knowledge/tooling.md index 0c3f2d83..d7191182 100644 --- a/docs/knowledge/tooling.md +++ b/docs/knowledge/tooling.md @@ -20,6 +20,7 @@ What is wired up, what was deliberately skipped, and the traps in each. | Docusaurus 3.10, React 19.3, TypeScript 7 | `web/docs/package.json`, `pnpm-lock.yaml`; `pnpm-workspace.yaml` holds `overrides` for transitive advisories | docs site, local + CI (`pnpm run typecheck` in `github-pages.yml`) | | Sparkle 2.10.0 (SwiftPM, `CrocApp Direct` only; auto-update for the direct-download build) | `Package.resolved` under `app/CrocApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/`, minimum in the `XCRemoteSwiftPackageReference` in `project.pbxproj`; Dependabot (`swift`, `/app`) reads the Xcode project's `Package.resolved` and opens the bump | `CrocApp Direct` build, local + CI (unsigned build step) | | MCP servers `xcode` (`xcrun mcpbridge`) and `gopls` (`gopls mcp`) for AI sessions | `.mcp.json` (Claude Code asks each user to approve them); `gopls` starts in `crocmobile/` because the Go module lives there, not at the repo root | local AI sessions | +| `scripts/verify-contrast.sh`, `scripts/verify-design.sh` (pure bash and awk, no toolchain) | none; the first holds the ratios `design/colors.md` claims, the second holds the token parity and no-literals tables | local + CI (`contrast` job, shown as `design checks`, runs both) | | GitHub Actions | `.github/workflows/ci.yml` | push to main, every PR; a `changes` job skips the jobs whose area is untouched; `interop (local relay)` runs `scripts/verify-interop.sh` on `macos-26` against a local `croc relay` | | GitHub Actions | `.github/workflows/go-weekly.yml` | Mondays 06:17 UTC + manual | | GitHub Actions | `.github/workflows/github-pages.yml` | PRs build only; push to main (`web/landing/**`, `web/docs/**`, `design/tokens.css`) also deploys | @@ -37,7 +38,7 @@ Xcode baseline lives in `.xcode-version` (plain text, one line). CI reads it int CI gotchas: -- **`ci.yml` has no workflow-level `paths:`.** A filtered-out workflow never reports, so a required check would stay pending. Instead a `changes` job diffs the PR base (or the push's `before`) against the head with plain `git diff --name-only` and sets one output per area (`swift`, `go`, `build`, `design`, `interop`); each job gates on its output with `if:`. A skipped job counts as passed. Adding a file CI *does* consume (a new script, a new dotfile config) means adding it to the matching regex in the `changes` job. An unknown base (new branch, force push) runs everything. A skipped matrix job reports under its unexpanded name and stays pending, so branch protection requires only the `ci pass` aggregate job, never the individual jobs. Unit tests ride on existing jobs: `go test -race` in `reusable-go.yml` (with `GOOS`/`GOARCH` unset for that step, since the job-level `darwin` would build a test binary the Linux runner cannot run), and `swift test` plus `xcodebuild test` on the macOS leg of `build`. The `interop (local relay)` job (`macos-26`) runs `scripts/verify-interop.sh` against a `croc relay` on 127.0.0.1 with raised admission limits (the public relays rate-limit a shared runner address); it installs the v11 CLI with `go install` at the version in `crocmobile/go.mod` (Homebrew's bottle lagged at 11.5.0, which has no `croc store`) and the v10 peer with `go install` at the version in `scripts/croc-v10.version`. The other harnesses and the public-pool scenario are not run by CI. +- **`ci.yml` has no workflow-level `paths:`.** A filtered-out workflow never reports, so a required check would stay pending. Instead a `changes` job diffs the PR base (or the push's `before`) against the head with plain `git diff --name-only` and sets one output per area (`swift`, `go`, `build`, `design`, `interop`; `design` covers `design/`, app Swift, the asset catalog, web CSS and both verify scripts); each job gates on its output with `if:`. A skipped job counts as passed. Adding a file CI *does* consume (a new script, a new dotfile config) means adding it to the matching regex in the `changes` job. An unknown base (new branch, force push) runs everything. A skipped matrix job reports under its unexpanded name and stays pending, so branch protection requires only the `ci pass` aggregate job, never the individual jobs. Unit tests ride on existing jobs: `go test -race` in `reusable-go.yml` (with `GOOS`/`GOARCH` unset for that step, since the job-level `darwin` would build a test binary the Linux runner cannot run), and `swift test` plus `xcodebuild test` on the macOS leg of `build`. The `interop (local relay)` job (`macos-26`) runs `scripts/verify-interop.sh` against a `croc relay` on 127.0.0.1 with raised admission limits (the public relays rate-limit a shared runner address); it installs the v11 CLI with `go install` at the version in `crocmobile/go.mod` (Homebrew's bottle lagged at 11.5.0, which has no `croc store`) and the v10 peer with `go install` at the version in `scripts/croc-v10.version`. The other harnesses and the public-pool scenario are not run by CI. - **govulncheck under the go job's cross-compile env.** The go job sets `GOOS=darwin GOARCH=arm64` job-wide so vet/build analyse the shipping target. `go install` honours that and drops a darwin binary into `$GOPATH/bin/darwin_arm64/` (unrunnable on the Linux host, and not where `$(go env GOPATH)/bin/…` looks) → exit 127. Install the tool with `env -u GOOS -u GOARCH` so the binary is host-native in `$GOPATH/bin`, then run it with the job env intact so the *analysis* still targets darwin. - **`conflicting nullability specifier on return types` was fixed on the Go side, not suppressed.** It used to fire on every macOS/iOS build: gomobile treats any `New` func as a constructor and builds the ObjC selector from whatever *follows* that prefix (`bind/gen.go`, `bind/genobjc.go`), so `NewOptions` on type `Options` left an empty suffix and emitted a bare `- (nullable instancetype)init` on `CrocmobileOptions`, clashing with `NSObject`'s `nonnull init`. Renaming the Go constructor to `NewOptionsDefault` keeps it recognised and yields `initDefault`. Do not rename it back, and do not add a `New` func whose suffix is empty. - **macOS app build has no signing identity.** The runner has no `Mac Development` cert, so the `build (macOS)` job passes `CODE_SIGNING_ALLOWED=NO` to `xcodebuild`. The iOS Simulator destination never signs, so it needs nothing. diff --git a/scripts/capture-screenshots.sh b/scripts/capture-screenshots.sh index 378b1044..1b50842a 100755 --- a/scripts/capture-screenshots.sh +++ b/scripts/capture-screenshots.sh @@ -513,7 +513,7 @@ mac_titles() { # the window title(s) a scre send-files|send-code|sending|stored-link) echo "Send" ;; receive-code|receive-incoming|receive-conflict|receiving|receive-done) echo "Receive" ;; history) echo "History" ;; - how-it-works) echo "How it works" ;; + how-it-works) echo "How It Works" ;; settings) echo "CrocApp Settings|General|Settings" ;; settings-advanced) echo "Advanced" ;; esac diff --git a/scripts/verify-contrast.sh b/scripts/verify-contrast.sh index ba867c0d..686d334d 100755 --- a/scripts/verify-contrast.sh +++ b/scripts/verify-contrast.sh @@ -29,7 +29,10 @@ TOKENS=design/tokens.css # theme|foreground|background stack|expected. The stack composites front to # back, so a translucent tint names the opaque surface under it — keep it and # the markdown table in step. Below the marker: rules that must hold whatever -# the values are, as label|theme|foreground|stack|min or max|threshold. +# the values are, as label|theme|foreground|stack|min or max|threshold. Below +# the second marker: alias rules, label|theme|token|target, true when the token +# resolves to the same color as the target in that theme. The focus ring bands +# (rule 7) are parsed from --shadow-focus-ring in the END block. TABLES=' light|--color-accent|--color-surface-base|3.41 light|--color-accent|--color-surface-grouped|3.06 @@ -37,12 +40,14 @@ light|--color-accent|--color-accent-tint>--color-surface-base|2.99 light|--color-accent-text|--color-surface-base|4.94 light|--color-accent-text|--color-surface-grouped|4.42 light|--color-accent-text|--color-accent-tint>--color-surface-base|4.32 +light|--color-accent-text|--color-surface-card|4.94 light|--color-accent-text-on-tint|--color-surface-base|7.36 light|--color-accent-text-on-tint|--color-surface-grouped|6.60 light|--color-accent-text-on-tint|--color-accent-tint>--color-surface-base|6.45 light|--label-2|--color-surface-base|3.44 light|--label-2-web|--color-surface-base|5.16 light|--label-2-web|--color-surface-grouped|4.85 +light|--color-text-secondary|--color-accent-tint>--color-surface-base|4.78 light|--label-3|--color-surface-base|1.73 light|--color-on-accent|--color-accent|3.41 light|--color-on-status|--color-status-error|3.55 @@ -53,12 +58,17 @@ light|--color-status-success|--color-surface-base|2.22 light|--color-status-warning|--color-surface-base|2.20 light|--color-status-error|--color-surface-base|3.55 light|--color-status-info|--color-surface-base|4.02 +light|--glass-tint-accent|--color-surface-base|1.14 dark|--color-accent|--color-surface-base|9.44 dark|--color-accent|--color-surface-card|7.65 dark|--color-accent|--color-accent-tint>--color-surface-base|7.72 dark|--color-on-accent|--color-accent|7.19 dark|--color-on-status|--color-status-error|3.41 dark|--label-2|--color-surface-base|6.36 +dark|--color-status-success|--color-status-success-tint>--color-surface-base|8.05 +dark|--color-status-warning|--color-status-warning-tint>--color-surface-base|7.93 +dark|--color-status-error|--color-status-error-tint>--color-surface-base|5.25 +dark|--color-status-info|--color-status-info-tint>--color-surface-base|4.92 == invariants == accent fill clears 3:1 on base|light|--color-accent|--color-surface-base|min|3.0 accent-text clears AA on base|light|--color-accent-text|--color-surface-base|min|4.5 @@ -71,6 +81,21 @@ filled destructive clears 3:1, dark|dark|--color-on-status|--color-status-error| focus ring outer band clears 3:1 on base|light|--color-accent|--color-surface-base|min|3.0 focus ring inner band clears 3:1 on the accent fill|light|--color-surface-base|--color-accent|min|3.0 tertiary is NOT a usable text color|light|--label-3|--color-surface-base|max|3.0 +secondary text clears AA on the accent tint (rule 4)|light|--color-text-secondary|--color-accent-tint>--color-surface-base|min|4.5 +accent-text clears AA on a card|light|--color-accent-text|--color-surface-card|min|4.5 +the accent tint is NOT a visible focus indicator (rule 7)|light|--glass-tint-accent|--color-surface-base|max|1.5 +dark status success clears AA on its own tint|dark|--color-status-success|--color-status-success-tint>--color-surface-base|min|4.5 +dark status warning clears AA on its own tint|dark|--color-status-warning|--color-status-warning-tint>--color-surface-base|min|4.5 +dark status error clears AA on its own tint|dark|--color-status-error|--color-status-error-tint>--color-surface-base|min|4.5 +dark status info clears AA on its own tint|dark|--color-status-info|--color-status-info-tint>--color-surface-base|min|4.5 +== aliases == +rule 4: secondary text is label-2-web, light|light|--color-text-secondary|--label-2-web +rule 4: secondary text is label-2-web, dark|dark|--color-text-secondary|--label-2-web +rule 6: link is accent-text, light|light|--color-text-link|--color-accent-text +rule 6: link is accent-text, dark|dark|--color-text-link|--color-accent-text +dark accent collapses to green-400|dark|--color-accent|--green-400 +dark accent-text collapses to green-400|dark|--color-accent-text|--green-400 +dark accent-text-on-tint collapses to green-400|dark|--color-accent-text-on-tint|--green-400 ' printf '%s\n' "$TABLES" | awk -v quiet="$QUIET" -v tokens="$TOKENS" ' @@ -186,6 +211,24 @@ function measure(theme, fg_name, stack, bg, fg, f, la, lb, hi, lo) { return (hi + 0.05) / (lo + 0.05) } +# Rule 7: --shadow-focus-ring is two bands, the base surface inside (2px) and +# the accent outside (4px). The contrast rows prove each band reads against the +# other and the accent; this proves the token still is that ring. +function check_ring( ring, n, bands, k, parts, np, spread, color, want_spread, want_color) { + if (!("--shadow-focus-ring" in VAL)) { failures[++nfail] = "--shadow-focus-ring is not defined in design/tokens.css"; return } + ring = VAL["--shadow-focus-ring"] + n = split_top(ring, bands) + if (n != 2) { failures[++nfail] = "--shadow-focus-ring must have exactly two bands, has " n; return } + for (k = 1; k <= 2; k++) { + want_spread = (k == 1) ? "2px" : "4px" + want_color = (k == 1) ? "var(--color-surface-base)" : "var(--color-accent)" + np = split(bands[k], parts, " ") + spread = parts[4]; color = parts[np] + if (np != 5 || parts[1] != "0" || parts[2] != "0" || parts[3] != "0" || spread != want_spread || color != want_color) + failures[++nfail] = "--shadow-focus-ring band " k " is \"" bands[k] "\", rule 7 needs \"0 0 0 " want_spread " " want_color "\"" + } +} + BEGIN { TOLERANCE = 0.01 section = "cases" @@ -201,7 +244,11 @@ BEGIN { if (!match(rest, /\*\//)) { body = head; break } body = head substr(rest, RSTART + 2) } - n = split(body, decls, ";") + # Hand-rolled split: BSD awk also breaks split(s, a, ";") on newlines, which + # truncates multi-line values such as --shadow-focus-ring. + n = 0 + while ((p = index(body, ";")) > 0) { decls[++n] = substr(body, 1, p - 1); body = substr(body, p + 1) } + decls[++n] = body for (i = 1; i <= n; i++) { d = trim(decls[i]) if (d !~ /^--[a-z0-9-]+[ \t]*:/) continue @@ -213,6 +260,7 @@ BEGIN { trim($0) == "" { next } trim($0) == "== invariants ==" { section = "invariants"; next } +trim($0) == "== aliases ==" { section = "aliases"; next } section == "cases" { split($0, f, "|") @@ -222,6 +270,13 @@ section == "cases" { next } +section == "aliases" { + split($0, f, "|") + nalias++ + alabel[nalias] = f[1]; atheme[nalias] = f[2]; atoken[nalias] = f[3]; atarget[nalias] = f[4] + next +} + { split($0, f, "|") ninvariant++ @@ -246,7 +301,14 @@ END { if (!ok) failures[++nfail] = "invariant broken: " ilabel[i] " (" sprintf("%.2f", got) " is " (ikind[i] == "min" ? "below " : "above ") sprintf("%.2f", ithreshold[i]) ")" } - if (!quiet) printf "\n%d documented ratios, %d invariants\n", ncase, ninvariant + for (i = 1; i <= nalias; i++) { + if (resolve(atoken[i], atheme[i], 0) != resolve(atarget[i], atheme[i], 0)) + failures[++nfail] = "alias rule broken: " alabel[i] " (" atoken[i] " no longer resolves to " atarget[i] ")" + } + + check_ring() + + if (!quiet) printf "\n%d documented ratios, %d invariants, %d alias rules, %d focus-ring bands\n", ncase, ninvariant, nalias, 2 if (nfail) { print "\nFAIL" @@ -255,6 +317,6 @@ END { exit 1 } - if (!quiet) print "OK: design/colors.md matches design/tokens.css" + if (!quiet) print "OK: the " ncase " ratios equal the values recorded here (the colors.md Contrast table), and the " ninvariant " invariants, " nalias " alias rules and focus-ring bands hold for design/tokens.css" } ' diff --git a/scripts/verify-design.sh b/scripts/verify-design.sh new file mode 100755 index 00000000..6d839c7e --- /dev/null +++ b/scripts/verify-design.sh @@ -0,0 +1,883 @@ +#!/usr/bin/env bash +# Enforces the two rules design/AGENTS.md leaves to a machine. +# +# 1. Mirror parity. design/tokens.css is canonical; DesignTokens.swift and the +# asset catalog color sets are hand-written mirrors. Every shared value +# must be equal in all three, every token must either be mirrored or +# listed as deliberately not mirrored. +# 2. No literals. Web CSS and the app write tokens, not hex, px, ms, raw +# system colors or spring presets (design/web.md -> The only literals web +# may write, design/swiftui.md -> Allowed literals). +# +# scripts/verify-design.sh # findings as path:line: message, exit 1 on any +# scripts/verify-design.sh --quiet # findings and FAIL lines only, no OK lines +# +# Pure bash and awk on purpose, like verify-contrast.sh: no toolchain, so CI +# runs it on a bare runner. Only POSIX awk features are used (BSD awk on macOS, +# mawk on the Ubuntu runner): no interval expressions, no gawk extensions. +# Exit 0 clean, 1 findings, 2 usage or unreadable input. +set -euo pipefail +cd "$(dirname "$0")/.." + +QUIET=0 +case "${1-}" in + --quiet) QUIET=1 ;; + "") ;; + *) echo "usage: $0 [--quiet]" >&2; exit 2 ;; +esac +export LC_ALL=C + +TOKENS=design/tokens.css +SWIFT_TOKENS=app/CrocApp/Support/DesignTokens.swift +COLORSETS=app/CrocApp/Assets.xcassets +[ -r "$TOKENS" ] || { echo "FAIL: $TOKENS not readable" >&2; exit 2; } +[ -r "$SWIFT_TOKENS" ] || { echo "FAIL: $SWIFT_TOKENS not readable" >&2; exit 2; } +[ -d "$COLORSETS" ] || { echo "FAIL: $COLORSETS not a directory" >&2; exit 2; } + +# --------------------------------------------------------------------------- +# Parity table: numeric and curve tokens. token|Swift Enum.constant[|curve] +# +# Values compare after unit normalization: px is dropped, ms becomes seconds, +# cubic-bezier(a, b, c, d) is compared to .timingCurve(a, b, c, d, ...). +# Add a shared token here (or to the not-mirrored block) in the same commit +# that adds it to tokens.css: design/AGENTS.md -> Adding or removing a token. +# --------------------------------------------------------------------------- +NUMERIC_TABLE=' +--space-1|Spacing.space1 +--space-2|Spacing.space2 +--space-3|Spacing.space3 +--space-4|Spacing.space4 +--space-5|Spacing.space5 +--space-6|Spacing.space6 +--space-7|Spacing.space7 +--space-8|Spacing.space8 +--space-9|Spacing.space9 +--space-10|Spacing.space10 +--space-11|Spacing.space11 +--radius-xs|Radius.xs +--radius-sm|Radius.sm +--radius-md|Radius.md +--radius-lg|Radius.lg +--radius-xl|Radius.xl +--radius-2xl|Radius.xxl +--radius-capsule|Radius.capsule +--border-hairline|BorderWidth.hairline +--border-hairline-half|BorderWidth.hairlineHalf +--control-height-lg|ControlHeight.large +--control-height-md|ControlHeight.medium +--control-height-sm|ControlHeight.small +--hit-target-min|ControlHeight.hitTargetMin +--icon-size-sm|IconSize.small +--icon-size-md|IconSize.medium +--icon-size-lg|IconSize.large +--content-max-width|LayoutCap.contentMaxWidth +--opacity-disabled-container|Opacity.disabledContainer +--dur-fast|Motion.durationFast +--dur-base|Motion.durationBase +--dur-slow|Motion.durationSlow +--tracking-code|Tracking.code +--tracking-eyebrow|Tracking.eyebrow +--ease-out|Motion.easeOut|curve +--ease-standard|Motion.easeStandard|curve +' + +# --------------------------------------------------------------------------- +# Parity table: color sets. ColorSetName|token. Each is resolved through +# var() and light-dark() for both themes and compared with the set's "any" +# (light) and "dark" appearances: 1/255 per channel, 0.01 alpha. A set with no +# dark appearance must have a token whose two themes are equal. +# --------------------------------------------------------------------------- +COLOR_TABLE=' +AccentColor|--color-accent +ColorAccentPressed|--color-accent-pressed +ColorAccentTint|--color-accent-tint +ColorAccentText|--color-accent-text +ColorAccentTextOnTint|--color-accent-text-on-tint +ColorOnAccent|--color-on-accent +ColorPaper|--paper +ColorScrim|--color-scrim +ColorStatusSuccessTint|--color-status-success-tint +ColorStatusWarningTint|--color-status-warning-tint +ColorStatusErrorTint|--color-status-error-tint +ColorStatusInfoTint|--color-status-info-tint +' + +# --------------------------------------------------------------------------- +# Not mirrored: shared token names (no `web` segment) the app deliberately has +# no Swift form for. One ERE per line, anchored. Source: design/swiftui.md -> +# Not mirrored, plus the semantic aliases swiftui.md -> Colors routes to +# system semantics (text, status, surface, separator). A token that matches +# neither this block nor a table above fails the check, so a new shared token +# forces the mirror decision in the same commit. +# --------------------------------------------------------------------------- +NOT_MIRRORED=' +^--green- +^--sys- +^--label- +^--fill- +^--bg- +^--separator +^--glass- +^--blur- +^--color-surface- +^--color-glass-border$ +^--shadow- +^--type- +^--weight- +^--font- +^--tracking- +^--icon-stroke +^--color-text- +^--color-fill +^--color-separator$ +^--color-on-status$ +^--color-status-(success|warning|error|info)$ +' + +# --------------------------------------------------------------------------- +# No-literals allowlists. path ERE @@ source-line ERE @@ reason. A finding is +# suppressed only when both match, so an allowance cannot silently widen to a +# whole file. Never suppress with a comment in Swift or CSS: add a row here. +# --------------------------------------------------------------------------- +ALLOW_WEB=' +' + +ALLOW_SWIFT=' +^app/CrocApp/CrocAppApp\.swift$ @@ \.frame\( @@ scene window minimums from platforms.md (swiftui.md -> Allowed literals: window sizes in the scene declarations) +^app/CrocApp/Views/AppCommands\.swift$ @@ NSColor\.labelColor @@ About-panel credits are an NSAttributedString, which has no SwiftUI color: the system semantic label color is the text color (swiftui.md -> Colors routes text to system semantics) +^app/CrocApp/Views/SettingsView\.swift$ @@ \.frame\(minWidth @@ the macOS Settings window minimum from platforms.md (swiftui.md -> Allowed literals) +' + +# Swift files the no-literals lint covers: DesignTokens.swift is where the +# literals live, tests may use any value. +SWIFT_FILES=$(find app -name '*.swift' ! -path "$SWIFT_TOKENS" ! -path 'app/CrocAppTests/*' | sort) +[ -n "$SWIFT_FILES" ] || { echo "FAIL: no Swift files under app/" >&2; exit 2; } + +WEB_FILES=$( + for f in web/landing/styles.css web/docs/src/css/custom.css; do + [ -r "$f" ] || { echo "FAIL: $f not readable" >&2; exit 2; } + echo "$f" + done + find web/landing -name '*.html' | sort +) + +# --------------------------------------------------------------------------- +# awk shared by every phase. Inputs travel through ENVIRON because a literal +# newline cannot pass through `awk -v`. +# --------------------------------------------------------------------------- +AWK_COMMON=' +function trim(s) { sub(/^[ \t\r\n]+/, "", s); sub(/[ \t\r\n]+$/, "", s); return s } + +function blank(s) { gsub(/[^\n]/, " ", s); return s } + +function nl_count(s, t) { t = s; return gsub(/\n/, "", t) } + +function read_file(path, line, buf, rc) { + buf = "" + while ((rc = (getline line < path)) > 0) buf = buf line "\n" + close(path) + if (rc < 0) return "\001unreadable" + return buf +} + +# Comments become spaces so line numbers survive. +function strip_css_comments(s, out, p, q) { + out = "" + while ((p = index(s, "/*")) > 0) { + out = out substr(s, 1, p - 1) + s = substr(s, p + 2) + q = index(s, "*/") + if (q == 0) { out = out " " blank(s); s = ""; break } + out = out " " blank(substr(s, 1, q - 1)) " " + s = substr(s, q + 2) + } + return out s +} + +# Swift source, one cleaned line per SL[] entry: comments gone, string and +# multi-line string contents emptied, so a regex never reads prose. Returns +# the line count, or -1 when the file is unreadable. +function swift_clean(path, n, raw, out, i, j, len, c, c2, c3, d, in_block, in_multi, rc) { + n = 0; in_block = 0; in_multi = 0 + while ((rc = (getline raw < path)) > 0) { + n++; out = ""; i = 1; len = length(raw) + while (i <= len) { + c = substr(raw, i, 1); c2 = substr(raw, i, 2); c3 = substr(raw, i, 3) + if (in_block) { if (c2 == "*/") { in_block = 0; i += 2 } else i++; continue } + if (in_multi) { if (c3 == "\"\"\"") { in_multi = 0; i += 3 } else i++; continue } + if (c3 == "\"\"\"") { in_multi = 1; i += 3; out = out "\"\""; continue } + if (c2 == "/*") { in_block = 1; i += 2; continue } + if (c2 == "//") break + if (c == "\"") { + j = i + 1 + while (j <= len) { + d = substr(raw, j, 1) + if (d == "\\") j += 2 + else if (d == "\"") break + else j++ + } + out = out "\"\""; i = j + 1; continue + } + out = out c; i++ + } + SL[n] = out + } + close(path) + if (rc < 0) return -1 + return n +} + +function count_of(line, ch, t) { t = line; return gsub(ch, "", t) } + +# rgba of a token or literal: "r g b a", channels 0-255, alpha 0-1. +function split_top(s, out, i, ch, depth, cur, n) { + depth = 0; cur = ""; n = 0 + for (i = 1; i <= length(s); i++) { + ch = substr(s, i, 1) + if (ch == "(") depth++ + else if (ch == ")") depth-- + if (ch == "," && depth == 0) { out[++n] = trim(cur); cur = "" } + else cur = cur ch + } + out[++n] = trim(cur) + return n +} + +function hex2dec(h, i, c, d, n) { + n = 0 + for (i = 1; i <= length(h); i++) { + c = tolower(substr(h, i, 1)) + d = index("0123456789abcdef", c) - 1 + if (d < 0) die("bad hex digit in " h) + n = n * 16 + d + } + return n +} + +function die(msg) { print "FAIL: " msg > "/dev/stderr"; internal = 1; exit 2 } +' + +# --------------------------------------------------------------------------- +# Phase 1: mirror parity +# --------------------------------------------------------------------------- +AWK_PARITY=' +function finding(path, line, msg) { print path ":" line ": " msg; nfind++ } + +function parse_color(v, theme, depth, args, n, inner, hex, parts, cnt, a) { + v = trim(v) + if (depth > 16) die("token recursion too deep at " v) + if (v ~ /^light-dark\(/) { + inner = substr(v, 12, length(v) - 12) + n = split_top(inner, args) + if (n != 2) die("light-dark() needs 2 arguments: " v) + return parse_color(args[theme == "light" ? 1 : 2], theme, depth + 1) + } + if (v ~ /^var\(/) return resolve(trim(substr(v, 5, length(v) - 5)), theme, depth + 1) + if (v ~ /^#[0-9a-fA-F]+$/) { + hex = substr(v, 2) + if (length(hex) != 6) die("only 6-digit hex is supported: " v) + return hex2dec(substr(hex, 1, 2)) " " hex2dec(substr(hex, 3, 2)) " " hex2dec(substr(hex, 5, 2)) " 1" + } + if (v ~ /^rgba?\(/) { + inner = v + sub(/^rgba?\(/, "", inner); sub(/\)$/, "", inner) + cnt = split_top(inner, parts) + if (cnt < 3 || cnt > 4) die("rgb()/rgba() needs 3 or 4 arguments: " v) + a = (cnt == 4) ? parts[4] + 0 : 1 + return (parts[1] + 0) " " (parts[2] + 0) " " (parts[3] + 0) " " a + } + die("cannot parse color " v) +} + +function resolve(name, theme, depth) { + if (!(name in VAL)) die(name " is not defined in " tokens) + return parse_color(VAL[name], theme, depth) +} + +# tokens.css -> VAL[name], VLINE[name], ORDER[1..ntok] +function load_tokens(path, body, n, pieces, i, d, p, name, cum, lead, k) { + body = read_file(path) + if (body ~ /^\001unreadable/) die(path " not readable") + body = strip_css_comments(body) + # Hand-rolled split: BSD awk also breaks split(s, a, ";") on newlines. + n = 0 + while ((p = index(body, ";")) > 0) { pieces[++n] = substr(body, 1, p - 1); body = substr(body, p + 1) } + pieces[++n] = body + cum = 0 + for (i = 1; i <= n; i++) { + d = pieces[i] + # Drop everything up to the last brace: `:root {` or `}` heads a piece. + k = 0 + for (p = length(d); p >= 1; p--) if (substr(d, p, 1) == "{" || substr(d, p, 1) == "}") { k = p; break } + lead = substr(d, 1, k) + d = substr(d, k + 1) + if (match(d, /--[a-z0-9-]+[ \t]*:/)) { + lead = lead substr(d, 1, RSTART - 1) + d = substr(d, RSTART) + p = index(d, ":") + name = trim(substr(d, 1, p - 1)) + if (!(name in VAL)) { + VAL[name] = trim(substr(d, p + 1)) + VLINE[name] = 1 + cum + nl_count(lead) + ORDER[++ntok] = name + } + } + cum += nl_count(pieces[i]) + } +} + +# DesignTokens.swift -> SW[Enum.name] (whole statement), SWLINE[...] +function load_swift(path, n, i, line, depth, sp, STK, c, k, stmt, j, pd, bd, name, pend, key) { + n = swift_clean(path) + if (n < 0) die(path " not readable") + depth = 0; sp = 0; pend = "" + for (i = 1; i <= n; i++) { + line = SL[i] + if (sp > 0 && depth == STKD[sp] && match(line, /static[ \t]+(let|var|func)[ \t]+[A-Za-z0-9_]+/)) { + name = substr(line, RSTART, RLENGTH); sub(/^static[ \t]+(let|var|func)[ \t]+/, "", name) + stmt = line; j = i + pd = count_of(stmt, "\\(") - count_of(stmt, "\\)") + bd = count_of(stmt, "{") - count_of(stmt, "}") + while ((pd > 0 || bd > 0) && j < n && j - i < 20) { + j++; stmt = stmt "\n" SL[j] + pd += count_of(SL[j], "\\(") - count_of(SL[j], "\\)") + bd += count_of(SL[j], "{") - count_of(SL[j], "}") + } + key = STKN[sp] "." name + SW[key] = stmt; SWLINE[key] = i + } + if (match(line, /(^|[ \t])enum[ \t]+[A-Za-z0-9_]+/)) { + name = substr(line, RSTART, RLENGTH); sub(/^.*enum[ \t]+/, "", name); pend = name + } + for (k = 1; k <= length(line); k++) { + c = substr(line, k, 1) + if (c == "{") { depth++; if (pend != "") { sp++; STKN[sp] = pend; STKD[sp] = depth; pend = "" } } + else if (c == "}") { if (sp > 0 && depth == STKD[sp]) sp--; depth-- } + } + } +} + +function swift_num(stmt, v) { + v = trim(substr(stmt, index(stmt, "=") + 1)) + sub(/[ \t]*\{.*$/, "", v) + if (v ~ /^-?[0-9]+(\.[0-9]+)?$/) return v + 0 + if (match(v, /^CGFloat\(-?[0-9.]+\)$/)) { sub(/^CGFloat\(/, "", v); sub(/\)$/, "", v); return v + 0 } + return "?" +} + +function token_num(raw) { + raw = trim(raw) + if (raw ~ /^-?[0-9.]+ms$/) { sub(/ms$/, "", raw); return (raw + 0) / 1000 } + if (raw ~ /^-?[0-9.]+px$/) { sub(/px$/, "", raw); return raw + 0 } + if (raw ~ /^-?[0-9.]+$/) return raw + 0 + return "?" +} + +# First `want` numbers inside the call that follows `marker`, space separated. +function call_nums(s, marker, want, p, rest, out, cnt) { + p = index(s, marker) + if (p == 0) return "?" + rest = substr(s, p + length(marker)); out = ""; cnt = 0 + while (cnt < want && match(rest, /-?[0-9]*\.?[0-9]+/)) { + out = out ((cnt ? " " : "") (substr(rest, RSTART, RLENGTH) + 0)) + rest = substr(rest, RSTART + RLENGTH); cnt++ + } + if (cnt < want) return "?" + return out +} + +function near(a, b) { return (a - b < 0.0005 && b - a < 0.0005) } + +function check_numeric( n, i, f, tok, key, tv, sv, tn, sn, a, b, k) { + n = split(ENVIRON["NUMERIC_TABLE"], rows, "\n") + for (i = 1; i <= n; i++) { + if (trim(rows[i]) == "") continue + split(rows[i], f, "|"); tok = f[1]; key = f[2] + nparity++; MIRRORED[tok] = 1 + if (!(tok in VAL)) { finding("scripts/verify-design.sh", 1, tok " is in the parity table but not defined in " tokens); continue } + if (!(key in SW)) { finding(swiftfile, 1, "missing " key ", the Swift mirror of " tok " (" tokens ":" VLINE[tok] ")"); continue } + if (f[3] == "curve") { + tv = call_nums(VAL[tok], "cubic-bezier(", 4) + sv = call_nums(SW[key], "timingCurve(", 4) + if (sv == "?") { finding(swiftfile, SWLINE[key], key " has no .timingCurve(a, b, c, d, ...) to compare with " tok); continue } + split(tv, a, " "); split(sv, b, " ") + for (k = 1; k <= 4; k++) if (!near(a[k], b[k])) break + if (k <= 4) finding(swiftfile, SWLINE[key], key " curve is (" sv ") but " tok " is (" tv ") in " tokens ":" VLINE[tok]) + continue + } + tn = token_num(VAL[tok]); sn = swift_num(SW[key]) + if (tn == "?") { finding(tokens, VLINE[tok], tok " value " VAL[tok] " is not a plain px, ms or unitless number the parity check can read"); continue } + if (sn == "?") { finding(swiftfile, SWLINE[key], key " is not a plain numeric literal the parity check can read"); continue } + if (!near(tn, sn)) finding(swiftfile, SWLINE[key], key " is " sn " but " tok " is " tn " (" VAL[tok] ") in " tokens ":" VLINE[tok]) + } +} + +# Colorset Contents.json -> CS[set, "any"|"dark"] = "r g b a" (255 scale), CSLINE. +function comp(v, is_alpha) { + v = trim(v); gsub(/["\t ,]/, "", v) + if (is_alpha) return v + 0 + if (v ~ /^0[xX][0-9a-fA-F]+$/) return hex2dec(substr(v, 3)) + if (v ~ /\./) return (v + 0) * 255 + return v + 0 +} + +function load_colorset(set, path, line, rc, mode, skip, r, g, b, a, rline, val, have) { + path = colorsets "/" set ".colorset/Contents.json" + mode = "any"; skip = 0; have = 0 + while ((rc = (getline line < path)) > 0) { + if (line ~ /"appearance"[ \t]*:[ \t]*"contrast"/) skip = 1 + else if (line ~ /"value"[ \t]*:[ \t]*"dark"/) mode = "dark" + else if (line ~ /"red"[ \t]*:/) { r = comp(substr(line, index(line, ":") + 1), 0); rline = FNR_; have = 1 } + else if (line ~ /"green"[ \t]*:/) g = comp(substr(line, index(line, ":") + 1), 0) + else if (line ~ /"blue"[ \t]*:/) b = comp(substr(line, index(line, ":") + 1), 0) + else if (line ~ /"alpha"[ \t]*:/) a = comp(substr(line, index(line, ":") + 1), 1) + if (line ~ /"idiom"/) { + if (have && !skip) { CS[set, mode] = r " " g " " b " " a; CSLINE[set, mode] = rline } + mode = "any"; skip = 0; have = 0 + } + FNR_++ + } + close(path) + FNR_ = 1 + return (rc < 0) ? 0 : 1 +} + +function check_colors( n, i, f, set, tok, th, want, got, w, g, k, mode, path) { + n = split(ENVIRON["COLOR_TABLE"], rows, "\n") + for (i = 1; i <= n; i++) { + if (trim(rows[i]) == "") continue + split(rows[i], f, "|"); set = f[1]; tok = f[2] + ncolor++; MIRRORED[tok] = 1 + path = colorsets "/" set ".colorset/Contents.json" + if (!(tok in VAL)) { finding("scripts/verify-design.sh", 1, tok " is in the color table but not defined in " tokens); continue } + FNR_ = 1 + if (!load_colorset(set)) { finding(path, 1, "missing color set " set ", the mirror of " tok " (" tokens ":" VLINE[tok] ")"); continue } + for (k = 1; k <= 2; k++) { + th = (k == 1) ? "light" : "dark" + mode = (k == 1) ? "any" : "dark" + want = resolve(tok, th, 0) + if (!((set, mode) in CS)) { + if (k == 2 && (set, "any") in CS) { + if (want != resolve(tok, "light", 0)) { finding(path, 1, set " has no dark appearance but " tok " differs in dark (" want ")"); continue } + got = CS[set, "any"]; mode = "any" + } else { finding(path, 1, set " has no " mode " appearance to compare with " tok); continue } + } else got = CS[set, mode] + split(want, w, " "); split(got, g, " ") + if (w[1] - g[1] > 1 || g[1] - w[1] > 1 || w[2] - g[2] > 1 || g[2] - w[2] > 1 || w[3] - g[3] > 1 || g[3] - w[3] > 1 || w[4] - g[4] > 0.01 || g[4] - w[4] > 0.01) + finding(path, CSLINE[set, mode], set " (" th ") is " sprintf("%.0f %.0f %.0f a%.3f", g[1], g[2], g[3], g[4]) " but " tok " is " sprintf("%.0f %.0f %.0f a%.3f", w[1], w[2], w[3], w[4]) " in " tokens ":" VLINE[tok]) + } + } +} + +# Every shared token is mirrored or deliberately not. +function check_complete( i, name, nn, pats, k, ok) { + nn = split(ENVIRON["NOT_MIRRORED"], pats, "\n") + for (i = 1; i <= ntok; i++) { + name = ORDER[i] + if (name ~ /(^|-)web(-|$)/) continue + if (name in MIRRORED) continue + ok = 0 + for (k = 1; k <= nn; k++) if (trim(pats[k]) != "" && name ~ trim(pats[k])) { ok = 1; break } + if (!ok) finding(tokens, VLINE[name], name " is shared but neither in the parity tables of scripts/verify-design.sh nor in design/swiftui.md -> Not mirrored: decide its mirror") + ncomplete++ + } +} + +BEGIN { + tokens = ENVIRON["TOKENS"]; swiftfile = ENVIRON["SWIFT_TOKENS"]; colorsets = ENVIRON["COLORSETS"] + FNR_ = 1 + load_tokens(tokens) + load_swift(swiftfile) + check_numeric() + check_colors() + check_complete() + if (nfind) { + printf "parity: FAIL (%d finding%s)\n", nfind, (nfind == 1 ? "" : "s") + exit 1 + } + if (ENVIRON["QUIET"] != "1") + printf "parity: OK (%d numeric and %d color mirrors, %d other shared tokens deliberately not mirrored)\n", nparity, ncolor, ncomplete +} +' + +# --------------------------------------------------------------------------- +# Phase 2a: web no-literals lint +# --------------------------------------------------------------------------- +AWK_WEB=' +function allowed(path, text, n, i, parts, rows) { + n = split(ENVIRON["ALLOW_WEB"], rows, "\n") + for (i = 1; i <= n; i++) { + if (trim(rows[i]) == "") continue + split(rows[i], parts, " @@ ") + if (path ~ trim(parts[1]) && text ~ trim(parts[2])) return 1 + } + return 0 +} + +function finding(path, line, msg, text) { + if (allowed(path, text)) return + print path ":" line ": " msg; nfind++ +} + +# Lines of every rule block that carries a clip or clip-path declaration: the +# visually-hidden pattern, where 1px is geometry (web.md). +function mark_clip(s, i, c, len, line, open_pos, open_line, blk, l) { + line = 1; open_pos = 0; len = length(s) + for (i = 1; i <= len; i++) { + c = substr(s, i, 1) + if (c == "\n") line++ + else if (c == "{") { open_pos = i; open_line = line } + else if (c == "}") { + if (open_pos > 0) { + blk = ";" substr(s, open_pos + 1, i - open_pos - 1) + if (blk ~ /[^A-Za-z-]clip(-path)?[ \t]*:/) for (l = open_line; l <= line; l++) EX[l] = 1 + } + open_pos = 0 + } + } +} + +# Selectors and at-rule preludes (including every @media condition) become +# spaces; only declarations remain. +function blank_preludes(s, i, c, len, seg, out) { + out = ""; seg = ""; len = length(s) + for (i = 1; i <= len; i++) { + c = substr(s, i, 1) + if (c == "{") { out = out blank(seg) " "; seg = "" } + else if (c == ";" || c == "}") { out = out seg c; seg = "" } + else seg = seg c + } + return out seg +} + +# CM[line, col] = 1 for every character inside a color-mix( ... ) call, across +# lines: the only place web.md lets a percentage and `black` appear (components.md +# -> Button specs). +function mark_color_mix(text, i, c, len, line, col, depth, want_open, k) { + delete CM + line = 1; col = 0; depth = 0; len = length(text) + for (i = 1; i <= len; i++) { + c = substr(text, i, 1); col++ + if (c == "\n") { line++; col = 0; continue } + if (depth == 0 && substr(text, i, 10) == "color-mix(" && (i == 1 || substr(text, i - 1, 1) !~ /[A-Za-z0-9_-]/)) depth = 1 + else if (depth > 0) { + if (c == "(") depth++ + else if (c == ")") depth-- + } + if (depth > 0) CM[line, col] = 1 + } +} + +# CSS named colors. transparent, currentColor, inherit, none are not colors to +# the lint (no entry here). +function is_named_color(w) { + return index(" " NAMED " ", " " tolower(w) " ") > 0 +} + +function lint_text(path, text, line0, use_ex, n, T, ln, t, rest, tok, prev, nextc, v, hexlen, pos, col, W, nw, k, off, before) { + if (NAMED == "") NAMED = "aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen" + mark_color_mix(text) + n = split(text, T, "\n") + for (ln = 1; ln <= n; ln++) { + t = T[ln] + if (t ~ /(^|[^A-Za-z0-9_-])(rgb|rgba|hsl|hsla)\(/) + finding(path, line0 + ln - 1, "color function literal: use a color token", t) + rest = t; off = 0 + while (match(rest, /#[0-9A-Fa-f]+/)) { + tok = substr(rest, RSTART, RLENGTH) + prev = (RSTART > 1) ? substr(rest, RSTART - 1, 1) : " " + nextc = substr(rest, RSTART + RLENGTH, 1) + before = substr(t, 1, off + RSTART - 1) + off += RSTART + RLENGTH - 1 + rest = substr(rest, RSTART + RLENGTH) + hexlen = length(tok) - 1 + if (before ~ /url\($/) continue + if (index(" \t:,(", prev) > 0 && nextc !~ /[A-Za-z0-9_-]/ && (hexlen == 3 || hexlen == 4 || hexlen == 6 || hexlen == 8)) + finding(path, line0 + ln - 1, "hex color literal " tok ": use a color token", t) + } + rest = t + while (match(rest, /-?[0-9]*\.?[0-9]+(rem|em|px|ms|s|vw|vh)/)) { + tok = substr(rest, RSTART, RLENGTH) + prev = (RSTART > 1) ? substr(rest, RSTART - 1, 1) : " " + nextc = substr(rest, RSTART + RLENGTH, 1) + rest = substr(rest, RSTART + RLENGTH) + if (prev ~ /[A-Za-z0-9_]/ || nextc ~ /[A-Za-z0-9_]/) continue + v = tok; sub(/(rem|em|px|ms|s|vw|vh)$/, "", v) + if (v + 0 == 0) continue + if (use_ex && EX[ln] && (tok == "1px" || tok == "-1px")) continue + finding(path, line0 + ln - 1, "dimension literal " tok ": use a spacing, size or duration token", t) + } + # Percentages: 0 and 100% are geometry; 50% only in a visually-hidden + # block (clip-path: inset(50%)); 12%, 22% and 82% only inside color-mix(). + rest = t; off = 0 + while (match(rest, /-?[0-9]*\.?[0-9]+%/)) { + tok = substr(rest, RSTART, RLENGTH) + prev = (RSTART > 1) ? substr(rest, RSTART - 1, 1) : " " + col = off + RSTART + off += RSTART + RLENGTH - 1 + rest = substr(rest, RSTART + RLENGTH) + if (prev ~ /[A-Za-z0-9_.]/) continue + v = tok; sub(/%$/, "", v) + if (v + 0 == 0 || v + 0 == 100) continue + if (use_ex && EX[ln] && v + 0 == 50) continue + if (((ln, col) in CM) && (v + 0 == 12 || v + 0 == 22 || v + 0 == 82)) continue + finding(path, line0 + ln - 1, "percentage literal " tok ": use a token (only 100%, and color-mix() as components.md specs, are allowed)", t) + } + # Named colors as values. black is spec-allowed inside color-mix() only. + rest = t; off = 0 + while (match(rest, /[A-Za-z][A-Za-z]*/)) { + tok = substr(rest, RSTART, RLENGTH) + prev = (RSTART > 1) ? substr(rest, RSTART - 1, 1) : " " + nextc = substr(rest, RSTART + RLENGTH, 1) + col = off + RSTART + off += RSTART + RLENGTH - 1 + rest = substr(rest, RSTART + RLENGTH) + if (prev ~ /[A-Za-z0-9_-]/ || nextc ~ /[A-Za-z0-9_(-]/) continue + if (!is_named_color(tok)) continue + if (tolower(tok) == "black" && ((ln, col) in CM)) continue + finding(path, line0 + ln - 1, "named color literal " tok ": use a color token", t) + } + } +} + +function lint_css(path, text, line0, s) { + delete EX + s = strip_css_comments(text) + mark_clip(s) + lint_text(path, blank_preludes(s), line0, 1) +} + +# Every style="..." (q is the quote character) in an HTML file. +function lint_style_attrs(path, text, re, q, rest, base, pre, m, qi, body, prevc) { + rest = text; base = 0 + while (match(rest, re)) { + pre = substr(rest, 1, RSTART - 1) + prevc = (RSTART > 1) ? substr(rest, RSTART - 1, 1) : " " + m = substr(rest, RSTART, RLENGTH) + rest = substr(rest, RSTART + RLENGTH) + base += nl_count(pre) + if (prevc !~ /[A-Za-z0-9_-]/) { + qi = index(m, q) + body = substr(m, qi + 1, length(m) - qi - 1) + lint_text(path, body, base + 1 + nl_count(substr(m, 1, qi)), 0) + } + base += nl_count(m) + } +} + +# Every