diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 1170799..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - -jobs: - ios: - name: iOS Unit Tests - runs-on: macos-15 - timeout-minutes: 30 - - steps: - - name: Check out - uses: actions/checkout@v5 - - - name: Select Xcode 26 - run: | - xcode_path=$(find /Applications -maxdepth 1 -type d -name 'Xcode_26*.app' -print | sort -V | tail -n 1) - test -n "$xcode_path" - sudo xcode-select -s "$xcode_path" - xcodebuild -version - - - name: Install XcodeGen - run: | - if ! command -v xcodegen >/dev/null 2>&1; then - brew install xcodegen - fi - - - name: Generate project - run: xcodegen generate --spec project.yml --project . --project-root . --quiet - - - name: Verify project configuration - run: Scripts/verify-project-config.sh - - - name: Resolve iOS simulator - id: simulator - run: | - set -o pipefail - udid=$(Scripts/resolve-ios-simulator.sh) - echo "udid=$udid" >> "$GITHUB_OUTPUT" - xcrun simctl list devices available | grep "$udid" - - - name: Run iOS unit tests - run: | - set -o pipefail - xcodebuild test \ - -project CheatSheet.xcodeproj \ - -scheme CheatSheetiOS \ - -destination "id=${{ steps.simulator.outputs.udid }}" \ - -derivedDataPath "$RUNNER_TEMP/CheatSheet-iOS-DD" \ - CODE_SIGNING_ALLOWED=NO - - - name: Run iOS UI smoke tests - run: | - set -o pipefail - xcodebuild test \ - -project CheatSheet.xcodeproj \ - -scheme CheatSheetiOSUI \ - -destination "id=${{ steps.simulator.outputs.udid }}" \ - -derivedDataPath "$RUNNER_TEMP/CheatSheet-iOS-UI-DD" \ - -only-testing:CheatSheetiOSUITests/CheatSheetiOSUITests \ - CODE_SIGNING_ALLOWED=NO - - - name: Build iOS Release configuration - run: | - set -o pipefail - xcodebuild build \ - -project CheatSheet.xcodeproj \ - -scheme CheatSheetiOS \ - -configuration Release \ - -destination "id=${{ steps.simulator.outputs.udid }}" \ - -derivedDataPath "$RUNNER_TEMP/CheatSheet-iOS-Release-DD" \ - CODE_SIGNING_ALLOWED=NO - - macos: - name: macOS Unit Tests - runs-on: macos-15 - timeout-minutes: 30 - - steps: - - name: Check out - uses: actions/checkout@v5 - - - name: Select Xcode 26 - run: | - xcode_path=$(find /Applications -maxdepth 1 -type d -name 'Xcode_26*.app' -print | sort -V | tail -n 1) - test -n "$xcode_path" - sudo xcode-select -s "$xcode_path" - xcodebuild -version - - - name: Install XcodeGen - run: | - if ! command -v xcodegen >/dev/null 2>&1; then - brew install xcodegen - fi - - - name: Generate project - run: xcodegen generate --spec project.yml --project . --project-root . --quiet - - - name: Run macOS unit tests - run: | - set -o pipefail - xcodebuild test \ - -project CheatSheet.xcodeproj \ - -scheme CheatSheet \ - -destination 'platform=macOS' \ - -derivedDataPath "$RUNNER_TEMP/CheatSheet-macOS-DD" \ - CODE_SIGNING_ALLOWED=NO - - - name: Build macOS Release configuration - run: | - set -o pipefail - xcodebuild build \ - -project CheatSheet.xcodeproj \ - -scheme CheatSheetApp \ - -configuration Release \ - -destination 'platform=macOS' \ - -derivedDataPath "$RUNNER_TEMP/CheatSheet-macOS-Release-DD" \ - CODE_SIGNING_ALLOWED=NO diff --git a/.gitignore b/.gitignore index 1e4693e..314249e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ CLAUDE.local.md *-release-audit.md *-release-qa-report.md *-release-checklist.md + +# App Store screenshot workspace: keep planning docs, templates, and the +# curated final set; raw/candidate captures are large and regeneratable. +AppStoreScreenshots/raw/ +AppStoreScreenshots/candidates/ +AppStoreScreenshots/templates/__pycache__/ diff --git a/.xcode-version b/.xcode-version new file mode 100644 index 0000000..7363977 --- /dev/null +++ b/.xcode-version @@ -0,0 +1 @@ +26.6 diff --git a/AppStoreScreenshots/final/README.md b/AppStoreScreenshots/final/README.md new file mode 100644 index 0000000..38aa1e9 --- /dev/null +++ b/AppStoreScreenshots/final/README.md @@ -0,0 +1,52 @@ +# CheatSheet App Store screenshots + +The current set contains ten iPhone screenshots (`iphone-01.png` through +`iphone-10.png`) and ten Mac screenshots (`mac-01.png` through `mac-10.png`). +Upload them in filename order. The iPhone files are 1320 × 2868 pixels for the +6.9-inch display slot. The Mac files are 2880 × 1800 pixels. All are RGB PNGs +without transparency. + +The first iPhone image shows the Home Screen widget. The next images show the +note library, editor, checklist, font and text-size controls, search, colors, +reference notes, and Trash recovery. The Mac set shows menu bar quick capture, +varied notes and colors, search, and Trash recovery. Captions and capture sources are +listed in `captions.tsv`. + +The iPad files in this directory are from an earlier set. They are outside +this iPhone and Mac update. + +## Capture and render + +The iPhone UI test target has a dedicated `MarketingScreenshotTests` suite. +It launches the app with `-cheatsheet-seed-screenshot-demo` to show curated +reference notes. Run it on an installed iPhone simulator runtime, then export +the kept attachments and copy them to `AppStoreScreenshots/raw/iphone/` using +the attachment names from `captions.tsv`: + +```sh +xcodebuild test -project CheatSheet.xcodeproj -scheme CheatSheetiOSUI \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=,OS=27.0' \ + -resultBundlePath /tmp/CheatSheet-Marketing-Captures.xcresult \ + -only-testing:CheatSheetiOSUITests/MarketingScreenshotTests +xcrun xcresulttool export attachments \ + --path /tmp/CheatSheet-Marketing-Captures.xcresult \ + --output-path /tmp/CheatSheet-Marketing-Attachments +``` + +The widget source is cropped from the existing +`Docs/Images/cheatsheet-iphone-widget.png` marketing capture. + +The Mac sources were captured from the app window and menu bar panel in the +Debug build with the same seeded notes and `-showWidgetHints NO`. The panel +and app window were aligned at their actual screen positions for `mac-02`. +The raw capture directory is +ignored by Git. Once the sources are present, render the final assets with: + +```sh +python3 AppStoreScreenshots/templates/render_final.py +``` + +The renderer uses the approved centered-caption layout and the existing dark +CheatSheet gradient. Captions describe visible features. They are short enough +to read at App Store preview size. diff --git a/AppStoreScreenshots/final/captions.tsv b/AppStoreScreenshots/final/captions.tsv new file mode 100644 index 0000000..d83b3f2 --- /dev/null +++ b/AppStoreScreenshots/final/captions.tsv @@ -0,0 +1,21 @@ +device order source caption feature +iphone 01 iphone/00-widget-source.png Pin a note on your\nHome Screen Home Screen widget +iphone 02 iphone/M1-library.png All your notes in\none place note library +iphone 03 iphone/M2-note-open.png Save commands\nin plain text note editor +iphone 04 iphone/M3-checklist.png Plan a release\nwith checklists checklist notes +iphone 05 iphone/M4-font-menu.png Choose a font\nfor each note font styles +iphone 06 iphone/M8-size-menu.png Set a comfortable\nreading size text size +iphone 07 iphone/M5-search.png Find notes\nas you type search +iphone 08 iphone/M9-concurrency.png Color-code notes\nby topic palette +iphone 09 iphone/M12-http.png Save references\nyou use often reference notes +iphone 10 iphone/M7-trash-detail.png Restore notes\nfrom Trash Trash recovery +mac 01 mac/01-library.png Notes for every\npart of your work note library +mac 02 mac/02-quick-capture.png Capture a note\nfrom the menu bar menu bar quick capture +mac 03 mac/03-checklist.png Check each step\nbefore you ship checklist notes +mac 04 mac/04-swift.png Save Swift notes\nfor later reference notes +mac 05 mac/05-docker.png Docker commands\nin one place command notes +mac 06 mac/07-zsh.png Use color to sort\nyour notes palette +mac 07 mac/08-xcode.png Xcode shortcuts\nat a glance shortcut notes +mac 08 mac/09-http.png Monospaced text\nfor technical notes font styles +mac 09 mac-raw-search.png Search notes\nas you type search +mac 10 mac/10-trash.png Bring notes back\nfrom Trash Trash recovery diff --git a/AppStoreScreenshots/final/ipad-01.png b/AppStoreScreenshots/final/ipad-01.png new file mode 100644 index 0000000..31f8656 Binary files /dev/null and b/AppStoreScreenshots/final/ipad-01.png differ diff --git a/AppStoreScreenshots/final/ipad-02.png b/AppStoreScreenshots/final/ipad-02.png new file mode 100644 index 0000000..eddf877 Binary files /dev/null and b/AppStoreScreenshots/final/ipad-02.png differ diff --git a/AppStoreScreenshots/final/ipad-03.png b/AppStoreScreenshots/final/ipad-03.png new file mode 100644 index 0000000..c03796b Binary files /dev/null and b/AppStoreScreenshots/final/ipad-03.png differ diff --git a/AppStoreScreenshots/final/iphone-01.png b/AppStoreScreenshots/final/iphone-01.png new file mode 100644 index 0000000..998ac9a Binary files /dev/null and b/AppStoreScreenshots/final/iphone-01.png differ diff --git a/AppStoreScreenshots/final/iphone-02.png b/AppStoreScreenshots/final/iphone-02.png new file mode 100644 index 0000000..d5d5ac3 Binary files /dev/null and b/AppStoreScreenshots/final/iphone-02.png differ diff --git a/AppStoreScreenshots/final/iphone-03.png b/AppStoreScreenshots/final/iphone-03.png new file mode 100644 index 0000000..082c072 Binary files /dev/null and b/AppStoreScreenshots/final/iphone-03.png differ diff --git a/AppStoreScreenshots/final/iphone-04.png b/AppStoreScreenshots/final/iphone-04.png new file mode 100644 index 0000000..a9754b3 Binary files /dev/null and b/AppStoreScreenshots/final/iphone-04.png differ diff --git a/AppStoreScreenshots/final/iphone-05.png b/AppStoreScreenshots/final/iphone-05.png new file mode 100644 index 0000000..7c27a73 Binary files /dev/null and b/AppStoreScreenshots/final/iphone-05.png differ diff --git a/AppStoreScreenshots/final/iphone-06.png b/AppStoreScreenshots/final/iphone-06.png new file mode 100644 index 0000000..0902db9 Binary files /dev/null and b/AppStoreScreenshots/final/iphone-06.png differ diff --git a/AppStoreScreenshots/final/iphone-07.png b/AppStoreScreenshots/final/iphone-07.png new file mode 100644 index 0000000..3259a93 Binary files /dev/null and b/AppStoreScreenshots/final/iphone-07.png differ diff --git a/AppStoreScreenshots/final/iphone-08.png b/AppStoreScreenshots/final/iphone-08.png new file mode 100644 index 0000000..5db899f Binary files /dev/null and b/AppStoreScreenshots/final/iphone-08.png differ diff --git a/AppStoreScreenshots/final/iphone-09.png b/AppStoreScreenshots/final/iphone-09.png new file mode 100644 index 0000000..fbbd661 Binary files /dev/null and b/AppStoreScreenshots/final/iphone-09.png differ diff --git a/AppStoreScreenshots/final/iphone-10.png b/AppStoreScreenshots/final/iphone-10.png new file mode 100644 index 0000000..73014a6 Binary files /dev/null and b/AppStoreScreenshots/final/iphone-10.png differ diff --git a/AppStoreScreenshots/final/mac-01.png b/AppStoreScreenshots/final/mac-01.png new file mode 100644 index 0000000..e2aa585 Binary files /dev/null and b/AppStoreScreenshots/final/mac-01.png differ diff --git a/AppStoreScreenshots/final/mac-02.png b/AppStoreScreenshots/final/mac-02.png new file mode 100644 index 0000000..836cb83 Binary files /dev/null and b/AppStoreScreenshots/final/mac-02.png differ diff --git a/AppStoreScreenshots/final/mac-03.png b/AppStoreScreenshots/final/mac-03.png new file mode 100644 index 0000000..743b5f0 Binary files /dev/null and b/AppStoreScreenshots/final/mac-03.png differ diff --git a/AppStoreScreenshots/final/mac-04.png b/AppStoreScreenshots/final/mac-04.png new file mode 100644 index 0000000..d4b6b1c Binary files /dev/null and b/AppStoreScreenshots/final/mac-04.png differ diff --git a/AppStoreScreenshots/final/mac-05.png b/AppStoreScreenshots/final/mac-05.png new file mode 100644 index 0000000..8fd543e Binary files /dev/null and b/AppStoreScreenshots/final/mac-05.png differ diff --git a/AppStoreScreenshots/final/mac-06.png b/AppStoreScreenshots/final/mac-06.png new file mode 100644 index 0000000..18521d5 Binary files /dev/null and b/AppStoreScreenshots/final/mac-06.png differ diff --git a/AppStoreScreenshots/final/mac-07.png b/AppStoreScreenshots/final/mac-07.png new file mode 100644 index 0000000..2df96c4 Binary files /dev/null and b/AppStoreScreenshots/final/mac-07.png differ diff --git a/AppStoreScreenshots/final/mac-08.png b/AppStoreScreenshots/final/mac-08.png new file mode 100644 index 0000000..af2d777 Binary files /dev/null and b/AppStoreScreenshots/final/mac-08.png differ diff --git a/AppStoreScreenshots/final/mac-09.png b/AppStoreScreenshots/final/mac-09.png new file mode 100644 index 0000000..e8a4f13 Binary files /dev/null and b/AppStoreScreenshots/final/mac-09.png differ diff --git a/AppStoreScreenshots/final/mac-10.png b/AppStoreScreenshots/final/mac-10.png new file mode 100644 index 0000000..add4fce Binary files /dev/null and b/AppStoreScreenshots/final/mac-10.png differ diff --git a/AppStoreScreenshots/planning/concept-matrix.csv b/AppStoreScreenshots/planning/concept-matrix.csv new file mode 100644 index 0000000..0a43bcc --- /dev/null +++ b/AppStoreScreenshots/planning/concept-matrix.csv @@ -0,0 +1,16 @@ +Number,ShortName,DeviceClass,FeaturedRealState,Evidence,Headline,Subhead,SearchTheme,Composition,ThemeTreatment,StoryRole,Risk,ClarityScore,DistinctivenessScore,UserValueScore,EvidenceScore,SearchFitScore,TotalScore +H1,"Every Format, One Glance",iphone,"Home Screen crop centered on the systemMedium widget showing the real 'Widget Formatting Demo' note (pinned, indigo). systemMedium caps body at 5 lines, which lands exactly on: heading, plain line, open bullet, open task, and the completed task rendering as green checkmark.circle.fill + strikethrough.","WidgetFamily+CheatSheetLayout.swift:16 (systemMedium lineLimit=5); CheatSheetNote.swift:110-124 (Widget Formatting Demo body, exactly 5 non-empty lines before Tips); WidgetLineView.swift:11,18,20 (checkmark/strikethrough); DisplayLine.swift:52-77 (parser)","Pin the note you need most.","Headings, tasks, and done items — formatted the moment you glance at it.",quick notes widget for iPhone,single-device-framed,"Dark widget gradient rgb(0.12,0.14,0.18)->rgb(0.05,0.06,0.08); indigo 5D6BFF header icon tint",hook,"Line-cap math independently verified against WidgetFamily+CheatSheetLayout.swift — this is the corrected replacement for the original run's 'Becomes a Checklist', which got the same idea wrong",9,8,8,10,8,43 +H2,"Home Screen, At a Glance",iphone,"Home Screen with the systemSmall widget prominently placed, showing the Git Rescue demo note (pinned, indigo) from ScreenshotDemoContent.swift. systemSmall caps at 4 lines: heading + first 3 git commands.","WidgetFamily+CheatSheetLayout.swift:16 (systemSmall lineLimit=4); ScreenshotDemoContent.swift:14-29 (Git Rescue note, pinned/indigo); CheatSheetWidgetView.swift (noteView rendering)",The command you always forget.,One glance at your Home Screen.,quick notes widget for iPhone,single-device-framed,"Dark backdrop consistent with widget's own gradient",hook (alternate),"none identified",8,7,8,9,8,40 +H3,"Add It in Seconds",iphone,"iOS system widget-gallery sheet (long-press Home Screen -> + -> search CheatSheet), showing the real configurationDisplayName and description strings plus the live 3-size carousel preview.","CheatSheetWidget.swift:11-13 (configurationDisplayName 'CheatSheet', description 'Keep a pinned coding note nearby.', supportedFamilies); WidgetFamily+CheatSheetLayout.swift:15-17 (per-family line caps)",Add it in seconds.,A real widget for a real note.,quick notes widget for iPhone,ui-forward-full-bleed,"OS system chrome, not custom app UI",how-it-works,"Must be a genuine simulator capture of iOS's own widget gallery — cannot be composited/faked",7,8,6,9,6,36 +H4,Pin Any Note in One Tap,iphone,"iPhone compact editor with EditorHeader visible: pin button (Label 'Use in Widget'/'Pinned to Widget', systemImage pin/pin.fill), status label ('Editable note'/'Shown in widget'), palette+font pickers alongside.","EditorHeader.swift:32-36 (status label), :44-52 (pin button, accessibilityLabel, .help text); GlassPalettePicker.swift; FontStylePicker.swift",One tap. Pinned for good.,Any note becomes your widget note.,quick notes widget for iPhone,close-crop,"Dark backdrop; note surface = regularMaterial + palette tint",how-it-works,"none identified",8,7,7,9,7,38 +H5,"The Widget, Up Close",iphone,"Chrome-free hero crop: systemLarge widget (9-line cap) isolated on the app's own brand backdrop, showing the full Widget Formatting Demo body including the Tips heading section.","WidgetFamily+CheatSheetLayout.swift:17 (systemLarge lineLimit=9); CheatSheetNote.swift:110-124 (full 8-line body fits within 9-line cap); CheatSheetWidgetView.swift:82-107 (widget's own gradient, distinct from in-app material)",Everything you pinned, at a glance.,The widget that doubles as onboarding.,quick notes widget for iPhone,close-crop,"Widget's own LinearGradient per colorScheme, not the app's regularMaterial glass",proof,"none identified",7,7,7,9,6,36 +1,Type It Plain,iphone,"iPhone note editor (EditorTextPanel) mid-type on the Git Flow starter note: title field reads Git Flow; TextEditor body shows real starter text typed line-by-line; EditorHeader status label reads Editable note; cyan palette swatch; keyboard visible.","EditorTextPanel.swift:11; EditorHeader.swift:9,32-36; CheatSheetNote.swift:126-137 (Git Flow starter body, cyan tint)",Type the command you already know.,Plain text in, no special syntax to learn.,code snippet notes app / command line reference,single-device-framed,"Dark backdrop #141923->#1A2130->#0D111C; regularMaterial + cyan tint 9-16%, hairline stroke, 20pt corner radius",hook,none identified,8,7,6,9,8,38 +3,"Plain Text, Real Structure",iphone,"Single iPhone frame split top/bottom: top = raw typed shorthand from real starter/demo content ('# Git Rescue' / '- [x] Pull, then rebase' / '- [ ] Never force-push main'); bottom = same lines through the real checklist pipeline rendered.","DisplayLine.swift:52-77 parsedChecklistLine; ChecklistLineView.swift:10-23; ScreenshotDemoContent.swift Git Rescue note (real checklist lines, fixes original run's fabricated sample text)",What you type is what you get.,No formatting toolbar — just the shorthand you already use.,markdown checklist notes / auto-formatting notes app,close-crop,"Dark backdrop; thin divider between halves",differentiator,"FIXED from original run: now uses real Git Rescue demo content instead of invented sample lines",7,8,6,9,9,39 +5,Color-Code by Project,iphone,"iPhone editor, GlassPalettePicker swatch row expanded inline in EditorHeader's compactControls, mid-selection on the cyan swatch, over the real Git Flow starter note with its actual git command lines.","GlassPalettePicker.swift; EditorHeader.swift (compactControls); CheatSheetNote.swift:147-157 (10-case palette enum) and :126-137 (Git Flow starter note)",10 colors. One glance.,Color-code notes the moment you write them.,customizable notes app,close-crop,"Dark AppTheme gradient; cyan 45C7C4 tint ~12% opacity, all 10 swatches at exact palette hex values",differentiator,none identified,7,7,8,9,8,39 +8,"Trash, Not Gone",iphone,"Two-panel before/after: left = Move to Trash toolbar action available; right = resulting TrashNoteView state — In Trash label, 'Deletes automatically in 30 days.' countdown text, Restore Note and Delete Now buttons.","ContentView.swift:266,274 (toggle/move-to-trash buttons); TrashNoteView.swift (full view); CheatSheetNote+Trash.swift:13-31 (trashStatusText, retentionInterval=30 days)",Deleted isn't gone for 30 days.,Move a note to Trash, and it stays recoverable.,notes app with trash recovery,multi-device-justified,"Dark backdrop; note surface = regularMaterial + palette tint",how-it-works,"'Live countdown' phrasing in original description overstated a computed-at-render string — corrected to static countdown text",8,7,6,9,6,36 +7,Zero Network Calls,iphone,"Text-forward claim slide (no device chrome): 'No account. No sign-in. No network calls.' with a small citation line referencing the Privacy Nutrition Label.","PrivacyInfo.xcprivacy (both targets, empty NSPrivacyCollectedDataTypes, NSPrivacyTracking false); full-repo grep confirms zero network calls; no login/auth screens exist anywhere",Nothing you write leaves your device.,No account system, no network calls — ever.,private notes app no account,close-crop,"Large-type slide, brand primary #4B88FF headline accent",trust,"Must avoid implying 'encrypted'/'secure' claims not evidenced — keep strictly to 'no account / no network'",9,9,7,10,6,41 +12,Find It Before You Lose It,ipad,"iPad split-view Sidebar with the real .searchable field active mid-type, filtering the note list down to 'Git Flow' by matching 'git'.","SidebarView.swift:32-36 (.searchable, placement: .sidebar); CheatSheetNote.swift:127-136 (Git Flow starter content)",Search finds the command.,Type what you remember — CheatSheet narrows instantly.,git commands cheat sheet,single-device-framed,"iPad framed, light AppTheme gradient white 94%->#E8F0F7->#DAE2E6",how-it-works,none identified,7,7,9,7,6,36 +13,iPad Split View Checklists,ipad,"iPad landscape, real splitRoot NavigationSplitView: sidebar with searchable field, detail pane showing the shipped Widget Formatting Demo note mixing heading + unchecked + checked lines via the real checklist pipeline.","ContentView.swift splitRoot (NavigationSplitView); DisplayLine.swift parsedChecklistLine; ChecklistLineView.swift; CheatSheetNote.swift starterNotes",Type '- [ ]'. It becomes a checklist.,Headings and checklists, formatted as you type.,code snippet notes app,ui-forward-full-bleed,"Light mode, full-bleed iPad, indigo 5D6BFF tint",how-it-works,none identified,8,7,6,9,5,35 +14,iPad Palette and Font Picker,ipad,"iPad landscape split view, EditorHeader with GlassPalettePicker expanded (all 10 swatches) alongside FontStylePicker's 4 options, sidebar behind showing color-coded notes.","GlassPalettePicker.swift; FontStylePicker.swift; EditorHeader.swift; CheatSheetPalette (10 cases) and CheatSheetFontStyle (4 cases)",Color-code every cheat sheet.,10 colors, 4 fonts — organize at a glance.,sticky notes for developers,close-crop,"Dark mode, cropped tight on editor header + expanded pickers",personalization,"Render all 10 swatches at exact hex values",7.5,8.5,5.5,9.5,8,39 +15,Mac Menu Bar Quick Capture,mac,"macOS menu bar dropdown (MenuBarQuickAccessView), 340pt wide: header, quick-capture field, glass New Note button (real Cmd-N shortcut), Recent Notes list (real prefix(5), showing Git Flow + Widget Formatting Demo). Full main window visible on the desktop behind.","MenuBarQuickAccessView.swift:17-92 (frame width 340), :51 (.keyboardShortcut(\"n\") VERIFIED), :23-24 (activeNotes.prefix(5) VERIFIED), :115-141 (quick capture), :212-250 (MenuBarNoteRow)",Every command, one click away.,Quick-capture a note without leaving what you're doing.,menu bar notes mac,ui-forward-full-bleed,"Full desktop capture, no device frame, dark backdrop, accent #4B88FF on the glass button",how-it-works,"CORRECTED: ⌘N shortcut and prefix(5) count independently re-verified against source (line 51, line 24) — both real, not speculative as the original synthesis flagged",7,9,9,10,7,42 +16,Font Style Menu Open,mac,"FontStylePicker Menu open on the Widget Formatting Demo note, showing all 4 real rows — Mono, System, Rounded, Serif — each rendered in its own Font.Design, checkmark on the active style.","FontStylePicker.swift:6-29 (Menu, ForEach CheatSheetFontStyle.allCases); CheatSheetNote.swift:197-221 (4-case enum)",Mono. System. Rounded. Serif.,Pick the type that matches how you think.,developer note fonts,single-device-framed,"Light AppTheme gradient; indigo 5D6BFF tint",personalization,none identified,8,8,5,9,7,37 diff --git a/AppStoreScreenshots/planning/feature-evidence.md b/AppStoreScreenshots/planning/feature-evidence.md new file mode 100644 index 0000000..8893445 --- /dev/null +++ b/AppStoreScreenshots/planning/feature-evidence.md @@ -0,0 +1,78 @@ +# CheatSheet: Feature Evidence Table + +Source repo: `/Users/wesleykeetch/Documents/Developer/CheatSheet`, branch +`feature/app-store-screenshots` off `develop`. Every row below is verified +against actual source, not assumed. + +## Inputs (Phase 0) + +| Input | Value | Basis | +| --- | --- | --- | +| `{APP_NAME}` | CheatSheet | `project.yml` `CFBundleDisplayName` | +| `{REPO_PATH}` | `/Users/wesleykeetch/Documents/Developer/CheatSheet` | n/a | +| `{OUTPUT_DIR}` | `./AppStoreScreenshots` | this directory | +| `{TARGET_USER}` | A developer mid-task, terminal or editor open, who needs a command or snippet they always forget | README: "keeping short coding notes, checklists, and command reminders close by"; starter notes are `git status` / `git switch -c` / `xcodebuild` style commands, not generic to-dos; onboarding copy: "Keep the coding commands you reach for every day close at hand." | +| `{PRIMARY_SEARCH_INTENT}` | "developer cheat sheet app" | App is a named-genre utility (cheat sheet), scoped explicitly to coding use per README/onboarding/starter content, category `public.app-category.productivity` | +| `{SECONDARY_SEARCH_INTENTS}` | "quick notes widget for iPhone" · "git commands cheat sheet" · "code snippet notes app" · "sticky notes for developers" · "command line reference widget" | Widget feature, Git Flow starter note, checklist/command formatting, menu bar quick capture | +| `{TOP_FEATURES}` | see Selected Features below | n/a | +| `{DEVICE_SUPPORT}` | iPhone + iPad + Mac | `project.yml`: `CheatSheetiOS` has `TARGETED_DEVICE_FAMILY: "1,2"`; a separate full `CheatSheet` macOS target also exists, brief's iPhone/iPhone+iPad enum doesn't cover this app; extended to include Mac | +| `{BRAND_SOURCE}` | App's own `AccentColor` + `CheatSheetPalette` (10 swatches) + Liquid Glass material system with material fallback | `Shared/Sources/CheatSheetNote.swift`, `CheatSheetApp/Sources/ViewModifiers.swift`, `AppDesign.swift`, `AppTheme.swift` | +| `{TONE}` | Calm, precise, native: a quiet utility, not a flashy consumer app | README Product Principles: "deliberately narrow," "local-first," "No sync, collaboration, rich-document, or account system without a separate product decision"; in-app empty states use plain `ContentUnavailableView`, no marketing chrome | +| `{LOCALIZATION}` | Primary: English (`en`, `sourceLanguage` in both `.xcstrings` catalogs). Follow-on: Spanish (`es`, "neutral international Spanish" per README), verified complete by `Scripts/verify-localization.sh` and covered by `CheatSheetiOSSpanishLocaleUITests` | `CheatSheetApp/Resources/Localizable.xcstrings`, `CheatSheetWidgets/Resources/Localizable.xcstrings` | + +## Product constraint list (honest, per Phase 1.5) + +- **No accounts, no login screen, no auth flow of any kind**: nothing to capture there; also means "no account needed" is a safe, true claim. +- **No network access at all**: zero `http(s)://` calls in source (verified previously by full-repo grep). No server-dependent screens exist to capture or avoid. +- **No cross-device sync**: App Group sharing is scoped to *one platform's* app ↔ its own widget, not device-to-device. **Do not claim "same notes everywhere"**: that would be false. Safe claim: "native on Mac, iPhone, and iPad," not "synced across." +- **No paid tier / IAP / subscription**: nothing to gate or disclaim. +- **No push notifications, no deep links, no Sign in with Apple**: none exist; not a capture gap, just absent by design. +- **Starter/demo content exists and is safe to show**: `CheatSheetNote.starterNotes` ("Widget Formatting Demo," pinned, indigo; "Git Flow," cyan) are real, shipped, privacy-safe seed content, ideal for deterministic, reproducible captures instead of inventing fake note text. +- **Existing marketing screenshots** (`Docs/Images/*.png`) are mostly good reference material: `cheatsheet-iphone.png`, `cheatsheet-ipad.png`, `cheatsheet-iphone-widget.png`, `cheatsheet-widget-gallery.png` are accurate and on-brand (verified during the earlier release audit). `cheatsheet-desktop-overview.png` is broken (shows an unrelated system widget, not the app). Do not reuse it as a reference; the Mac shot must be recaptured clean. + +## Selected top features (used for ASO / message map) + +| # | Feature | File(s) | Real user benefit | Capture state | Screenshot-safe? | +| --- | --- | --- | --- | --- | --- | +| 1 | Pin one note to the Home Screen / menu bar widget | `EditorHeader.swift` (pin button), `CheatSheetWidget.swift` (`.systemSmall/.systemMedium/.systemLarge`), `CheatSheetNote.widgetDisplayNote` | The one command you always forget is one glance away, no app launch | iPhone Home Screen with the "Git Rescue"-style pinned widget visible | Yes, real, shipped feature | +| 2 | Automatic checklist & heading formatting | `DisplayLine.swift` (`parsedChecklistLine`), `ChecklistLineView.swift` | Type `- [ ]` and `#` like you already do; it renders as real checklist rows and headings, no special editor | Note editor/preview with a mix of heading + checked + unchecked lines | Yes | +| 3 | 10 colors × 4 fonts per note | `GlassPalettePicker.swift`, `FontStylePicker.swift`, `CheatSheetPalette` (10 cases), `CheatSheetFontStyle` (4 cases) | Color-code by project/language at a glance | Editor header with the palette row visible/expanded | Yes | +| 4 | 30-day Trash with restore | `TrashNoteView.swift`, `NoteTrashPolicy.retentionInterval` (30 days) | Deleted a note by accident? It's recoverable, not gone | Trash detail view showing "Deletes automatically in N days" + Restore | Yes | +| 5 | 100% private, no account, no tracking, no network | `PrivacyInfo.xcprivacy` (both targets, empty `NSPrivacyCollectedDataTypes`, `NSPrivacyTracking: false`), zero network calls in source | Notes never leave the device | Text-forward claim slide (no login screen exists to show *because there isn't one*) | Yes, verifiable claim | + +## Additional real, evidenced flows (not in the top-5 ASO set, available as alternates) + +| Feature | File(s) | Capture state | +| --- | --- | --- | +| Mac menu bar quick capture | `MenuBarQuickAccessView.swift` | macOS menu bar dropdown, quick-capture field + recent notes list | +| Native per-platform layout (iPad split view, Mac window, compact iPhone) | `ContentView.swift` (`splitRoot`/`compactRoot`) | Side-by-side device composition; real cross-surface feature, not invented | +| Search notes | `SidebarView.swift`, `CompactNoteListView` (`.searchable`) | Sidebar/list with search field active and a filtered result | +| Light & dark mode | `AppTheme.swift` (branches on `colorScheme`) | Same screen captured twice | +| Spanish localization | `Localizable.xcstrings` × 2, `CheatSheetiOSSpanishLocaleUITests` | Same shortlisted screens, es locale, for the Spanish storefront set only | + +## Localization cross-reference (found mid-task, folded in) + +`Docs/app-store-localization-es.md` (prepared by prior work on this same +release branch) already covers Spanish App Store Connect metadata and +explicitly anticipates this exact screenshot question: *"App Store Connect +lets a Spanish (Mexico) localization reuse the same screenshot images as +English if you don't want to caption them separately... Only recapture if +you later add Spanish caption overlays via the `app-store-screenshots` +skill."* Since Phase 4 of this brief burns headline/subhead text into the +final composites, that condition is now true. The final set needs a +Spanish-captioned variant once the English set is approved. Plan: +1. Ship the English final set first (Phase 6/7 of the brief, gated on the + human style-choice checkpoint below). +2. Re-run only the caption/compositing step (not a full recapture, same + underlying app screenshots, since none of the shortlisted concepts need + Spanish app UI specifically to make their point) with Spanish headlines + translated from the approved English set, informed by the tone already + established in `Docs/app-store-localization-es.md`'s description copy. +3. Note this in App Store Connect guidance: add the Spanish set under the + **Spanish (Mexico)** locale only, per that doc's own recommendation. + +## Explicitly excluded (not supported, do not invent) + +- Any onboarding claim implying sync, collaboration, or accounts. +- Ratings/download counts/awards in captions: none exist in the repo to cite. +- Apple Watch, tvOS, visionOS, no such targets in `project.yml`. diff --git a/AppStoreScreenshots/planning/message-map.md b/AppStoreScreenshots/planning/message-map.md new file mode 100644 index 0000000..b872daa --- /dev/null +++ b/AppStoreScreenshots/planning/message-map.md @@ -0,0 +1,33 @@ +# CheatSheet: Message Map + +Narrative arc: **hook → core workflow → differentiator → depth/personalization → +retained value / companion surface**, plus one trust beat. No screen leads with +Settings, an empty state, or a permission prompt. None of those are the +product's value anyway (the app has none of the latter two beyond a plain +first-run empty state). + +| # | Story role | Screen / state | Headline direction (3–7 words) | Search theme | Benefit | Source evidence | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | Hook | iPhone Home Screen, pinned widget visible | "The command you always forget" | quick notes widget | Glanceable, zero app-launch | `CheatSheetWidget.swift`, `widgetDisplayNote` | +| 2 | Core workflow | Note editor, heading + checklist mix | "Type it like you already do" | code snippet notes app | Markdown-ish syntax → real formatting, no learning curve | `DisplayLine.swift` parser | +| 3 | Differentiator | Editor header, palette + font picker | "Color-code by project" | customizable notes app | Visual organization without folders | `CheatSheetPalette` (10), `CheatSheetFontStyle` (4) | +| 4 | Depth / personalization | Trash detail, restore + countdown | "Deleted isn't gone for 30 days" | notes app with trash recovery | Forgiving, low-anxiety deletion | `NoteTrashPolicy.retentionInterval` | +| 5 | Retained value / companion surface | Split composition: Mac window + iPhone + iPad | "Native on Mac, iPhone, and iPad" | developer cheat sheet app mac iphone ipad | Same tool, no compromise, on every device you code on | `ContentView.swift` adaptive layout | +| 6 | Trust | Text-forward claim slide, app icon | "Nothing you write leaves your device" | private notes app no account | Verified: zero network calls, no account, empty privacy-collected-data declaration | `PrivacyInfo.xcprivacy`, prior audit's network-call grep | +| 7 | Bonus / Mac-set only | macOS menu bar quick capture | "Capture without breaking flow" | menu bar notes mac | Zero-friction capture mid-work | `MenuBarQuickAccessView.swift` | + +## Rules applied + +- No phrase repeats verbatim across headlines. Each screen owns one distinct + search theme; no keyword stuffing. +- Headlines 4–6 words, benefit-first, no feature-name jargon a non-technical + reader would trip on ("pinned widget note" → "the command you always forget"). +- Slide 6 (privacy) is a *verifiable* claim, not a generic "your privacy + matters" platitude, cited against the actual manifest and audit findings. +- Slide 5 says **"native on"**, never "synced across" or "same notes + everywhere", per the no-cross-device-sync constraint in + `feature-evidence.md`. +- Row order above is the default narrative order for the **iPhone** set. iPad + and Mac sets reorder to lead with whichever row is strongest on that device + (e.g. Mac set opens with row 5 or 7, not row 1, since there's no Home Screen + widget on Mac in the same sense, Mac uses the menu bar + widget gallery). diff --git a/AppStoreScreenshots/planning/shortlist.md b/AppStoreScreenshots/planning/shortlist.md new file mode 100644 index 0000000..c4aebba --- /dev/null +++ b/AppStoreScreenshots/planning/shortlist.md @@ -0,0 +1,80 @@ +# CheatSheet: Shortlist + +15 unique, evidence-verified concepts survived generation + scoring + a +targeted retry (the original 5-lens run lost one lens to a transient rate +limit, retried directly and it produced the strongest hook set in the whole +pool). That's short of the brief's 20+ target after quality filtering, +reported honestly rather than padded; see "Excluded" below for what didn't +make it and why. + +## iPhone (6 selected, 4 alternates) + +Upload order: + +| Order | # | Concept | Beat | +| --- | --- | --- | --- | +| 1 | H1 | Every Format, One Glance | HOOK, widget + core-workflow proof in one shot | +| 2 | 1 | Type It Plain | CORE WORKFLOW | +| 3 | 3 | Plain Text, Real Structure | CORE WORKFLOW / DIFFERENTIATOR | +| 4 | 5 | Color-Code by Project | DIFFERENTIATOR | +| 5 | 8 | Trash, Not Gone | DEPTH | +| 6 | 7 | Zero Network Calls | TRUST | + +**Alternates (ready-to-produce, for A/B / Product Page Optimization):** +H2 (Home Screen, systemSmall variant), H3 (widget-gallery add-flow), +H4 (in-app pin mechanism), H5 (chrome-free widget hero crop). + +**Why H1 over the original run's "iPhone Home Screen Spanish" pick:** that +concept scored highest in the first pass, but it's Spanish-locale-only and +the first pass had no non-Spanish equivalent (the lens that should have +produced one failed to rate-limiting). H1, from the retry, covers the same +beat in English with equal evidence rigor. Spanish set: see Localization +below. H1 gets a translated-caption twin, not a re-capture. + +## iPad (3 selected, smaller than 6-10, honestly) + +| Order | # | Concept | Beat | +| --- | --- | --- | --- | +| 1 | 13 | iPad Split View Checklists | CORE WORKFLOW | +| 2 | 12 | Find It Before You Lose It | CORE VALUE (highest userValue score in the whole set: 9) | +| 3 | 14 | iPad Palette and Font Picker | DIFFERENTIATOR / PERSONALIZATION | +| 4 (reused) | 7 | Zero Network Calls | TRUST, device-frame-free, reused as-is from the iPhone set | + +Gap, stated honestly: the source concept pool only had 4 iPad concepts total +(1 excluded for fabricated content). No dedicated iPad hook exists, iPad +doesn't have a Home Screen widget moment in the same visual sense an iPhone +does, so #13 (the app's own core workflow, shown natively in split view) +leads instead. + +## Mac (3 selected, smaller than 6-10, honestly) + +| Order | # | Concept | Beat | +| --- | --- | --- | --- | +| 1 | 15 | Mac Menu Bar Quick Capture | HOOK / MAC-SPECIFIC: re-verified: the ⌘N shortcut and the 5-note "Recent Notes" cap are both confirmed real in source, not speculative as first flagged | +| 2 | 16 | Font Style Menu Open | PERSONALIZATION | +| 3 (reused) | 7 | Zero Network Calls | TRUST, reused as-is | + +Gap, stated honestly: only 4 Mac concepts existed in the source pool (1 +excluded, see below). No Mac-specific depth/trash concept survived scoring. + +## Excluded (evidence or compliance concern, not shortlisted anywhere) + +| Concept | Reason | +| --- | --- | +| "Becomes a Checklist" (original hook-lens attempt) | Didn't account for the widget's 5-line cap correctly; superseded by H1, which gets this exactly right | +| "palette_note_grid" (iPad) | 8 of 10 sidebar note titles shown were fabricated, not real captured content | +| "Mac Widget Gallery" | Subhead implied the same widget syncs between iPhone and Mac, reads as a cross-device-sync claim the app doesn't support | +| "mac_sidebar_your_colors" | Same fabricated-note-title issue as palette_note_grid | +| "One Row, Up Close" | Weak clarity/userValue/searchFit, generic demo text with no command content | +| "Built for VoiceOver" | Real and accurate, but weakest searchFit/userValue in the set; kept as a possible bonus 7th iPhone slot if wanted, not in the primary 6 | + +## Localization + +Per `feature-evidence.md`'s cross-reference to `Docs/app-store-localization-es.md`: +the Spanish (Mexico) listing can reuse the English screenshots as-is *until* +captions are burned in. Since this brief burns headline/subhead text into +every final composite, the plan is: ship the English final set first (gated +on the style-choice checkpoint below), then produce a Spanish-captioned twin +of the same underlying captures, translated headlines only, no re-capture +needed for any concept except where the shot's whole point is the Spanish UI +itself (none of the current shortlist requires that). diff --git a/AppStoreScreenshots/planning/style-direction.md b/AppStoreScreenshots/planning/style-direction.md new file mode 100644 index 0000000..1b20069 --- /dev/null +++ b/AppStoreScreenshots/planning/style-direction.md @@ -0,0 +1,63 @@ +# CheatSheet: Approved Screenshot Style Direction + +**Decision:** Brand Gradient backdrop, eyebrow + headline caption, full-bleed +canvas with the device cropped at the bottom edge. Approved 2026-08-30 in two +passes: +1. Backdrop/palette choice, 3-option comp + (`cheatsheet-screenshot-style-directions.html`). +2. Caption layout: the first pass had the caption overlaid *on top of* the + device screen, overlapping real UI (correctly flagged as "crowded and not + readable"). Rebuilt against real reference examples the user supplied + (Claude, Eight Sleep, Nibble, Arc Search app-store screenshots) as a + full-bleed canvas with the caption in dedicated space above the device, + never overlapping, approved as "eyebrow + headline" + (`cheatsheet-screenshot-style-v2.html`). + +Do not re-litigate this choice in later work on this feature, extend it. +The working, reproducible implementation is +`AppStoreScreenshots/templates/compose_screenshot.py`: use it, don't +re-derive the layout by hand. + +## System + +- **Canvas**: full-bleed, exact required pixel size per device class (no + rounded corners, no border, no card chrome on the canvas itself; this + file IS the exported screenshot). +- **Backdrop**: `linear-gradient(165deg, #141923 0%, #1A2130 55%, #0D111C 100%)`, + the app's own real `AppTheme.windowGradient` dark-mode stops + (`CheatSheetApp/Sources/AppTheme.swift`), not an invented marketing color. +- **Caption zone**: top ~11% padding, left/right ~8% padding, left-aligned + (not centered, matches the reference examples). + - Eyebrow: ~3.2% of canvas width, SF Semibold, uppercase, manual letter + tracking, color `#7FA6FF`. + - Headline: ~8.8% of canvas width, SF Bold, line-height ~1.08, white, + wrapped to fit the available width, one short benefit-first line where + possible, two at most. + - No subhead, no floating chip/pill badge over the device, both read as + clutter against the reference examples; the eyebrow alone carries the + "search theme" tag role. +- **Device frame**: drawn bezel (metal-gradient edge, rounded per device + aspect, dynamic-island cutout for iPhone) at ~92% of canvas width, + positioned so roughly the bottom ~9% of the device extends past the canvas + edge, matches every one of the reference examples' "cropped, not + floating" technique. Real captured app screen inside, never re-tinted to + match the backdrop. +- **Type**: system San Francisco (`/System/Library/Fonts/SFNS.ttf`, variable + font, Bold/Semibold instances), matches the app's own actual UI + typography exactly, not an imported web font. + +## Applies to + +The full 15-concept shortlist in `shortlist.md`, across iPhone, iPad, and +Mac. iPad reuses the same phone-style bezel proportions adjusted for its own +aspect ratio; Mac needs a different frame treatment entirely (no phone +bezel: a window-chrome or bezel-free full-bleed treatment, since Mac +screenshots aren't phone-shaped) and hasn't been designed yet. + +## Localization + +Once the English final set is approved end-to-end, produce a Spanish- +captioned twin: same backdrop/device/screen captures, +eyebrow + headline text translated, per +`Docs/app-store-localization-es.md`'s recommendation to target Spanish +(Mexico) only. diff --git a/AppStoreScreenshots/planning/visual-dna.json b/AppStoreScreenshots/planning/visual-dna.json new file mode 100644 index 0000000..5124b81 --- /dev/null +++ b/AppStoreScreenshots/planning/visual-dna.json @@ -0,0 +1,66 @@ +{ + "source": "Derived from CheatSheetApp/Sources/{AppDesign,AppTheme,ViewModifiers}.swift and Shared/Sources/CheatSheetNote.swift — verified against actual constants, not estimated.", + "brandPrimary": { + "hex": "4B88FF", + "name": "CheatSheet Blue", + "evidence": "AccentColor.colorset sRGB (0.294, 0.533, 1.000) == CheatSheetPalette.blue (\"4B88FF\") exactly. This is the true brand primary, not a guess." + }, + "palette": [ + { "name": "Blue", "hex": "4B88FF" }, + { "name": "Cyan", "hex": "45C7C4" }, + { "name": "Violet", "hex": "8B7CFF" }, + { "name": "Mint", "hex": "59C979" }, + { "name": "Lime", "hex": "8FBF3F" }, + { "name": "Amber", "hex": "D99A2B" }, + { "name": "Coral", "hex": "E86F51" }, + { "name": "Rose", "hex": "E85D75" }, + { "name": "Indigo", "hex": "5D6BFF" }, + { "name": "Graphite", "hex": "5C6470" } + ], + "colorMode": "Full light/dark support, branches explicitly on colorScheme (AppTheme.swift). No forced single mode.", + "windowBackdrop": { + "dark": ["#141923", "#1A2130", "#0D111C"], + "light": ["#FFFFFF (94% opacity)", "#E8F0F7", "#DAE2E6"], + "evidence": "AppTheme.windowGradient(for:) exact RGB values, linear gradient topLeading->bottomTrailing" + }, + "materialTreatment": { + "primary": "Native Liquid Glass (.glassEffect / .buttonStyle(.glass...) / GlassEffectContainer) on macOS 26+ / iOS 26+", + "fallback": "content: .regularMaterial (dark) / .ultraThinMaterial (light) on earlier supported OS (macOS 15+/iOS 18+)", + "noteContentSurface": ".regularMaterial base + palette-tint overlay at 9-16% opacity + 1px hairline stroke at 8-14% opacity", + "evidence": "ViewModifiers.swift: LiquidGlassPanelModifier, NoteContentSurfaceModifier" + }, + "typography": { + "noteFontChoices": ["monospaced (default)", "system", "rounded", "serif"], + "uiChrome": "System semantic text styles throughout (.title, .callout, .caption, .headline) — Dynamic Type respected, no custom typeface", + "evidence": "CheatSheetFontStyle enum, Font.Design mapping" + }, + "cornerRadii": { + "editorPanel": 20, + "previewCard": 24, + "control": 14, + "onboardingPanel": 30 + }, + "spacing": { + "windowPadding": 24, + "panelPadding": 16, + "panelSpacing": 16, + "editorTextPadding": 18, + "minimumHitTarget": 44 + }, + "iconLanguage": { + "system": "SF Symbols exclusively, no custom iconography", + "recurring": ["note.text", "pin.fill / pin", "archivebox / archivebox.fill", "checkmark.circle.fill / circle", "text.page.fill", "square.and.pencil"] + }, + "motionPersonality": "Restrained — a single .snappy(duration: 0.22) selection animation on the palette swatch; no bouncy/playful motion anywhere in the codebase.", + "emotionalTone": "Calm, precise, get-in-get-out utility. Product principles explicitly state 'deliberately narrow.' No celebratory confetti, no gamification, no streaks/badges.", + "appIcon": { + "asset": "CheatSheetApp/Resources/Assets.xcassets/AppIcon.appiconset", + "marketingSize1024": "1024x1024, no alpha channel (verified with sips)" + }, + "doNotIntroduce": [ + "Unrelated neon/purple-blue generic AI-gradient backdrops not derived from AppTheme's actual gradient stops", + "Stock photography or hands-on-devices imagery — nothing like that exists in the app or its current marketing", + "Playful/bouncy motion or confetti — contradicts the app's own restrained animation choices", + "Any claim of cloud sync, collaboration, or accounts — none exist" + ] +} diff --git a/AppStoreScreenshots/templates/compose_screenshot.py b/AppStoreScreenshots/templates/compose_screenshot.py new file mode 100644 index 0000000..6079cb8 --- /dev/null +++ b/AppStoreScreenshots/templates/compose_screenshot.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Reusable App Store screenshot compositor for CheatSheet. + +Implements the approved Brand Gradient style (see +AppStoreScreenshots/planning/style-direction.md): full-bleed gradient +canvas, eyebrow + headline caption in the top zone, a real device +screenshot inside a drawn bezel, cropped at the bottom edge. + +Usage: + python3 compose_screenshot.py \ + --screenshot /path/to/real-capture.png \ + --eyebrow "AUTO-FORMATTING" \ + --headline "It's already a checklist." \ + --canvas-size 1320x2868 \ + --out /path/to/final.png +""" +import argparse +import math +import tempfile +from PIL import Image, ImageDraw, ImageFont + +GRADIENT_STOPS = [ + (0.00, (0x14, 0x19, 0x23)), + (0.55, (0x1A, 0x21, 0x30)), + (1.00, (0x0D, 0x11, 0x1C)), +] +EYEBROW_COLOR = (0x7F, 0xA6, 0xFF) +HEADLINE_COLOR = (0xFF, 0xFF, 0xFF) +BEZEL_TOP = (0x3A, 0x3F, 0x47) +BEZEL_MID = (0x1C, 0x1F, 0x24) +BEZEL_BOTTOM = (0x0B, 0x0C, 0x0E) + +SF = "/System/Library/Fonts/SFNS.ttf" + + +def sf_font(size, weight): + f = ImageFont.truetype(SF, size) + f.set_variation_by_name(weight) + return f + + +def diagonal_gradient(w, h, angle_deg, stops): + """Render a linear gradient at angle_deg (CSS-style, 0=up, 90=right) with multiple stops.""" + img = Image.new("RGB", (w, h)) + px = img.load() + theta = math.radians(angle_deg - 90) + dx, dy = math.cos(theta), math.sin(theta) + # project all four corners onto the gradient axis to find [tmin, tmax] + corners = [(0, 0), (w, 0), (0, h), (w, h)] + projs = [cx * dx + cy * dy for cx, cy in corners] + tmin, tmax = min(projs), max(projs) + + def color_at(t): + if t <= stops[0][0]: + return stops[0][1] + if t >= stops[-1][0]: + return stops[-1][1] + for i in range(len(stops) - 1): + t0, c0 = stops[i] + t1, c1 = stops[i + 1] + if t0 <= t <= t1: + f = (t - t0) / (t1 - t0) + return tuple(int(c0[k] + (c1[k] - c0[k]) * f) for k in range(3)) + + # Precompute a 1D ramp for speed, sampled along the projection range. + ramp_n = 512 + ramp = [color_at(i / (ramp_n - 1)) for i in range(ramp_n)] + for y in range(h): + for x in range(0, w, 2): # sample every 2px, fill pairs — smooth enough, faster + t = (x * dx + y * dy - tmin) / (tmax - tmin) + t = min(max(t, 0.0), 1.0) + c = ramp[int(t * (ramp_n - 1))] + px[x, y] = c + if x + 1 < w: + px[x + 1, y] = c + return img + + +def rounded_mask(size, radius): + w, h = size + mask = Image.new("L", (w, h), 0) + d = ImageDraw.Draw(mask) + d.rounded_rectangle([0, 0, w - 1, h - 1], radius=radius, fill=255) + return mask + + +def wrap_text(draw, text, font, max_width): + words = text.split() + lines, cur = [], "" + for word in words: + trial = (cur + " " + word).strip() + if draw.textlength(trial, font=font) <= max_width or not cur: + cur = trial + else: + lines.append(cur) + cur = word + if cur: + lines.append(cur) + return lines + + +def compose(screenshot_path, eyebrow, headline, canvas_w, canvas_h, out_path, + device="iphone"): + W, H = canvas_w, canvas_h + canvas = diagonal_gradient(W, H, 165, GRADIENT_STOPS) + draw = ImageDraw.Draw(canvas) + + pad_x = int(W * 0.08) + cap_top = int(H * 0.11) + + eyebrow_size = max(1, int(W * 0.032)) + headline_size = int(W * 0.088) + eyebrow_font = sf_font(eyebrow_size, "Semibold") + headline_font = sf_font(headline_size, "Bold") + + y = cap_top + if eyebrow: + eyebrow_up = eyebrow.upper() + # manual letter-spacing: draw glyph by glyph with a tracking gap + tracking = int(eyebrow_size * 0.09) + cx = pad_x + for ch in eyebrow_up: + draw.text((cx, y), ch, font=eyebrow_font, fill=EYEBROW_COLOR) + cx += draw.textlength(ch, font=eyebrow_font) + tracking + y += int(eyebrow_size * 1.3) + int(W * 0.028) + + max_text_w = W - 2 * pad_x + lines = wrap_text(draw, headline, headline_font, max_text_w) + line_h = int(headline_size * 1.08) + for line in lines: + draw.text((pad_x, y), line, font=headline_font, fill=HEADLINE_COLOR) + y += line_h + caption_bottom = y + int(W * 0.02) + + # --- device frame --- + phone_w = int(W * 0.92) + phone_h = int(phone_w * (H / W)) # same aspect as the canvas/device + phone_top = H - int(phone_h * 0.91) # crops ~9% of the phone below the canvas edge + phone_top = max(phone_top, caption_bottom) + phone_left = (W - phone_w) // 2 + + # iPads have a visibly smaller corner radius relative to device width + # than iPhones — verified against reference photos, not guessed. + # Mac windows are near-square corners in a thin bezel, no cutout. + bezel_scale = {"iphone": 0.115, "ipad": 0.06, "mac": 0.018}[device] + bezel_radius = int(phone_w * bezel_scale) + bezel = diagonal_gradient(phone_w, phone_h, 180, + [(0.0, BEZEL_TOP), (0.22, BEZEL_MID), (0.78, BEZEL_MID), (1.0, BEZEL_BOTTOM)]) + bezel_mask = rounded_mask((phone_w, phone_h), bezel_radius) + + bezel_pad = max(2, int(phone_w * 0.011)) + screen_w = phone_w - 2 * bezel_pad + screen_h = phone_h - 2 * bezel_pad + screen_radius = int(screen_w * (0.012 if device == "mac" else 0.10)) + + shot = Image.open(screenshot_path).convert("RGB") + shot_ratio = screen_w / screen_h + src_ratio = shot.width / shot.height + if src_ratio > shot_ratio: + new_h = shot.height + new_w = int(new_h * shot_ratio) + left = (shot.width - new_w) // 2 + shot_cropped = shot.crop((left, 0, left + new_w, new_h)) + else: + new_w = shot.width + new_h = int(new_w / shot_ratio) + shot_cropped = shot.crop((0, 0, new_w, min(new_h, shot.height))) + shot_resized = shot_cropped.resize((screen_w, screen_h), Image.LANCZOS) + screen_mask = rounded_mask((screen_w, screen_h), screen_radius) + + phone_img = Image.new("RGB", (phone_w, phone_h), BEZEL_MID) + phone_img.paste(bezel, (0, 0), bezel_mask) + phone_img.paste(shot_resized, (bezel_pad, bezel_pad), screen_mask) + + # Camera cutout — device-specific, since real iPhones and iPads differ + # (verified against reference photos, not guessed): iPhone Pro-class + # devices show a pill-shaped Dynamic Island; iPad Pro/Air models show a + # small circular camera dot, no pill. + pd = ImageDraw.Draw(phone_img) + if device == "iphone": + isl_w = int(phone_w * 0.27) + isl_h = int(phone_w * 0.025) + isl_x = (phone_w - isl_w) // 2 + isl_y = int(phone_h * 0.034) + pd.rounded_rectangle([isl_x, isl_y, isl_x + isl_w, isl_y + isl_h], + radius=isl_h // 2, fill=(0, 0, 0)) + elif device == "ipad": + dot_r = int(phone_w * 0.009) + dot_x = phone_w // 2 + dot_y = int(phone_h * 0.022) + pd.ellipse([dot_x - dot_r, dot_y - dot_r, dot_x + dot_r, dot_y + dot_r], fill=(0, 0, 0)) + elif device == "mac": + pass + else: + raise ValueError(f"device {device!r} has no bezel treatment yet — see style-direction.md; only 'iphone' and 'ipad' are implemented") + + phone_mask = rounded_mask((phone_w, phone_h), bezel_radius) + canvas.paste(phone_img, (phone_left, phone_top), phone_mask) + + canvas.save(out_path, "PNG") + return out_path + + +def compose_centered(screenshot_path, headline, canvas_w, canvas_h, out_path, device): + """Center a short benefit caption above a real iPhone or Mac capture.""" + W, H = canvas_w, canvas_h + if device == "iphone": + with tempfile.NamedTemporaryFile(suffix=".png") as temporary: + compose(screenshot_path, "", "", W, H, temporary.name, device) + canvas = Image.open(temporary.name).convert("RGB") + elif device == "mac": + canvas = diagonal_gradient(W, H, 165, GRADIENT_STOPS) + shot = Image.open(screenshot_path).convert("RGB") + frame_w = int(W * 0.89) + frame_h = round(frame_w * shot.height / shot.width) + max_h = int(H * 0.78) + if frame_h > max_h: + frame_h = max_h + frame_w = round(frame_h * shot.width / shot.height) + frame_x = (W - frame_w) // 2 + frame_y = H - frame_h - int(H * 0.025) + shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + sd = ImageDraw.Draw(shadow) + sd.rounded_rectangle( + (frame_x + 18, frame_y + 22, frame_x + frame_w + 18, frame_y + frame_h + 22), + radius=26, fill=(0, 0, 0, 90) + ) + from PIL import ImageFilter + canvas = Image.alpha_composite(canvas.convert("RGBA"), shadow.filter(ImageFilter.GaussianBlur(32))) + screen = shot.resize((frame_w, frame_h), Image.LANCZOS) + canvas.paste(screen, (frame_x, frame_y), rounded_mask((frame_w, frame_h), 24)) + canvas = canvas.convert("RGB") + else: + raise ValueError("Centered layout supports iPhone and Mac") + + draw = ImageDraw.Draw(canvas) + font_size = int(W * (0.071 if device == "iphone" else 0.035)) + font = sf_font(font_size, "Bold") + max_width = int(W * 0.83) + lines = [] + for paragraph in headline.split("\n"): + lines.extend(wrap_text(draw, paragraph, font, max_width)) + if len(lines) > 2: + raise ValueError(f"Caption needs more than two lines: {headline}") + line_h = int(font_size * 1.12) + top = int(H * (0.047 if device == "iphone" else 0.036)) + for index, line in enumerate(lines): + width = draw.textlength(line, font=font) + draw.text(((W - width) / 2, top + index * line_h), line, + font=font, fill=HEADLINE_COLOR) + canvas.save(out_path, "PNG") + return out_path + + +if __name__ == "__main__": + p = argparse.ArgumentParser() + p.add_argument("--screenshot", required=True) + p.add_argument("--eyebrow", default="") + p.add_argument("--headline", required=True) + p.add_argument("--canvas-size", default="1320x2868") + p.add_argument("--out", required=True) + p.add_argument("--device", default="iphone", choices=["iphone", "ipad", "mac"]) + p.add_argument("--layout", default="original", choices=["original", "centered"]) + args = p.parse_args() + w, h = (int(v) for v in args.canvas_size.split("x")) + if args.layout == "centered": + out = compose_centered(args.screenshot, args.headline, w, h, args.out, args.device) + else: + out = compose(args.screenshot, args.eyebrow, args.headline, w, h, args.out, args.device) + print("wrote", out) diff --git a/AppStoreScreenshots/templates/render_final.py b/AppStoreScreenshots/templates/render_final.py new file mode 100644 index 0000000..d425687 --- /dev/null +++ b/AppStoreScreenshots/templates/render_final.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Render the approved centered-caption iPhone and Mac screenshot sets.""" + +import argparse +import csv +from pathlib import Path + +from compose_screenshot import compose_centered + + +ROOT = Path(__file__).resolve().parents[1] +SIZES = {"iphone": (1320, 2868), "mac": (2880, 1800)} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--device", choices=SIZES) + args = parser.parse_args() + with (ROOT / "final" / "captions.tsv").open(newline="") as manifest: + for row in csv.DictReader(manifest, delimiter="\t"): + device = row["device"] + if args.device and args.device != device: + continue + source = ROOT / "raw" / row["source"] + output = ROOT / "final" / f"{device}-{row['order']}.png" + if not source.is_file(): + raise FileNotFoundError(source) + compose_centered(source, row["caption"].replace("\\n", "\n"), + *SIZES[device], output, device) + print(output) + + +if __name__ == "__main__": + main() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f70e9e..d51f8e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,11 +2,14 @@ Thanks for taking the time to improve CheatSheet. -This project is meant to stay small, native, and easy to build. The best contributions are focused, well tested, and easy to review. +This project is meant to stay small, native, and easy to build. The best contributions stay focused and come with tests that make them easy to review. ## Development Setup -1. Install Xcode 26 or later. +1. Install Xcode 26. An Xcode 27 beta is fine for development, but must + never be used for a submission build. See `Docs/os-support-policy.md`. + The app requires macOS 26 / iOS 26, so a Mac running macOS 26 or later is + needed to run the macOS app and its tests. 2. Install XcodeGen. 3. Generate the project. @@ -21,6 +24,15 @@ xcodegen generate Scripts/verify-macos.sh ``` +## Verifying the Build SDK + +Before archiving for the App Store, confirm the active toolchain ships the +SDK this branch targets. A beta SDK is rejected at upload. + +```sh +Scripts/verify-build-sdk.sh +``` + ## Running Tests ```sh @@ -40,6 +52,18 @@ xcodegen generate --spec project.yml xcodebuild test -project CheatSheet.xcodeproj -scheme CheatSheetiOS -destination "id=$(Scripts/resolve-ios-simulator.sh)" -derivedDataPath /tmp/CheatSheet-iOS-Test-DD CODE_SIGNING_ALLOWED=NO ``` +Tests run on iOS 26.5 or newer. `Scripts/resolve-ios-simulator.sh` fails rather +than selecting an older runtime; install a newer simulator via Xcode > Settings > +Components, or override the floor with `CHEATSHEET_MIN_IOS_RUNTIME`. + +## Continuous Integration + +CheatSheet builds and tests on Xcode Cloud, not GitHub Actions. Every push to +`main`, and every pull request against it, triggers a build. +Xcode Cloud regenerates the project from `project.yml` on clone +(`ci_scripts/ci_post_clone.sh`), so nothing needs to change in that script when +adding new source files. + ## Widget Signing The widgets need a shared app group. Forks should use their own Apple Developer @@ -47,22 +71,24 @@ Team ID and app groups. Update `project.yml`, the macOS and iOS entitlement file under `CheatSheetApp/` and `CheatSheetWidgets/`, and `Shared/Sources/CheatSheetNote.swift`, then run `xcodegen generate`. -## Branching (Git Flow) +## Branching -This project follows [Git Flow](https://nvie.com/posts/a-successful-git-branching-model/): +This project uses a single long-lived branch, `main`, which must stay submittable. -- `main` — always reflects the latest released version. Only release and hotfix branches merge here. -- `develop` — integration branch for the next release. Feature branches merge here. -- `feature/` — new work, branched from `develop`. Merge back into `develop` via pull request when done. -- `release/` — cut from `develop` to stabilize a release (version bumps, release notes, final QA). Merges into both `main` (tagged) and back into `develop`. -- `hotfix/` — urgent fixes branched from `main`. Merges into both `main` (tagged) and `develop`. +- `main`: the only long-lived branch. Everything merges here by pull request. +- `feature/` and `fix/`: short-lived branches cut from `main`. Open a pull request back into `main` when done. +- `feature/ios--readiness`: a long-lived OS adoption branch cut from `main` for a different SDK + contract from the shipping line. Keep it thin and merge `main` into it often. See + [`Docs/os-support-policy.md`](Docs/os-support-policy.md). +- `gh-pages`: hosts the GitHub Pages site (landing page and privacy mirror). It is not a development branch. Guidelines: -- Never commit directly to `main` or `develop`; use a pull request. -- Keep branch names lowercase and hyphenated, e.g. `feature/widget-color-picker`, `hotfix/checklist-crash`. +- Never commit directly to `main`; use a pull request. +- Keep branch names lowercase and hyphenated, e.g. `feature/widget-color-picker`, `fix/checklist-crash`. - Delete a branch after it merges. -- Tag every merge into `main` with the release version (e.g. `v1.2.0`). +- Tag every release on `main` with its version (e.g. `v1.2.0`). +- Keep `main` submittable at all times. Beta-SDK work belongs on an OS readiness branch. ## Code Style @@ -75,7 +101,7 @@ Guidelines: ## Conduct -Be kind, specific, and practical. Assume good intent, keep feedback focused on the work, and help maintain a welcoming space for people learning macOS app development. +Be kind, specific, and practical. Assume good intent and keep feedback focused on the work. Many contributors here are still learning macOS app development, so help keep the space welcoming. ## Pull Requests diff --git a/CheatSheetApp/Resources/Localizable.xcstrings b/CheatSheetApp/Resources/Localizable.xcstrings new file mode 100644 index 0000000..484f476 --- /dev/null +++ b/CheatSheetApp/Resources/Localizable.xcstrings @@ -0,0 +1,1939 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@ note color" : { + "comment" : "Accessibility label for a color swatch button. %@ is the color name, e.g. 'Blue note color'.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ note color" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color de nota %@" + } + } + } + }, + "Add" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir" + } + } + } + }, + "Add notes" : { + "comment" : "Onboarding step title.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añade notas" + } + } + } + }, + "Adds a CheatSheet icon to the menu bar for quick capture and recent notes." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adds a CheatSheet icon to the menu bar for quick capture and recent notes." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añade un icono de CheatSheet a la barra de menús para captura rápida y notas recientes." + } + } + } + }, + "Back to Notes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Back to Notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volver a las notas" + } + } + } + }, + "Cancel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + } + } + }, + "CheatSheet" : { + "comment" : "Product/brand name. Do not translate.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheatSheet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheatSheet" + } + } + } + }, + "Choose Use in Widget, then add the CheatSheet widget on supported platforms." : { + "comment" : "Onboarding step description.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose Use in Widget, then add the CheatSheet widget on supported platforms." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige Usar en el widget y, después, añade el widget de CheatSheet en las plataformas compatibles." + } + } + } + }, + "Choose note font" : { + "comment" : "Tooltip for the font style picker control.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose note font" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elegir fuente de la nota" + } + } + } + }, + "Clear" : { + "comment" : "Button that clears the quick-capture text field.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar" + } + } + } + }, + "Close" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + } + } + }, + "Create Note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create Note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crear nota" + } + } + } + }, + "Create a small checklist or cheat sheet to keep nearby." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a small checklist or cheat sheet to keep nearby." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crea una pequeña lista de tareas o chuleta para tener a mano." + } + } + } + }, + "Creates a note and opens the CheatSheet window." : { + "comment" : "Accessibility hint for the New Note button in the macOS menu bar extra.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creates a note and opens the CheatSheet window." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crea una nota y abre la ventana de CheatSheet." + } + } + } + }, + "Delete Now" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Now" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar ahora" + } + } + } + }, + "Delete this note permanently?" : { + "comment" : "Confirmation dialog title before permanently deleting a note from Trash.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete this note permanently?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar esta nota de forma permanente?" + } + } + } + }, + "Deleted notes stay here for 30 days." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deleted notes stay here for 30 days." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las notas eliminadas permanecen aquí 30 días." + } + } + } + }, + "Done" : { + "comment" : "Confirmation button that dismisses onboarding.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listo" + } + } + } + }, + "Editable note" : { + "comment" : "Status label shown above the editor when the note is not pinned to the widget.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editable note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nota editable" + } + } + } + }, + "Hide" : { + "comment" : "Dismisses the widget setup hint card shown above a newly created note.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar" + } + } + } + }, + "In Trash" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In Trash" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En la papelera" + } + } + } + }, + "Keep the coding commands you reach for every day close at hand." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keep the coding commands you reach for every day close at hand." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ten a mano los comandos de programación que usas cada día." + } + } + } + }, + "Move the selected note to Trash for 30 days" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move the selected note to Trash for 30 days" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mueve la nota seleccionada a la papelera durante 30 días" + } + } + } + }, + "Move to Trash" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move to Trash" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover a la papelera" + } + } + } + }, + "New Note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nota nueva" + } + } + } + }, + "No Note Selected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Note Selected" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ninguna nota seleccionada" + } + } + } + }, + "No Notes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin notas" + } + } + } + }, + "Not selected" : { + "comment" : "Accessibility value for an unselected color swatch.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not selected" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No seleccionada" + } + } + } + }, + "Note font" : { + "comment" : "Accessibility label for the font style picker control.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note font" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fuente de la nota" + } + } + } + }, + "Notes" : { + "comment" : "Sidebar section header listing active notes.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas" + } + } + } + }, + "OK" : { + "comment" : "Dismisses the storage-error alert on iOS without retrying.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aceptar" + } + } + } + }, + "Open CheatSheet" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open CheatSheet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir CheatSheet" + } + } + } + }, + "Paste a command, shortcut, or reminder" : { + "comment" : "Placeholder text in the menu bar quick-capture editor.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paste a command, shortcut, or reminder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pega un comando, atajo o recordatorio" + } + } + } + }, + "Pick a color or font above the editor and the widget follows that style." : { + "comment" : "Onboarding step description.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pick a color or font above the editor and the widget follows that style." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige un color o una fuente encima del editor y el widget seguirá ese estilo." + } + } + } + }, + "Pin" : { + "comment" : "Short action button that pins the onboarding preview note.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fijar" + } + } + } + }, + "Pin a note, then add the CheatSheet widget on a supported platform." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin a note, then add the CheatSheet widget on a supported platform." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fija una nota y, después, añade el widget de CheatSheet en una plataforma compatible." + } + } + } + }, + "Pin the widget note" : { + "comment" : "Onboarding step title.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin the widget note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fija la nota del widget" + } + } + } + }, + "Pin this note to keep it available in your widget." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin this note to keep it available in your widget." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fija esta nota para que esté disponible en tu widget." + } + } + } + }, + "Pinned" : { + "comment" : "Accessibility label for the pinned-note indicator icon in a note list row.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pinned" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fijada" + } + } + } + }, + "Pinned to Widget" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pinned to Widget" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fijada al widget" + } + } + } + }, + "Pinned: %@" : { + "comment" : "Tooltip naming the pinned note in the macOS menu bar extra header. %@ is the note title.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pinned: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fijada: %@" + } + } + } + }, + "Quick Capture" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quick Capture" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Captura rápida" + } + } + } + }, + "Ready for the widget" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready for the widget" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listo para el widget" + } + } + } + }, + "Recent Notes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recent Notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas recientes" + } + } + } + }, + "Restore Note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore Note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurar nota" + } + } + } + }, + "Return to active notes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Return to active notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volver a las notas activas" + } + } + } + }, + "Saves this text as a new note without opening the main window." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saves this text as a new note without opening the main window." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guarda este texto como una nota nueva sin abrir la ventana principal." + } + } + } + }, + "Search notes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar notas" + } + } + } + }, + "Search trash" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search trash" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar en la papelera" + } + } + } + }, + "Selected" : { + "comment" : "Accessibility value for a selected color swatch.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selected" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccionada" + } + } + } + }, + "Set %@ note color" : { + "comment" : "Tooltip for a color swatch button. %@ is the color name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set %@ note color" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar %@ como color de la nota" + } + } + } + }, + "Settings" : { + "comment" : "Toolbar button and sheet title that opens app settings on iOS/iPadOS.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" + } + } + } + }, + "Show Notes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar notas" + } + } + } + }, + "Show Trash" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Trash" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar papelera" + } + } + } + }, + "Show menu bar quick access" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show menu bar quick access" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar acceso rápido en la barra de menús" + } + } + } + }, + "Show onboarding next launch" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show onboarding next launch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar la introducción en el próximo inicio" + } + } + } + }, + "Show this note in the widget" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show this note in the widget" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muestra esta nota en el widget" + } + } + } + }, + "Show widget setup hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show widget setup hint" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar sugerencia de configuración del widget" + } + } + } + }, + "Shown in widget" : { + "comment" : "Status label shown above the editor when the note is currently pinned to the widget.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shown in widget" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se muestra en el widget" + } + } + } + }, + "Start Writing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Writing" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empezar a escribir" + } + } + } + }, + "This cannot be undone." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This cannot be undone." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta acción no se puede deshacer." + } + } + } + }, + "This note appears in the widget" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This note appears in the widget" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta nota aparece en el widget" + } + } + } + }, + "Title" : { + "comment" : "Placeholder for the note title text field.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Título" + } + } + } + }, + "Trash" : { + "comment" : "Navigation title and sidebar section header for the Trash view.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trash" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Papelera" + } + } + } + }, + "Trash is empty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trash is empty" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La papelera está vacía" + } + } + } + }, + "Try Again" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try Again" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reintentar" + } + } + } + }, + "Tune the look" : { + "comment" : "Onboarding step title.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tune the look" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajusta el estilo" + } + } + } + }, + "Use in Widget" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use in Widget" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar en el widget" + } + } + } + }, + "Use the plus button to create small Git, Swift, terminal, or project checklists." : { + "comment" : "Onboarding step description.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use the plus button to create small Git, Swift, terminal, or project checklists." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa el botón más para crear pequeñas listas de Git, Swift, terminal o proyectos." + } + } + } + }, + "checklist.line.complete" : { + "comment" : "Accessibility label prefix for a completed checklist line. %@ is the line's text content (user-authored, not translated).", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Complete, %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completada, %@" + } + } + } + }, + "checklist.line.incomplete" : { + "comment" : "Accessibility label prefix for an open checklist line. %@ is the line's text content (user-authored, not translated).", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Incomplete, %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pendiente, %@" + } + } + } + }, + "colorName.amber" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amber" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ámbar" + } + } + } + }, + "colorName.blue" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blue" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Azul" + } + } + } + }, + "colorName.coral" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coral" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coral" + } + } + } + }, + "colorName.cyan" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cyan" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cian" + } + } + } + }, + "colorName.graphite" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Graphite" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grafito" + } + } + } + }, + "colorName.indigo" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indigo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Índigo" + } + } + } + }, + "colorName.lime" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lime" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lima" + } + } + } + }, + "colorName.mint" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mint" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menta" + } + } + } + }, + "colorName.rose" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rose" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rosa" + } + } + } + }, + "colorName.violet" : { + "comment" : "Note color name shown in the color picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Violet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Violeta" + } + } + } + }, + "fontSize.large" : { + "comment" : "Note text size option shown in the font size picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Large" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grande" + } + } + } + }, + "fontSize.medium" : { + "comment" : "Note text size option shown in the font size picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediano" + } + } + } + }, + "fontSize.small" : { + "comment" : "Note text size option shown in the font size picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Small" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pequeño" + } + } + } + }, + "fontStyle.monospaced" : { + "comment" : "Font style name shown in the font picker. Keep reasonably short: it appears in a small toolbar button.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mono" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monoespaciada" + } + } + } + }, + "fontStyle.rounded" : { + "comment" : "Font style name shown in the font picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rounded" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redondeada" + } + } + } + }, + "fontStyle.serif" : { + "comment" : "Font style name shown in the font picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serif" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serif" + } + } + } + }, + "fontStyle.system" : { + "comment" : "Font style name shown in the font picker.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + } + } + }, + "menuBar.quickCapture.defaultTitle" : { + "comment" : "Fallback title used for a quick-capture note when no usable first line of text is found.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quick Note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nota rápida" + } + } + } + }, + "note.default.title" : { + "comment" : "Default title given to a newly created note.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Cheat Sheet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoja de referencia nueva" + } + } + } + }, + "note.previewEmpty" : { + "comment" : "Preview line shown for a note whose body has no readable text.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empty note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nota vacía" + } + } + } + }, + "note.untitled" : { + "comment" : "Displayed title for a note whose title is blank.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Untitled Note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nota sin título" + } + } + } + }, + "persistence.loadFailed.retryable" : { + "comment" : "Storage banner body when the initial load failed and a retry is offered. %@ is a localized, sentence-ending error description.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Editing is paused so your stored notes are not overwritten." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ La edición está en pausa para no sobrescribir tus notas guardadas." + } + } + } + }, + "persistence.saveFailed.suffix" : { + "comment" : "Storage banner body when a save failed. %@ is a localized, sentence-ending error description.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Recent edits are only in memory." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Los cambios recientes solo están en memoria." + } + } + } + }, + "persistence.title.loadFailed" : { + "comment" : "Storage banner title when the initial load failed.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notes could not be opened" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudieron abrir las notas" + } + } + } + }, + "persistence.title.saveFailed" : { + "comment" : "Storage banner title when a save failed.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changes are not being saved" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los cambios no se están guardando" + } + } + } + }, + "starterNote.gitFlow.body" : { + "comment" : "First-run sample note body. Lines starting with 'git ' are shell commands and must stay in English/unmodified; only the heading and the quoted commit message placeholder are natural-language text.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "# Git Flow\n- git status\n- git switch -c feature/name\n- git add .\n- git commit -m \"Describe change\"\n- git push -u origin HEAD" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "# Flujo de Git\n- git status\n- git switch -c feature/nombre\n- git add .\n- git commit -m \"Describe el cambio\"\n- git push -u origin HEAD" + } + } + } + }, + "starterNote.gitFlow.title" : { + "comment" : "First-run sample note title.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Git Flow" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flujo de Git" + } + } + } + }, + "starterNote.widgetDemo.body" : { + "comment" : "First-run sample note body shown pinned to the widget and in onboarding. Preserve the '#' heading markers and '-'/'[ ]'/'[x]' checklist markers exactly; only translate the natural-language text.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "# Widget Formatting Demo\nPlain lines show as simple reminder text.\n- Bullets become quick checklist rows.\n- [ ] Open tasks use an empty circle.\n- [x] Finished tasks use a checkmark.\n# Tips\n- Pin one note to show it in the widget.\n- Pick a color and font above the editor.\n- Short lines read best on the desktop." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "# Demostración de formato del widget\nLas líneas simples se muestran como texto de recordatorio.\n- Las viñetas se convierten en filas de lista rápidas.\n- [ ] Las tareas pendientes usan un círculo vacío.\n- [x] Las tareas terminadas usan una marca de verificación.\n# Consejos\n- Fija una nota para mostrarla en el widget.\n- Elige un color y una fuente encima del editor.\n- Las líneas cortas se leen mejor en el escritorio." + } + } + } + }, + "starterNote.widgetDemo.title" : { + "comment" : "First-run sample note title.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget Formatting Demo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demostración de formato del widget" + } + } + } + }, + "storageError.appGroupUnavailable" : { + "comment" : "Error shown when the shared App Group storage cannot be opened. %@ is a technical group identifier, not translated.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Group is unavailable: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El grupo de aplicaciones no está disponible: %@" + } + } + } + }, + "storageError.noteDecodingFailed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stored notes could not be read." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudieron leer las notas guardadas." + } + } + } + }, + "storageError.noteEncodingFailed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not encode notes for storage." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudieron codificar las notas para guardarlas." + } + } + } + }, + "storageError.repositoryUnavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note storage is unavailable." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El almacenamiento de notas no está disponible." + } + } + } + }, + "storageError.widgetSnapshotDecodingFailed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The widget note snapshot could not be read." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo leer la instantánea de la nota del widget." + } + } + } + }, + "storageError.widgetSnapshotEncodingFailed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not encode the widget note snapshot." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo codificar la instantánea de la nota del widget." + } + } + } + }, + "trash.status.manyDays" : { + "comment" : "Countdown shown for a note in Trash with more than one day remaining before permanent deletion. %lld is the day count (2 or more).", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deletes automatically in %lld days." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se elimina automáticamente en %lld días." + } + } + } + }, + "trash.status.notInTrash" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This note is not in Trash." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta nota no está en la papelera." + } + } + } + }, + "trash.status.oneDay" : { + "comment" : "Countdown shown for a note in Trash with exactly one day remaining before permanent deletion.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deletes automatically in 1 day." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se elimina automáticamente en 1 día." + } + } + } + }, + "trash.status.readyForDeletion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This note is ready for permanent deletion." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta nota está lista para eliminarse de forma permanente." + } + } + } + } + }, + "version" : "1.1" +} diff --git a/CheatSheetApp/Resources/PrivacyInfo.xcprivacy b/CheatSheetApp/Resources/PrivacyInfo.xcprivacy index 727e3d3..ba0b895 100644 --- a/CheatSheetApp/Resources/PrivacyInfo.xcprivacy +++ b/CheatSheetApp/Resources/PrivacyInfo.xcprivacy @@ -15,7 +15,20 @@ NSPrivacyCollectedDataTypes - + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyTracking NSPrivacyTrackingDomains diff --git a/CheatSheetApp/Sources/AnalyticsService.swift b/CheatSheetApp/Sources/AnalyticsService.swift new file mode 100644 index 0000000..e1d002f --- /dev/null +++ b/CheatSheetApp/Sources/AnalyticsService.swift @@ -0,0 +1,23 @@ +import Foundation +import TelemetryDeck + +/// Thin wrapper around TelemetryDeck: aggregate, anonymous usage signals only +/// (e.g. "a note was pinned"), never note content or other user data. +/// +/// Signals are no-ops until `appID` is replaced with a real App ID from +/// https://dashboard.telemetrydeck.com -- until then this sends nothing. +enum AnalyticsService { + private static let appID = "REPLACE_WITH_TELEMETRYDECK_APP_ID" + + private static let isConfigured = UUID(uuidString: appID) != nil + + static func start() { + guard isConfigured else { return } + TelemetryDeck.initialize(config: TelemetryDeck.Config(appID: appID)) + } + + static func send(_ signal: String) { + guard isConfigured else { return } + TelemetryDeck.signal(signal) + } +} diff --git a/CheatSheetApp/Sources/CheatSheetApp.swift b/CheatSheetApp/Sources/CheatSheetApp.swift index b4203ce..cce66c3 100644 --- a/CheatSheetApp/Sources/CheatSheetApp.swift +++ b/CheatSheetApp/Sources/CheatSheetApp.swift @@ -1,13 +1,42 @@ import SwiftUI +#if os(macOS) +/// Blocks app termination just long enough to flush any pending debounced +/// save. Without this, quitting right after typing can race the 400ms save +/// debounce and drop the last edit burst -- SwiftUI's `scenePhase` alone only +/// fires on backgrounding, not on quit. +@MainActor +final class CheatSheetAppDelegate: NSObject, NSApplicationDelegate { + var store: NoteStore? + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let store else { return .terminateNow } + Task { + await store.flushPendingChanges() + NSApplication.shared.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } +} +#endif + @main struct CheatSheetApp: App { @AppStorage("showMenuBarQuickAccess") private var showMenuBarQuickAccess = true @State private var store: NoteStore + #if os(macOS) + @NSApplicationDelegateAdaptor(CheatSheetAppDelegate.self) private var appDelegate + #endif init() { CheatSheetLaunchEnvironment.applyLaunchOverrides() - _store = State(wrappedValue: NoteStore(repository: CheatSheetLaunchEnvironment.makeRepository())) + AnalyticsService.start() + AnalyticsService.send("App.launched") + let store = NoteStore(repository: CheatSheetLaunchEnvironment.makeRepository()) + _store = State(wrappedValue: store) + #if os(macOS) + appDelegate.store = store + #endif } var body: some Scene { diff --git a/CheatSheetApp/Sources/CheatSheetLaunchEnvironment.swift b/CheatSheetApp/Sources/CheatSheetLaunchEnvironment.swift index c20ff64..50d58fe 100644 --- a/CheatSheetApp/Sources/CheatSheetLaunchEnvironment.swift +++ b/CheatSheetApp/Sources/CheatSheetLaunchEnvironment.swift @@ -3,18 +3,36 @@ import Foundation /// Launch-argument hooks used by UI tests. /// /// UI tests drive the real app process, so without these the suite would read -/// and write the shared App Group store — the developer's (or CI machine's) +/// and write the shared App Group store: the developer's (or CI machine's) /// actual notes. Test mode swaps in an in-memory SwiftData container so runs are /// isolated, deterministic, and repeatable. enum CheatSheetLaunchEnvironment { static let uiTestingArgument = "-cheatsheet-ui-testing" static let resetOnboardingArgument = "-cheatsheet-reset-onboarding" static let skipOnboardingArgument = "-cheatsheet-skip-onboarding" + static let showTrashArgument = "-cheatsheet-show-trash" + /// Seeds `ScreenshotDemoContent.notes` into the same isolated in-memory + /// store UI tests use, so App Store captures are reproducible without + /// manual tapping and never touch a real device's notes. + static let seedScreenshotDemoArgument = "-cheatsheet-seed-screenshot-demo" static var isUITesting: Bool { arguments.contains(uiTestingArgument) } + /// Starts the app already showing Trash. The toggle lives in the toolbar's + /// secondary-action group, which iOS may collapse into a system "More" + /// overflow whose menu rows do not resolve by accessibility identifier -- + /// so a test that only needs to read Trash content should not have to + /// drive that chrome. Gated on UI testing so it cannot fire in production. + static var isStartingInTrash: Bool { + isUITesting && arguments.contains(showTrashArgument) + } + + static var isSeedingScreenshotDemo: Bool { + arguments.contains(seedScreenshotDemoArgument) + } + private static var arguments: [String] { ProcessInfo.processInfo.arguments } @@ -39,9 +57,15 @@ enum CheatSheetLaunchEnvironment { } do { - return SwiftDataCheatSheetNoteRepository( + let repository = SwiftDataCheatSheetNoteRepository( container: try SwiftDataCheatSheetNoteRepository.makeInMemoryContainer() ) + + if isSeedingScreenshotDemo { + try repository.saveNotes(ScreenshotDemoContent.notes) + } + + return repository } catch { return UnavailableCheatSheetNoteRepository() } diff --git a/CheatSheetApp/Sources/ChecklistLineView.swift b/CheatSheetApp/Sources/ChecklistLineView.swift index ac342b5..575a814 100644 --- a/CheatSheetApp/Sources/ChecklistLineView.swift +++ b/CheatSheetApp/Sources/ChecklistLineView.swift @@ -12,7 +12,7 @@ struct ChecklistLineView: View { Image(systemName: isComplete ? "checkmark.circle.fill" : "circle") .font(.caption) .foregroundStyle(isComplete ? .green : .secondary) - .opacity(isTask ? 1 : 0.4) + .opacity(isTask ? 1 : 0.18) .accessibilityHidden(true) Text(text) @@ -27,6 +27,8 @@ struct ChecklistLineView: View { private var accessibilityLabel: String { guard isTask else { return text } - return isComplete ? "Complete, \(text)" : "Incomplete, \(text)" + return isComplete + ? String(localized: "checklist.line.complete", defaultValue: "Complete, \(text)") + : String(localized: "checklist.line.incomplete", defaultValue: "Incomplete, \(text)") } } diff --git a/CheatSheetApp/Sources/ContentView.swift b/CheatSheetApp/Sources/ContentView.swift index 6b4b2f7..384b1f3 100644 --- a/CheatSheetApp/Sources/ContentView.swift +++ b/CheatSheetApp/Sources/ContentView.swift @@ -1,8 +1,10 @@ import Observation +import StoreKit import SwiftUI struct ContentView: View { @Environment(\.scenePhase) private var scenePhase + @Environment(\.requestReview) private var requestReview #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass #endif @@ -10,32 +12,85 @@ struct ContentView: View { @AppStorage("showWidgetHints") private var showWidgetHints = true let store: NoteStore @State private var isShowingOnboarding = false - @State private var isShowingTrash = false + @State private var isShowingTrash = CheatSheetLaunchEnvironment.isStartingInTrash @State private var compactPath: [CheatSheetNote.ID] = [] + /// Bound explicitly so the sidebar can never auto-collapse: without a + /// binding here, NavigationSplitView occasionally drops to `.detailOnly` + /// on its own (e.g. right after inserting a new note) and the system + /// "Show Sidebar" command has no state to restore, leaving the sidebar + /// stuck hidden until the app relaunches. + @State private var columnVisibility: NavigationSplitViewVisibility = .all + #if os(iOS) + @State private var isShowingSettings = false + @State private var isPresentingPersistenceAlert = false + #endif var body: some View { adaptiveContent .disabled(store.isPersistenceSuspended) + #if os(macOS) .safeAreaInset(edge: .top, spacing: 0) { PersistenceStatusBanner( status: store.persistenceStatus, canRetryLoad: store.isPersistenceSuspended, - retryAction: store.retryLoad + retryAction: store.retry ) } - .tint(.blue) + #else + // A top safe-area banner would sit between the status bar and the + // nav bar, an unusual spot on iOS -- an alert is the more typical + // way this class of error surfaces here. + .alert( + persistenceAlertTitle, + isPresented: $isPresentingPersistenceAlert + ) { + Button("Try Again", action: store.retry) + if !store.isPersistenceSuspended { + Button("OK", role: .cancel) {} + } + } message: { + Text(persistenceAlertMessage) + } + .onChange(of: store.persistenceStatus) { _, newStatus in + isPresentingPersistenceAlert = newStatus.isFailure + } + #endif + .tint(Color(hex: CheatSheetPalette.blue.rawValue)) .onChange(of: scenePhase) { _, newPhase in guard newPhase != .active else { return } Task { await store.flushPendingChanges() } } + .onChange(of: store.shouldRequestReview) { _, shouldRequest in + guard shouldRequest else { return } + requestReview() + store.shouldRequestReview = false + } .task { isShowingOnboarding = !hasCompletedOnboarding } .sheet(isPresented: $isShowingOnboarding) { OnboardingView() + // Onboarding only marks itself complete when the user taps + // Done/Start Writing. Without this, a swipe-to-dismiss on + // iOS skips that and onboarding reappears every launch. + .interactiveDismissDisabled() + } + #if os(iOS) + .sheet(isPresented: $isShowingSettings) { + NavigationStack { + SettingsView() + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { isShowingSettings = false } + } + } + } } + #endif } @ViewBuilder @@ -72,6 +127,21 @@ struct ContentView: View { /// items, which left the compact iPhone layout with no visible actions. @ToolbarContentBuilder private var contentToolbar: some ToolbarContent { + #if os(iOS) + ContentToolbar( + isShowingTrash: isShowingTrash, + selectedNoteIsArchived: store.selectedNote?.isArchived ?? true, + createAction: createNote, + archiveAction: { + store.archiveSelectedNote() + compactPath.removeAll() + }, + toggleTrashAction: { + isShowingTrash ? showNotes() : showTrash() + }, + settingsAction: { isShowingSettings = true } + ) + #else ContentToolbar( isShowingTrash: isShowingTrash, selectedNoteIsArchived: store.selectedNote?.isArchived ?? true, @@ -84,10 +154,11 @@ struct ContentView: View { isShowingTrash ? showNotes() : showTrash() } ) + #endif } private var splitRoot: some View { - NavigationSplitView { + NavigationSplitView(columnVisibility: $columnVisibility) { SidebarView(store: store, isShowingTrash: isShowingTrash) .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320) } detail: { @@ -150,7 +221,7 @@ struct ContentView: View { } deleteNowAction: { store.permanentlyDeleteArchivedNote(selectedNote.id) } - .padding(AppDesign.windowPadding) + .padding(AppDesign.contentPadding) } else { TrashEmptyStateView() } @@ -160,7 +231,7 @@ struct ContentView: View { @ViewBuilder private func compactDestination(for noteID: CheatSheetNote.ID) -> some View { if isShowingTrash, let note = store.note(with: noteID), note.isArchived { - TrashNoteView(note: note) { + TrashNoteView(note: note, showBackButton: false) { showNotes() } restoreAction: { store.restoreArchivedNote(noteID) @@ -236,6 +307,31 @@ struct ContentView: View { showWidgetHints = false } } + + #if os(iOS) + private var persistenceAlertTitle: String { + store.isPersistenceSuspended + ? String(localized: "persistence.title.loadFailed", defaultValue: "Notes could not be opened") + : String(localized: "persistence.title.saveFailed", defaultValue: "Changes are not being saved") + } + + private var persistenceAlertMessage: String { + switch store.persistenceStatus { + case let .loadFailed(message): + String( + localized: "persistence.loadFailed.retryable", + defaultValue: "\(message) Editing is paused so your stored notes are not overwritten." + ) + case let .saveFailed(message): + String( + localized: "persistence.saveFailed.suffix", + defaultValue: "\(message) Recent edits are only in memory." + ) + case .ready, .saving, .saved: + "" + } + } + #endif } private struct ContentToolbar: ToolbarContent { @@ -244,6 +340,9 @@ private struct ContentToolbar: ToolbarContent { let createAction: () -> Void let archiveAction: () -> Void let toggleTrashAction: () -> Void + #if os(iOS) + let settingsAction: () -> Void + #endif var body: some ToolbarContent { #if os(iOS) @@ -272,6 +371,13 @@ private struct ContentToolbar: ToolbarContent { } .disabled(selectedNoteIsArchived) .accessibilityIdentifier("move-to-trash-button") + + Button { + settingsAction() + } label: { + Label("Settings", systemImage: "gear") + } + .accessibilityIdentifier("settings-button") } #else ToolbarItemGroup(placement: .primaryAction) { @@ -281,6 +387,7 @@ private struct ContentToolbar: ToolbarContent { Label("New Note", systemImage: "plus") } .keyboardShortcut("n") + .help("New Note") Button(role: .destructive) { archiveAction() @@ -303,16 +410,20 @@ private struct ContentToolbar: ToolbarContent { private struct TrashEmptyStateView: View { var body: some View { - VStack(spacing: 12) { + VStack(spacing: 8) { Image(systemName: "archivebox") - .font(.system(size: 36, weight: .semibold)) + .font(.largeTitle.weight(.semibold)) .foregroundStyle(.secondary) .accessibilityHidden(true) Text("Trash is empty") .font(.title3.weight(.semibold)) + + Text("Deleted notes stay here for 30 days.") + .font(.callout) } .foregroundStyle(.secondary) + .multilineTextAlignment(.center) .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/CheatSheetApp/Sources/EditorHeader.swift b/CheatSheetApp/Sources/EditorHeader.swift index 59c3644..3298f24 100644 --- a/CheatSheetApp/Sources/EditorHeader.swift +++ b/CheatSheetApp/Sources/EditorHeader.swift @@ -2,6 +2,7 @@ import SwiftUI struct EditorHeader: View { @Binding var note: CheatSheetNote + @Binding var fontSize: CheatSheetFontSize let pinAction: () -> Void var body: some View { @@ -23,7 +24,13 @@ struct EditorHeader: View { VStack(alignment: .leading, spacing: 8) { statusLabel - compactControls + GlassPalettePicker(selection: $note.tintHex) + + HStack(spacing: 8) { + FontStylePicker(selection: $note.fontStyle) + FontSizePicker(selection: $fontSize) + pinButton + } } } } @@ -41,15 +48,23 @@ struct EditorHeader: View { FontStylePicker(selection: $note.fontStyle) - Button(action: pinAction) { - Label(note.isPinned ? "Pinned to Widget" : "Use in Widget", systemImage: note.isPinned ? "pin.fill" : "pin") - } - .labelStyle(.iconOnly) - .controlSize(.small) - .glassCompatibleButtonStyle(prominent: note.isPinned) - .help(note.isPinned ? "This note appears in the widget" : "Show this note in the widget") - .accessibilityLabel(note.isPinned ? "Pinned to Widget" : "Use in Widget") - .accessibilityIdentifier("widget-pin-button") + FontSizePicker(selection: $fontSize) + + pinButton + } + } + + private var pinButton: some View { + Button(action: pinAction) { + Label(note.isPinned ? "Pinned to Widget" : "Use in Widget", systemImage: note.isPinned ? "pin.fill" : "pin") } + .labelStyle(.iconOnly) + .controlSize(.small) + .glassCompatibleButtonStyle(prominent: note.isPinned) + .help(note.isPinned ? "This note appears in the widget" : "Show this note in the widget") + .accessibilityElement(children: .ignore) + .accessibilityLabel(note.isPinned ? "Pinned to Widget" : "Use in Widget") + .accessibilityAddTraits(note.isPinned ? [.isSelected] : []) + .accessibilityIdentifier("widget-pin-button") } } diff --git a/CheatSheetApp/Sources/EditorTextPanel.swift b/CheatSheetApp/Sources/EditorTextPanel.swift index 64e583f..a626638 100644 --- a/CheatSheetApp/Sources/EditorTextPanel.swift +++ b/CheatSheetApp/Sources/EditorTextPanel.swift @@ -1,16 +1,20 @@ import SwiftUI +import WidgetKit struct EditorTextPanel: View { @Binding var note: CheatSheetNote let pinAction: () -> Void + @AppStorage(CheatSheetFontSize.storageKey, store: CheatSheetFontSize.sharedDefaults) + private var fontSize: CheatSheetFontSize = .medium + var body: some View { VStack(spacing: AppDesign.editorSectionSpacing) { - EditorHeader(note: $note, pinAction: pinAction) + EditorHeader(note: $note, fontSize: $fontSize, pinAction: pinAction) TextEditor(text: $note.body) .accessibilityIdentifier("note-body-editor") - .font(.system(.body, design: note.fontStyle.design)) + .font(.system(fontSize.bodyTextStyle, design: note.fontStyle.design)) .lineSpacing(5) .scrollContentBackground(.hidden) .padding(AppDesign.editorTextPadding) @@ -19,5 +23,8 @@ struct EditorTextPanel: View { .scrollDismissesKeyboard(.interactively) #endif } + .onChange(of: fontSize) { _, _ in + WidgetCenter.shared.reloadAllTimelines() + } } } diff --git a/CheatSheetApp/Sources/FontSizePicker.swift b/CheatSheetApp/Sources/FontSizePicker.swift new file mode 100644 index 0000000..a529a30 --- /dev/null +++ b/CheatSheetApp/Sources/FontSizePicker.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct FontSizePicker: View { + @Binding var selection: CheatSheetFontSize + + var body: some View { + Menu { + ForEach(CheatSheetFontSize.allCases) { size in + Button { + selection = size + } label: { + Label { + Text(size.displayName) + } icon: { + Image(systemName: selection == size ? "checkmark" : "textformat.size") + } + } + } + } label: { + Label(selection.displayName, systemImage: "textformat.size") + } + .menuStyle(.button) + .controlSize(.small) + .help("Choose note text size") + .accessibilityLabel("Note text size") + .accessibilityValue(selection.displayName) + .accessibilityIdentifier("font-size-picker") + } +} diff --git a/CheatSheetApp/Sources/GlassPaletteSwatchButton.swift b/CheatSheetApp/Sources/GlassPaletteSwatchButton.swift index fb514a5..93a9765 100644 --- a/CheatSheetApp/Sources/GlassPaletteSwatchButton.swift +++ b/CheatSheetApp/Sources/GlassPaletteSwatchButton.swift @@ -23,7 +23,11 @@ struct GlassPaletteSwatchButton: View { .buttonStyle(.plain) .frame(width: hitTargetSize, height: hitTargetSize) .contentShape(Rectangle()) - .accessibilityLabel("\(swatch.displayName) note color") + // Declares this as one opaque accessible element rather than letting + // the circle/checkmark overlay expose themselves as separate children, + // which otherwise interferes with the explicit label/value below. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("\(swatch.displayName) note color")) .accessibilityIdentifier("palette-\(swatch.rawValue.lowercased())") .accessibilityValue(isSelected ? "Selected" : "Not selected") .accessibilityAddTraits(isSelected ? [.isSelected] : []) diff --git a/CheatSheetApp/Sources/LiquidGlassGroup.swift b/CheatSheetApp/Sources/LiquidGlassGroup.swift deleted file mode 100644 index 4bcde20..0000000 --- a/CheatSheetApp/Sources/LiquidGlassGroup.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI - -struct LiquidGlassGroup: View { - let spacing: CGFloat - @ViewBuilder var content: Content - - var body: some View { - #if os(macOS) - if #available(macOS 26.0, *) { - GlassEffectContainer(spacing: spacing) { - content - } - } else { - content - } - #elseif os(iOS) - if #available(iOS 26.0, *) { - GlassEffectContainer(spacing: spacing) { - content - } - } else { - content - } - #else - content - #endif - } -} diff --git a/CheatSheetApp/Sources/MenuBarQuickAccessView.swift b/CheatSheetApp/Sources/MenuBarQuickAccessView.swift index 0190226..89bf370 100644 --- a/CheatSheetApp/Sources/MenuBarQuickAccessView.swift +++ b/CheatSheetApp/Sources/MenuBarQuickAccessView.swift @@ -39,8 +39,7 @@ struct MenuBarQuickAccessView: View { quickCapture Button { - let noteID = store.addNote() - store.selectedNoteID = noteID + store.addNote() openMainWindow() } label: { Label("New Note", systemImage: "plus") @@ -185,7 +184,7 @@ struct MenuBarQuickAccessView: View { } private static func title(for text: String) -> String { - let fallback = "Quick Note" + let fallback = String(localized: "menuBar.quickCapture.defaultTitle", defaultValue: "Quick Note") guard let firstLine = text .split(whereSeparator: \.isNewline) .map({ String($0).trimmingCharacters(in: .whitespacesAndNewlines) }) @@ -234,7 +233,7 @@ private struct MenuBarNoteRow: View { } } - Text(note.body.notePreviewLine) + Text(note.previewLine) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/CheatSheetApp/Sources/NoteStore.swift b/CheatSheetApp/Sources/NoteStore.swift index 3006530..91d138f 100644 --- a/CheatSheetApp/Sources/NoteStore.swift +++ b/CheatSheetApp/Sources/NoteStore.swift @@ -31,6 +31,7 @@ final class NoteStore { } } var searchText = "" + var shouldRequestReview = false private(set) var persistenceStatus: PersistenceStatus = .ready private(set) var activeNotes: [CheatSheetNote] = [] private(set) var archivedNotes: [CheatSheetNote] = [] @@ -153,10 +154,18 @@ final class NoteStore { } } + nonisolated static var defaultNoteTitle: String { + String(localized: "note.default.title", defaultValue: "New Cheat Sheet") + } + + private nonisolated static var defaultNoteBody: String { + "# \(defaultNoteTitle)\n- " + } + @discardableResult func addNote( - title: String = "New Cheat Sheet", - body: String = "# New Cheat Sheet\n- ", + title: String = NoteStore.defaultNoteTitle, + body: String = NoteStore.defaultNoteBody, tintHex: String = CheatSheetPalette.blue.rawValue ) -> CheatSheetNote.ID { let note = CheatSheetNote( @@ -221,6 +230,7 @@ final class NoteStore { } func setPinned(_ noteID: CheatSheetNote.ID) { + let isNewPin = notes.first(where: { $0.id == noteID })?.isPinned == false notes = notes.map { note in var copy = note copy.isPinned = !note.isArchived && note.id == noteID @@ -229,6 +239,27 @@ final class NoteStore { } selectedNoteID = noteID persistImmediately() + + if isNewPin { + AnalyticsService.send("Note.pinned") + recordPinForReviewPrompt() + } + } + + /// Requests a review prompt once the user has pinned `reviewPromptPinThreshold` + /// distinct notes over the app's lifetime -- a proxy for having engaged with + /// the widget feature, not just opened the app. Fires at most once per install. + private static let reviewPromptPinThreshold = 5 + + private func recordPinForReviewPrompt() { + let defaults = UserDefaults.standard + let pinCount = defaults.integer(forKey: "pinNoteCount") + 1 + defaults.set(pinCount, forKey: "pinNoteCount") + + guard pinCount >= Self.reviewPromptPinThreshold, + !defaults.bool(forKey: "hasRequestedReviewAfterPinning") else { return } + defaults.set(true, forKey: "hasRequestedReviewAfterPinning") + shouldRequestReview = true } func flushPendingChanges() async { @@ -251,11 +282,26 @@ final class NoteStore { lastReloadedWidgetNote = Self.widgetNote(in: notes) isPersistenceSuspended = false persistenceStatus = .ready + + if notes != storedNotes { + persistImmediately() + } } catch { handleLoadFailure(error) } } + /// Retries whichever kind of failure is currently showing: re-reads the + /// store after a load failure, or re-attempts writing the in-memory notes + /// after a save failure. + func retry() { + if isPersistenceSuspended { + retryLoad() + } else { + persistImmediately() + } + } + private func schedulePersist() { guard !isPersistenceSuspended else { return } let snapshot = snapshotForPersistence() diff --git a/CheatSheetApp/Sources/OnboardingStepRow.swift b/CheatSheetApp/Sources/OnboardingStepRow.swift index 5dbcd96..ad54744 100644 --- a/CheatSheetApp/Sources/OnboardingStepRow.swift +++ b/CheatSheetApp/Sources/OnboardingStepRow.swift @@ -1,24 +1,27 @@ import SwiftUI +/// A short caption row for onboarding: a colored dot (one of the app's own +/// note colors) plus title/description. Deliberately not an icon-in-a-circle +/// -- the hero above already shows a real note, so these are captions +/// explaining it, not a second set of illustrations. struct OnboardingStepRow: View { - let symbol: String - let title: String - let description: String + let dotColor: Color + let title: LocalizedStringKey + let description: LocalizedStringKey var body: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: symbol) - .font(.headline) - .frame(width: 32, height: 32) - .background(.secondary.opacity(0.1), in: Circle()) + HStack(alignment: .firstTextBaseline, spacing: 10) { + Circle() + .fill(dotColor) + .frame(width: 8, height: 8) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { Text(title) - .font(.headline) + .font(.subheadline.weight(.semibold)) Text(description) - .font(.callout) + .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } diff --git a/CheatSheetApp/Sources/OnboardingView.swift b/CheatSheetApp/Sources/OnboardingView.swift index 8bac595..133cc57 100644 --- a/CheatSheetApp/Sources/OnboardingView.swift +++ b/CheatSheetApp/Sources/OnboardingView.swift @@ -42,16 +42,15 @@ struct OnboardingView: View { VStack(alignment: .leading, spacing: onboardingSpacing) { header(showCloseButton: showCloseButton) - stepGroup + StickyNotePreview(note: CheatSheetNote.starterNotes[0]) + .frame(maxWidth: onboardingPreviewMaxWidth) + .frame(maxWidth: .infinity, alignment: .center) - VStack(spacing: onboardingPreviewSpacing) { - OnboardingStartButton(action: finish) - .frame(maxWidth: 260) + stepGroup - StickyNotePreview(note: CheatSheetNote.starterNotes[0]) - .frame(maxWidth: onboardingPreviewMaxWidth) - } - .frame(maxWidth: .infinity) + OnboardingStartButton(action: finish) + .frame(maxWidth: 260) + .frame(maxWidth: .infinity, alignment: .center) } } @@ -80,26 +79,24 @@ struct OnboardingView: View { } private var stepGroup: some View { - LiquidGlassGroup(spacing: 12) { - VStack(spacing: 12) { - OnboardingStepRow( - symbol: "plus", - title: "Add notes", - description: "Use the plus button to create small Git, Swift, terminal, or project checklists." - ) - - OnboardingStepRow( - symbol: "paintpalette", - title: "Tune the look", - description: "Pick a color or font above the editor and the widget follows that style." - ) - - OnboardingStepRow( - symbol: "pin.fill", - title: "Pin the widget note", - description: "Choose Use in Widget, then add the CheatSheet widget on supported platforms." - ) - } + VStack(alignment: .leading, spacing: 12) { + OnboardingStepRow( + dotColor: Color(hex: CheatSheetPalette.blue.rawValue), + title: "Add notes", + description: "Use the plus button to create small Git, Swift, terminal, or project checklists." + ) + + OnboardingStepRow( + dotColor: Color(hex: CheatSheetPalette.mint.rawValue), + title: "Tune the look", + description: "Pick a color or font above the editor and the widget follows that style." + ) + + OnboardingStepRow( + dotColor: Color(hex: CheatSheetPalette.coral.rawValue), + title: "Pin the widget note", + description: "Choose Use in Widget, then add the CheatSheet widget on supported platforms." + ) } } diff --git a/CheatSheetApp/Sources/PersistenceStatusBanner.swift b/CheatSheetApp/Sources/PersistenceStatusBanner.swift index 02f5530..d436475 100644 --- a/CheatSheetApp/Sources/PersistenceStatusBanner.swift +++ b/CheatSheetApp/Sources/PersistenceStatusBanner.swift @@ -27,9 +27,10 @@ struct PersistenceStatusBanner: View { Spacer(minLength: 8) - if canRetryLoad { + if status.isFailure { Button("Try Again", action: retryAction) .controlSize(.small) + .glassCompatibleButtonStyle() .accessibilityIdentifier("persistence-retry-button") } } @@ -46,17 +47,25 @@ struct PersistenceStatusBanner: View { } private var title: String { - canRetryLoad ? "Notes could not be opened" : "Changes are not being saved" + canRetryLoad + ? String(localized: "persistence.title.loadFailed", defaultValue: "Notes could not be opened") + : String(localized: "persistence.title.saveFailed", defaultValue: "Changes are not being saved") } private var failureMessage: String? { switch status { case let .loadFailed(message): canRetryLoad - ? "\(message) Editing is paused so your stored notes are not overwritten." + ? String( + localized: "persistence.loadFailed.retryable", + defaultValue: "\(message) Editing is paused so your stored notes are not overwritten." + ) : message case let .saveFailed(message): - "\(message) Recent edits are only in memory." + String( + localized: "persistence.saveFailed.suffix", + defaultValue: "\(message) Recent edits are only in memory." + ) case .ready, .saving, .saved: nil } diff --git a/CheatSheetApp/Sources/ScreenshotDemoContent.swift b/CheatSheetApp/Sources/ScreenshotDemoContent.swift new file mode 100644 index 0000000..a712bed --- /dev/null +++ b/CheatSheetApp/Sources/ScreenshotDemoContent.swift @@ -0,0 +1,125 @@ +import Foundation + +/// Deterministic demo content for App Store screenshot capture only. +/// +/// Real starter content (`CheatSheetNote.starterNotes`) stays small and +/// first-run-appropriate. Screenshot captures need a fuller, varied sidebar +/// to show organization and color variety honestly. This is real, +/// representative developer reference content (generic git/docker/vim/HTTP +/// facts, nothing app-specific or fabricated), never used outside a launch +/// gated by `-cheatsheet-seed-screenshot-demo`. +enum ScreenshotDemoContent { + static let notes: [CheatSheetNote] = [ + CheatSheetNote( + title: "Git Rescue", + body: """ + # Git Rescue + - git switch -c fix/name + - git commit --amend --no-edit + - git rebase -i main + - git reset --soft HEAD~1 + - git stash -u + - git log --oneline --graph + - git push --force-with-lease + # Before you push + - [x] Pull, then rebase + - [ ] Never force-push main + """, + tintHex: CheatSheetPalette.indigo.rawValue, + isPinned: true + ), + CheatSheetNote( + title: "Ship Checklist", + body: """ + # Ship Checklist + - [ ] Bump the build number + - [ ] Update release notes + - [ ] Run the full test suite + - [ ] Archive in Release configuration + - [ ] Verify entitlements match project.yml + - [ ] Tag the release commit + """, + tintHex: CheatSheetPalette.mint.rawValue + ), + CheatSheetNote( + title: "Swift Concurrency", + body: """ + # Swift Concurrency + - @MainActor on anything UI + - actor for shared mutable state + - Task { } for fire-and-forget work + - Task.detached only to escape actor context + - await MainActor.run { } from background work + - Cancel long tasks in deinit + """, + tintHex: CheatSheetPalette.violet.rawValue + ), + CheatSheetNote( + title: "Docker Cleanup", + body: """ + # Docker Cleanup + - docker ps -a + - docker system prune -a + - docker volume prune + - docker image prune -a + - docker compose down -v + - docker stats + """, + tintHex: CheatSheetPalette.amber.rawValue + ), + CheatSheetNote( + title: "Vim Motions", + body: """ + # Vim Motions + - ciw change inside word + - dd delete line + - yy yank line + - p paste after + - gg top of file + - G bottom of file + - :%s/old/new/g replace all + """, + tintHex: CheatSheetPalette.coral.rawValue + ), + CheatSheetNote( + title: "zsh + Homebrew", + body: """ + # zsh + Homebrew + - brew bundle dump --force + - brew bundle install + - brew doctor + - brew cleanup + - brew list --formula + - source ~/.zshrc + """, + tintHex: CheatSheetPalette.cyan.rawValue + ), + CheatSheetNote( + title: "Xcode Shortcuts", + body: """ + # Xcode Shortcuts + - Cmd-Shift-O open quickly + - Cmd-Shift-J reveal in navigator + - Cmd-Ctrl-Up switch header/source + - Cmd-/ toggle comment + - Cmd-Ctrl-E edit all in scope + - Cmd-B build + """, + tintHex: CheatSheetPalette.rose.rawValue + ), + CheatSheetNote( + title: "HTTP Status Codes", + body: """ + # HTTP Status Codes + - 200 OK + - 201 created, 204 no content + - 301 moved permanently + - 400 bad request + - 401 unauthorized + - 404 not found + - 500 internal server error + """, + tintHex: CheatSheetPalette.lime.rawValue + ) + ] +} diff --git a/CheatSheetApp/Sources/SettingsView.swift b/CheatSheetApp/Sources/SettingsView.swift index 56d3b39..8bde64c 100644 --- a/CheatSheetApp/Sources/SettingsView.swift +++ b/CheatSheetApp/Sources/SettingsView.swift @@ -10,27 +10,29 @@ struct SettingsView: View { var body: some View { Form { #if os(macOS) - Toggle("Show menu bar quick access", isOn: $showMenuBarQuickAccess) - Text("Adds a CheatSheet icon to the menu bar for quick capture and recent notes.") - .font(.caption) - .foregroundStyle(.secondary) - - Divider() + Section { + Toggle("Show menu bar quick access", isOn: $showMenuBarQuickAccess) + } footer: { + Text("Adds a CheatSheet icon to the menu bar for quick capture and recent notes.") + } #endif - Toggle("Show widget setup hint", isOn: $showWidgetHints) - Text("Pin a note, then add the CheatSheet widget on a supported platform.") - .font(.caption) - .foregroundStyle(.secondary) - - Divider() + Section { + Toggle("Show widget setup hint", isOn: $showWidgetHints) + } footer: { + Text("Pin a note, then add the CheatSheet widget on a supported platform.") + } - Button("Show onboarding next launch") { - hasCompletedOnboarding = false + Section { + Button("Show onboarding next launch") { + hasCompletedOnboarding = false + } } } .formStyle(.grouped) + #if os(macOS) .scenePadding() .frame(minWidth: 320, idealWidth: 360, maxWidth: 440) + #endif } } diff --git a/CheatSheetApp/Sources/SidebarNoteRow.swift b/CheatSheetApp/Sources/SidebarNoteRow.swift index d478347..1f2508c 100644 --- a/CheatSheetApp/Sources/SidebarNoteRow.swift +++ b/CheatSheetApp/Sources/SidebarNoteRow.swift @@ -29,7 +29,7 @@ struct SidebarNoteRow: View { } } - Text(note.isArchived ? note.trashStatusText : note.body.notePreviewLine) + Text(note.isArchived ? note.trashStatusText : note.previewLine) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/CheatSheetApp/Sources/SidebarView.swift b/CheatSheetApp/Sources/SidebarView.swift index bccd245..bfd06c4 100644 --- a/CheatSheetApp/Sources/SidebarView.swift +++ b/CheatSheetApp/Sources/SidebarView.swift @@ -37,5 +37,6 @@ struct SidebarView: View { prompt: isShowingTrash ? "Search trash" : "Search notes" ) .listStyle(.sidebar) + .accessibilityIdentifier("note-list") } } diff --git a/CheatSheetApp/Sources/TrashNoteView.swift b/CheatSheetApp/Sources/TrashNoteView.swift index fdd8a91..d91a7e9 100644 --- a/CheatSheetApp/Sources/TrashNoteView.swift +++ b/CheatSheetApp/Sources/TrashNoteView.swift @@ -2,6 +2,11 @@ import SwiftUI struct TrashNoteView: View { let note: CheatSheetNote + /// Hidden when a system back chevron already exists (the iPhone compact + /// layout pushes this view onto a `NavigationStack`). Shown by default, + /// since the macOS and iPad split-view layouts present this view directly + /// with no navigation chrome of their own to return to Notes with. + var showBackButton: Bool = true let backAction: () -> Void let restoreAction: () -> Void let deleteNowAction: () -> Void @@ -9,10 +14,12 @@ struct TrashNoteView: View { var body: some View { VStack(alignment: .leading, spacing: AppDesign.panelSpacing) { - Button("Back to Notes", systemImage: "chevron.left", action: backAction) - .controlSize(.small) - .glassCompatibleButtonStyle() - .help("Return to active notes") + if showBackButton { + Button("Back to Notes", systemImage: "chevron.left", action: backAction) + .controlSize(.small) + .glassCompatibleButtonStyle() + .help("Return to active notes") + } VStack(alignment: .leading, spacing: AppDesign.panelSpacing) { Label("In Trash", systemImage: "archivebox") @@ -30,7 +37,7 @@ struct TrashNoteView: View { Text(note.displayTitle) .font(.headline) - Text(note.body.notePreviewLine) + Text(note.previewLine) .font(.callout) .foregroundStyle(.secondary) .lineLimit(3) diff --git a/CheatSheetApp/Sources/ViewModifiers.swift b/CheatSheetApp/Sources/ViewModifiers.swift index 5b4631c..9dfc487 100644 --- a/CheatSheetApp/Sources/ViewModifiers.swift +++ b/CheatSheetApp/Sources/ViewModifiers.swift @@ -8,29 +8,11 @@ extension View { @ViewBuilder func glassCompatibleButtonStyle(prominent: Bool = false) -> some View { - #if os(macOS) - if #available(macOS 26.0, *) { - if prominent { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.glass) - } - } else if prominent { - self.buttonStyle(.borderedProminent) + #if os(macOS) || os(iOS) + if prominent { + self.buttonStyle(.glassProminent) } else { - self.buttonStyle(.bordered) - } - #elseif os(iOS) - if #available(iOS 26.0, *) { - if prominent { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.glass) - } - } else if prominent { - self.buttonStyle(.borderedProminent) - } else { - self.buttonStyle(.bordered) + self.buttonStyle(.glass) } #else if prominent { @@ -74,31 +56,11 @@ private struct LiquidGlassPanelModifier: ViewModifier { let interactive: Bool func body(content: Content) -> some View { - #if os(macOS) - if #available(macOS 26.0, *) { - if interactive { - content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)).interactive(), in: .rect(cornerRadius: cornerRadius)) - } else { - content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)), in: .rect(cornerRadius: cornerRadius)) - } + #if os(macOS) || os(iOS) + if interactive { + content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)).interactive(), in: .rect(cornerRadius: cornerRadius)) } else { - content.background( - AppTheme.glassFallbackFill(for: colorScheme), - in: RoundedRectangle(cornerRadius: cornerRadius) - ) - } - #elseif os(iOS) - if #available(iOS 26.0, *) { - if interactive { - content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)).interactive(), in: .rect(cornerRadius: cornerRadius)) - } else { - content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)), in: .rect(cornerRadius: cornerRadius)) - } - } else { - content.background( - AppTheme.glassFallbackFill(for: colorScheme), - in: RoundedRectangle(cornerRadius: cornerRadius) - ) + content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)), in: .rect(cornerRadius: cornerRadius)) } #else content.background( diff --git a/CheatSheetTests/Sources/DisplayLineTests.swift b/CheatSheetTests/Sources/DisplayLineTests.swift index d5ca934..0044dd2 100644 --- a/CheatSheetTests/Sources/DisplayLineTests.swift +++ b/CheatSheetTests/Sources/DisplayLineTests.swift @@ -10,6 +10,42 @@ struct DisplayLineTests { #expect(parsed.isComplete == false) } + @Test func headingKeepsTrailingHashInLanguageNames() { + // Regression: trimmingCharacters(in:) trimmed both ends, so "# C#" + // rendered as "C" in both the editor and the widget. + #expect("# C#".parsedChecklistLine.text == "C#") + #expect("# F#".parsedChecklistLine.text == "F#") + #expect("# C# vs F#".parsedChecklistLine.text == "C# vs F#") + } + + @Test func headingStripsOnlyLeadingHashes() { + let parsed = "## Objective-C".parsedChecklistLine + + #expect(parsed.text == "Objective-C") + #expect(parsed.isHeading) + #expect(parsed.isTask == false) + } + + @Test func previewSkipsAHeadingThatOnlyRepeatsTheTitle() { + // Both shipped starter notes open with "# "; without + // this the row rendered the same string as title and subtitle. + let note = CheatSheetNote(title: "Git Flow", body: "# Git Flow\n- git status") + + #expect(note.previewLine == "git status") + } + + @Test func previewKeepsAHeadingThatCarriesNewInformation() { + let note = CheatSheetNote(title: "Git Flow", body: "# Before you push\n- git status") + + #expect(note.previewLine == "Before you push") + } + + @Test func previewSkipsOnlyTheFirstTitleHeading() { + let note = CheatSheetNote(title: "Git Flow", body: "# Git Flow\n# Git Flow\n- git status") + + #expect(note.previewLine == "Git Flow") + } + @Test func parsesCompleteTaskLine() { let parsed = "- [x] Run build".parsedChecklistLine diff --git a/CheatSheetTests/Sources/LocalizationTests.swift b/CheatSheetTests/Sources/LocalizationTests.swift new file mode 100644 index 0000000..afef4c1 --- /dev/null +++ b/CheatSheetTests/Sources/LocalizationTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing + +/// Exercises every localized call site's Swift-side logic in process. This unit +/// test target does not embed `Resources/` (see `project.yml`), so `String(localized:)` +/// always resolves to its English `defaultValue` fallback here -- exactly the path +/// a real install takes if a translation is ever missing from the bundle. These +/// tests lock in that the fallback text is correct, non-empty, and that singular/ +/// plural and interpolated call sites don't crash. Catalog-level checks (every key +/// translated into Spanish, matching %@ / %lld placeholders between languages, no +/// stable key leaking into the UI untranslated) live in `Scripts/verify-localization.sh`, +/// which runs against the `.xcstrings` files directly and is wired into CI -- +/// unlike this test target, it isn't sandboxed away from the checkout on disk. +struct LocalizationTests { + @Test func noteDisplayTitleFallsBackToLocalizedPlaceholderWhenBlank() { + let blank = CheatSheetNote(title: " ", body: "- test") + let whitespaceOnly = CheatSheetNote(title: "\n\t", body: "- test") + let real = CheatSheetNote(title: "Deploy Steps", body: "- test") + + #expect(blank.displayTitle == "Untitled Note") + #expect(whitespaceOnly.displayTitle == "Untitled Note") + #expect(real.displayTitle == "Deploy Steps") + } + + @Test func notePreviewLineFallsBackToLocalizedPlaceholderWhenEmpty() { + #expect("".notePreviewLine == "Empty note") + #expect("\n \n".notePreviewLine == "Empty note") + #expect("- Ship it".notePreviewLine == "Ship it") + } + + @Test func trashStatusTextSingularAndPluralBranchesAreDistinctAndNonCrashing() { + let archivedAt = Date(timeIntervalSince1970: 1_000) + let note = CheatSheetNote(title: "Archived", body: "- demo", archivedAt: archivedAt) + + let thirtyDays = note.trashStatusText(referenceDate: archivedAt) + let oneDay = note.trashStatusText(referenceDate: archivedAt.addingTimeInterval(29 * NoteTrashPolicy.dayInterval + 1)) + let readyForDeletion = note.trashStatusText(referenceDate: archivedAt.addingTimeInterval(NoteTrashPolicy.retentionInterval)) + let notArchived = CheatSheetNote(title: "Active", body: "- demo").trashStatusText + + #expect(thirtyDays == "Deletes automatically in 30 days.") + #expect(oneDay == "Deletes automatically in 1 day.") + #expect(oneDay != thirtyDays, "Singular and plural day counts must render distinct text.") + #expect(readyForDeletion == "This note is ready for permanent deletion.") + #expect(notArchived == "This note is not in Trash.") + } + + @Test func everyStorageErrorHasANonEmptyLocalizedDescription() { + let identifier = "group.com.example.test" + let errors: [CheatSheetStorageError] = [ + .appGroupUnavailable(identifier), + .repositoryUnavailable, + .noteEncodingFailed, + .noteDecodingFailed, + .widgetSnapshotDecodingFailed, + .widgetSnapshotEncodingFailed + ] + + for error in errors { + #expect(error.errorDescription?.isEmpty == false, "\(error) must have a non-empty localized description") + } + + #expect(CheatSheetStorageError.appGroupUnavailable(identifier).errorDescription?.contains(identifier) == true) + } + + @Test func everyColorNameIsNonEmptyAndDistinct() { + let names = CheatSheetPalette.allCases.map(\.displayName) + + #expect(names.allSatisfy { !$0.isEmpty }) + #expect(Set(names).count == names.count, "Color names must be distinct so the picker isn't ambiguous.") + } + + @Test func everyFontStyleNameIsNonEmptyAndDistinct() { + let names = CheatSheetFontStyle.allCases.map(\.displayName) + + #expect(names.allSatisfy { !$0.isEmpty }) + #expect(Set(names).count == names.count, "Font style names must be distinct so the picker isn't ambiguous.") + } + + @Test func starterNotesHaveNonEmptyTitlesAndBodies() { + for note in CheatSheetNote.starterNotes { + #expect(!note.title.isEmpty) + #expect(!note.body.isEmpty) + #expect(note.displayLines.isEmpty == false, "Starter note body should still parse into displayable lines.") + } + } + + @Test func defaultNoteTitleIsStableAndNonEmpty() { + #expect(NoteStore.defaultNoteTitle == "New Cheat Sheet") + } + + @Test func screenshotDemoContentIsWellFormed() { + let notes = ScreenshotDemoContent.notes + + #expect(notes.count == 8) + #expect(notes.filter(\.isPinned).count == 1, "Exactly one demo note should be pinned so the widget capture is unambiguous.") + #expect(Set(notes.map(\.title)).count == notes.count, "Demo note titles must be distinct.") + #expect(Set(notes.map(\.tintHex)).count == notes.count, "Demo notes should each use a distinct palette color to show organization.") + + for note in notes { + #expect(!note.title.isEmpty) + #expect(!note.body.isEmpty) + #expect(note.displayLines.isEmpty == false, "Demo note body should parse into displayable lines.") + } + } +} diff --git a/CheatSheetTests/Sources/NoteRepositoryTests.swift b/CheatSheetTests/Sources/NoteRepositoryTests.swift index 034a273..038dd56 100644 --- a/CheatSheetTests/Sources/NoteRepositoryTests.swift +++ b/CheatSheetTests/Sources/NoteRepositoryTests.swift @@ -382,7 +382,12 @@ private struct EmptyLegacyRepository: CheatSheetNoteRepository { private final class SpyLegacyRepository: CheatSheetNoteRepository, @unchecked Sendable { let notes: [CheatSheetNote] - private(set) var savedNotes: [[CheatSheetNote]] = [] + private let lock = NSLock() + private var _savedNotes: [[CheatSheetNote]] = [] + + var savedNotes: [[CheatSheetNote]] { + lock.withLock { _savedNotes } + } init(notes: [CheatSheetNote]) { self.notes = notes @@ -393,6 +398,6 @@ private final class SpyLegacyRepository: CheatSheetNoteRepository, @unchecked Se } func saveNotes(_ notes: [CheatSheetNote]) throws { - savedNotes.append(notes) + lock.withLock { _savedNotes.append(notes) } } } diff --git a/CheatSheetUITests/Sources/CheatSheetiOSSpanishLocaleUITests.swift b/CheatSheetUITests/Sources/CheatSheetiOSSpanishLocaleUITests.swift new file mode 100644 index 0000000..f6bd307 --- /dev/null +++ b/CheatSheetUITests/Sources/CheatSheetiOSSpanishLocaleUITests.swift @@ -0,0 +1,100 @@ +import XCTest + +/// Runs a subset of the standard UI flow with the device language forced to +/// Spanish, so a regression that leaves a raw String Catalog key on screen (or +/// crashes while resolving a Spanish plural/format string) is caught in CI. The +/// main `CheatSheetiOSUITests` suite intentionally asserts on rendered English +/// text and stays locale-coupled by design (see its comments); this suite +/// exists specifically to exercise the Spanish translations end to end. +@MainActor +final class CheatSheetiOSSpanishLocaleUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + private func launchAppInSpanish( + showOnboarding: Bool = false, + extraArguments: [String] = [] + ) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-cheatsheet-ui-testing", + showOnboarding ? "-cheatsheet-reset-onboarding" : "-cheatsheet-skip-onboarding", + "-AppleLanguages", "(es-ES)", + "-AppleLocale", "es_ES" + ] + extraArguments + app.launch() + return app + } + + /// Accessibility identifiers are stable across locales, so the core surface + /// must still be reachable purely by identifier under Spanish. + func testCoreSurfaceIsReachableByAccessibilityIdentifierInSpanish() throws { + let app = launchAppInSpanish() + + XCTAssertTrue( + app.buttons["new-note-button"].waitForExistence(timeout: 10), + "Expected the notes surface to load under a Spanish locale." + ) + + app.buttons["new-note-button"].tap() + XCTAssertTrue( + app.textFields["note-title-field"].waitForExistence(timeout: 10), + "Expected the note editor to open under a Spanish locale." + ) + XCTAssertTrue( + app.textViews["note-body-editor"].waitForExistence(timeout: 10), + "Expected the note body editor to be present under a Spanish locale." + ) + } + + /// Confirms the Spanish String Catalog entries actually load and render, + /// not just that the app avoids crashing. + func testSeededStarterNoteRendersInSpanish() throws { + let app = launchAppInSpanish() + + XCTAssertTrue(app.buttons["new-note-button"].waitForExistence(timeout: 10)) + XCTAssertTrue( + app.staticTexts["Demostración de formato del widget"].waitForExistence(timeout: 10), + "Expected the seeded starter note title to render in Spanish." + ) + XCTAssertFalse( + app.staticTexts["Widget Formatting Demo"].exists, + "The starter note title should not still be in English under a Spanish locale." + ) + } + + /// Onboarding is the first thing a new Spanish-speaking user sees; make sure + /// none of its String Catalog keys leak onto screen untranslated. + func testOnboardingRendersTranslatedTextInSpanish() throws { + let app = launchAppInSpanish(showOnboarding: true) + + XCTAssertTrue( + app.staticTexts["Ten a mano los comandos de programación que usas cada día."].waitForExistence(timeout: 10), + "Expected the onboarding subtitle to render in Spanish." + ) + + let doneButton = app.buttons["onboarding-done-button"] + XCTAssertTrue(doneButton.waitForExistence(timeout: 10)) + doneButton.tap() + + XCTAssertTrue(app.buttons["new-note-button"].waitForExistence(timeout: 10)) + } + + /// Exercises the empty-Trash string under a Spanish locale. + /// + /// This asserts a translated *string*, so it launches straight into Trash + /// rather than driving the toolbar. The toggle lives in the secondary-action + /// group, which iOS collapses into a system "More" overflow at compact + /// widths -- and a collapsed item becomes a plain menu row that no longer + /// resolves by accessibility identifier, so navigating there was a + /// width- and OS-dependent coin flip. + func testTrashEmptyStateRendersInSpanish() throws { + let app = launchAppInSpanish(extraArguments: ["-cheatsheet-show-trash"]) + + XCTAssertTrue( + app.staticTexts["La papelera está vacía"].waitForExistence(timeout: 10), + "Expected the empty Trash state to render in Spanish." + ) + } +} diff --git a/CheatSheetUITests/Sources/CheatSheetiOSUITests.swift b/CheatSheetUITests/Sources/CheatSheetiOSUITests.swift index d517b0b..8f47722 100644 --- a/CheatSheetUITests/Sources/CheatSheetiOSUITests.swift +++ b/CheatSheetUITests/Sources/CheatSheetiOSUITests.swift @@ -12,7 +12,11 @@ final class CheatSheetiOSUITests: XCTestCase { let app = XCUIApplication() app.launchArguments = [ "-cheatsheet-ui-testing", - showOnboarding ? "-cheatsheet-reset-onboarding" : "-cheatsheet-skip-onboarding" + showOnboarding ? "-cheatsheet-reset-onboarding" : "-cheatsheet-skip-onboarding", + // This suite asserts rendered English text throughout, so pin the + // locale rather than depending on the simulator's default. + "-AppleLanguages", "(en)", + "-AppleLocale", "en_US" ] app.launch() return app @@ -76,9 +80,7 @@ final class CheatSheetiOSUITests: XCTestCase { let titleField = app.textFields["note-title-field"] XCTAssertTrue(titleField.waitForExistence(timeout: 10)) - titleField.tap() - titleField.press(forDuration: 1.0) - app.menuItems["Select All"].tap() + selectAllText(in: titleField, app: app) titleField.typeText("Release Commands") let bodyEditor = app.textViews["note-body-editor"] @@ -101,7 +103,13 @@ final class CheatSheetiOSUITests: XCTestCase { let app = launchApp() createNote(in: app) - let mintButton = app.buttons["palette-59c979"] + let titleField = app.textFields["note-title-field"] + let pinButton = app.buttons["Use in Widget"] + XCTAssertTrue(pinButton.waitForExistence(timeout: 10)) + XCTAssertGreaterThanOrEqual(titleField.frame.minX, app.frame.minX) + XCTAssertLessThanOrEqual(pinButton.frame.maxX, app.frame.maxX) + + let mintButton = app.descendants(matching: .any)["palette-59c979"] XCTAssertTrue(mintButton.waitForExistence(timeout: 10)) mintButton.tap() XCTAssertEqual(mintButton.value as? String, "Selected") @@ -114,7 +122,6 @@ final class CheatSheetiOSUITests: XCTestCase { serifButton.tap() XCTAssertEqual(fontPicker.value as? String, "Serif") - let pinButton = app.buttons["widget-pin-button"] XCTAssertTrue(pinButton.waitForExistence(timeout: 10)) pinButton.tap() XCTAssertTrue(app.staticTexts["Shown in widget"].waitForExistence(timeout: 10)) @@ -127,27 +134,24 @@ final class CheatSheetiOSUITests: XCTestCase { openSecondaryAction("Move to Trash", in: app) openSecondaryAction("Show Trash", in: app) - XCTAssertTrue(app.staticTexts["Disposable Note"].waitForExistence(timeout: 10)) - app.staticTexts["Disposable Note"].tap() + XCTAssertTrue(noteList(in: app).staticTexts["Disposable Note"].waitForExistence(timeout: 10)) + noteList(in: app).staticTexts["Disposable Note"].tap() let restoreButton = app.buttons["restore-note-button"] XCTAssertTrue(restoreButton.waitForExistence(timeout: 10)) restoreButton.tap() - XCTAssertTrue(app.staticTexts["Disposable Note"].waitForExistence(timeout: 10)) + XCTAssertTrue(noteList(in: app).staticTexts["Disposable Note"].waitForExistence(timeout: 10)) - app.staticTexts["Disposable Note"].tap() + noteList(in: app).staticTexts["Disposable Note"].tap() XCTAssertTrue(app.textFields["note-title-field"].waitForExistence(timeout: 10)) openSecondaryAction("Move to Trash", in: app) openSecondaryAction("Show Trash", in: app) - app.staticTexts["Disposable Note"].tap() + noteList(in: app).staticTexts["Disposable Note"].tap() let deleteButton = app.buttons["delete-note-button"] XCTAssertTrue(deleteButton.waitForExistence(timeout: 10)) deleteButton.tap() - let deleteButtons = app.buttons.matching(identifier: "Delete Now") - let confirmDeleteButton = deleteButtons.element(boundBy: max(deleteButtons.count - 1, 0)) - XCTAssertTrue(confirmDeleteButton.waitForExistence(timeout: 10)) - confirmDeleteButton.tap() + confirmPermanentDelete(in: app) XCTAssertTrue(app.staticTexts["Trash is empty"].waitForExistence(timeout: 10)) } @@ -161,22 +165,77 @@ final class CheatSheetiOSUITests: XCTestCase { private func renameCurrentNote(_ title: String, in app: XCUIApplication) { let titleField = app.textFields["note-title-field"] XCTAssertTrue(titleField.waitForExistence(timeout: 10)) - titleField.tap() - titleField.press(forDuration: 1.0) - app.menuItems["Select All"].tap() + selectAllText(in: titleField, app: app) titleField.typeText(title) returnToList(in: app) } + /// Selects all text in a field via the system edit menu. + /// + /// That menu is system chrome and appears on its own schedule -- later on a + /// loaded machine. Two of the three call sites tapped it with no wait, + /// which surfaced as "Failed to tap \"Select All\" MenuItem: No matches + /// found" rather than as a product failure. + private func selectAllText(in field: XCUIElement, app: XCUIApplication) { + field.tap() + field.press(forDuration: 1.0) + XCTAssertTrue( + app.menuItems["Select All"].waitForExistence(timeout: 10), + "The edit menu never offered Select All." + ) + app.menuItems["Select All"].tap() + } + private func returnToList(in app: XCUIApplication) { if app.keyboards.count > 0 { - app.keyboards.buttons["Return"].tap() + // Present is not the same as hittable: under load this failed with + // kAXErrorCannotComplete scrolling the Return key into view. + let returnKey = app.keyboards.buttons["Return"] + if returnKey.waitForExistence(timeout: 5), returnKey.isHittable { + returnKey.tap() + } } + // iPad keeps the list on screen in a split-view sidebar, so there is + // nothing to pop. Tapping anyway is actively harmful: with two + // navigation bars present, `navigationBars.buttons.firstMatch` + // resolves to the DETAIL pane's first button -- "New Note" -- so this + // silently created a note instead of navigating. + guard !isSplitViewLayout(in: app) else { return } let backButton = app.navigationBars.buttons.firstMatch XCTAssertTrue(backButton.waitForExistence(timeout: 10)) backButton.tap() } + /// True on the iPad/regular-width layout, where NavigationSplitView shows + /// the note list in a sidebar alongside the detail pane. + private func isSplitViewLayout(in app: XCUIApplication) -> Bool { + app.collectionViews["Sidebar"].exists + } + + /// The note list itself: a system "Sidebar" collection view on iPad, and + /// the app's own `note-list` on iPhone. Membership assertions must be + /// scoped to it. On iPad a note's title also renders in the detail pane, + /// which makes an app-wide staticTexts query ambiguous, and lets a stale + /// detail pane defeat a "no longer in the list" assertion. + private func noteList(in app: XCUIApplication) -> XCUIElement { + let sidebar = app.collectionViews["Sidebar"] + return sidebar.exists ? sidebar : app.collectionViews["note-list"] + } + + /// Confirms the permanent-delete dialog. Both the detail pane and the + /// dialog carry a "Delete Now" button, and the previous approach picked + /// the last app-wide match on the assumption that the dialog sorts after + /// the pane. That does not hold on iPad, where the confirmation is a + /// popover: the pane's own button was tapped instead, the dialog was never + /// confirmed, and the note survived. + private func confirmPermanentDelete(in app: XCUIApplication) { + let sheet = app.sheets.firstMatch + let container = sheet.waitForExistence(timeout: 5) ? sheet : app.alerts.firstMatch + let confirm = container.buttons["Delete Now"] + XCTAssertTrue(confirm.waitForExistence(timeout: 10), "Delete confirmation dialog never offered Delete Now.") + confirm.tap() + } + private func openSecondaryAction(_ name: String, in app: XCUIApplication) { let directButton = app.buttons[name] if directButton.waitForExistence(timeout: 2), directButton.isHittable { @@ -191,12 +250,279 @@ final class CheatSheetiOSUITests: XCTestCase { directButton.tap() } + /// Pops back to the note list on iPhone's compact NavigationStack, where + /// the pushed editor screen replaces the list in the accessible + /// hierarchy. A no-op on iPad/Mac's NavigationSplitView, where the + /// sidebar list (and its search field) is already reachable -- detected + /// here by checking for the search field rather than branching on + /// device/size-class directly, so this helper works unmodified on both. + private func returnToListIfPushed(in app: XCUIApplication) { + guard !app.searchFields.firstMatch.exists else { return } + let backButton = app.navigationBars.firstMatch.buttons.firstMatch + if backButton.waitForExistence(timeout: 2), backButton.isHittable { + backButton.tap() + } + } + + /// Clearing the search field's text does not end its active/focused + /// search session -- `.searchable()` keeps showing the Cancel/Close + /// button and keeps the keyboard up until that session is explicitly + /// dismissed, and while it's active iOS hides the rest of the + /// navigation bar's toolbar items (New Note, Show Trash, ...) to make + /// room for it. Confirmed via the accessibility hierarchy dump at a + /// prior failure here: the toolbar existed but had zero buttons in it + /// while the search field still reported `Keyboard Focused`. + private func dismissSearchIfActive(in app: XCUIApplication) { + let closeButton = app.buttons["Close"] + if closeButton.waitForExistence(timeout: 2), closeButton.isHittable { + closeButton.tap() + } + } + + /// Taps a text field and waits for the keyboard before returning. Typing + /// without this races first-responder assignment on a loaded machine, which + /// surfaces as "Neither element nor any descendant has keyboard focus" + /// rather than as a real product failure. + private func focusForTyping(_ field: XCUIElement, in app: XCUIApplication) { + XCTAssertTrue(field.waitForExistence(timeout: 10)) + field.tap() + XCTAssertTrue( + app.keyboards.element.waitForExistence(timeout: 10), + "Expected the keyboard to appear before typing." + ) + } + + /// iPhone hides the navigationBarDrawer search field until the list + /// scrolls, and a full-screen swipe reaches it. iPad keeps the list in a + /// split-view sidebar, where `.searchable(placement: .sidebar)` puts the + /// field above the first row -- a full-screen swipe there lands on the + /// detail pane and scrolls nothing, so the sidebar must be swiped directly. private func revealSearchField(in app: XCUIApplication) -> XCUIElement { var field = app.searchFields.firstMatch - if !field.waitForExistence(timeout: 2) { - app.swipeDown() - field = app.searchFields.firstMatch + if field.waitForExistence(timeout: 2) { return field } + + app.swipeDown() + field = app.searchFields.firstMatch + if field.waitForExistence(timeout: 2) { return field } + + let sidebar = app.collectionViews["Sidebar"] + if sidebar.waitForExistence(timeout: 2) { + sidebar.swipeDown() + } + return app.searchFields.firstMatch + } + + // MARK: - Ad hoc QA sweep (manual verification pass, runs on iPhone and iPad) + // + // This drives the whole manual QA checklist end to end against a live + // simulator, capturing a named screenshot at every checkpoint so the + // run can be verified visually afterward. Screenshots are attached with + // `.keepAlways` so they land in the .xcresult bundle for extraction. + // + // Originally written against iPad's NavigationSplitView, where the + // sidebar list and the editor detail pane are on screen at the same + // time. Verified against iPhone too: everything up through editing a + // note holds as-is, but the list-level checks (pin invariant, search, + // selecting a different note) need `returnToListIfPushed(in:)` first -- + // iPhone's compact NavigationStack pushes the editor over the list, so + // the list briefly isn't part of the accessible hierarchy at all. On + // iPad/Mac that helper is a no-op since the list is already reachable. + + func testQASweep() throws { + // --- Item 1: fresh onboarding --- + let app = XCUIApplication() + app.launchArguments = ["-cheatsheet-ui-testing", "-cheatsheet-reset-onboarding"] + app.launch() + + let doneButton = app.buttons["onboarding-done-button"] + XCTAssertTrue(doneButton.waitForExistence(timeout: 10), "Onboarding sheet should appear on first launch.") + XCTAssertTrue(app.staticTexts["Add notes"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Tune the look"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Pin the widget note"].waitForExistence(timeout: 5)) + capture(app, "01-onboarding-sheet") + + doneButton.tap() + XCTAssertTrue(app.buttons["new-note-button"].waitForExistence(timeout: 10), "Should land on the notes surface after onboarding.") + capture(app, "02-onboarding-dismissed") + assertNoPersistenceBanner(app) + + // --- Item 2: relaunch with seeded screenshot demo notes --- + app.terminate() + app.launchArguments = ["-cheatsheet-ui-testing", "-cheatsheet-skip-onboarding", "-cheatsheet-seed-screenshot-demo"] + app.launch() + XCTAssertTrue(app.buttons["new-note-button"].waitForExistence(timeout: 10)) + + let demoTitles = [ + "Git Rescue", "Ship Checklist", "Swift Concurrency", "Docker Cleanup", + "Vim Motions", "zsh + Homebrew", "Xcode Shortcuts", "HTTP Status Codes" + ] + for title in demoTitles { + XCTAssertTrue(app.staticTexts[title].waitForExistence(timeout: 10), "Missing seeded demo note '\(title)'.") + } + XCTAssertTrue(app.images["Pinned"].waitForExistence(timeout: 10), "Expected exactly one pin indicator (Git Rescue) after seeding.") + XCTAssertEqual(app.images.matching(identifier: "Pinned").count, 1) + capture(app, "03-demo-notes-seeded") + assertNoPersistenceBanner(app) + + // --- Item 3: open a seeded note, inspect raw text content --- + // NOTE: `.firstMatch` works around a SwiftUI List/NavigationSplitView + // accessibility quirk on this iOS 27 beta where the auto-selected + // sidebar row briefly exposes its label as two duplicate StaticText + // nodes, which makes a plain identifier lookup ambiguous for `.tap()`. + app.staticTexts["Git Rescue"].firstMatch.tap() + let titleFieldExisting = app.textFields["note-title-field"] + XCTAssertTrue(titleFieldExisting.waitForExistence(timeout: 10)) + XCTAssertTrue(app.textViews["note-body-editor"].waitForExistence(timeout: 10)) + capture(app, "04-note-opened-git-rescue") + + // --- Item 4: new note opens straight into editor with default title --- + let newNoteButton = app.buttons["new-note-button"] + XCTAssertTrue(newNoteButton.waitForExistence(timeout: 10)) + newNoteButton.tap() + let titleField = app.textFields["note-title-field"] + XCTAssertTrue(titleField.waitForExistence(timeout: 10)) + XCTAssertEqual(titleField.value as? String, "New Cheat Sheet") + capture(app, "05-new-note-default-title") + + // --- Item 5: edit title + body --- + titleField.tap() + titleField.press(forDuration: 1.0) + if app.menuItems["Select All"].waitForExistence(timeout: 3) { + app.menuItems["Select All"].tap() + } + titleField.typeText("QA Sweep Note") + capture(app, "06-title-edited") + + let bodyEditor = app.textViews["note-body-editor"] + XCTAssertTrue(bodyEditor.waitForExistence(timeout: 10)) + bodyEditor.tap() + bodyEditor.typeText("# QA Heading\n- [ ] Open task line\n- [x] Done task line") + capture(app, "07-body-edited") + + // --- Item 6: palette picker, 3 distinct swatches --- + // Deliberately avoids palette-4b88ff (Blue), which is the app's likely + // default tint, so every tap here produces a visibly different color + // from the note surface's starting state, not just from each other. + let paletteSamples = ["palette-d99a2b", "palette-e85d75", "palette-8b7cff"] + for (index, identifier) in paletteSamples.enumerated() { + let swatch = app.buttons[identifier] + XCTAssertTrue(swatch.waitForExistence(timeout: 10), "Missing palette swatch \(identifier)") + swatch.tap() + XCTAssertEqual(swatch.value as? String, "Selected", "\(identifier) should report Selected after tap.") + let suffix = ["a", "b", "c"][index] + capture(app, "08\(suffix)-palette-\(identifier)") + } + + // --- Item 7: font picker lists all 4 styles, selecting one changes typeface --- + let fontPicker = app.buttons["font-style-picker"] + XCTAssertTrue(fontPicker.waitForExistence(timeout: 10)) + fontPicker.tap() + for style in ["Mono", "System", "Rounded", "Serif"] { + XCTAssertTrue(app.buttons[style].waitForExistence(timeout: 5), "Font menu missing style \(style)") + } + capture(app, "09a-font-menu-open") + app.buttons["Serif"].tap() + XCTAssertEqual(fontPicker.value as? String, "Serif") + capture(app, "09b-font-serif-applied") + + // --- Item 8: pin / unpin --- + let pinButton = app.buttons["widget-pin-button"] + XCTAssertTrue(pinButton.waitForExistence(timeout: 10)) + pinButton.tap() + XCTAssertTrue(app.staticTexts["Shown in widget"].waitForExistence(timeout: 10)) + capture(app, "10a-note-pinned") + returnToListIfPushed(in: app) + XCTAssertEqual(app.images.matching(identifier: "Pinned").count, 1, "Only one note should be pinned after re-pinning.") + capture(app, "10b-list-single-pin") + + // --- Item 9: search --- + let searchField = revealSearchField(in: app) + focusForTyping(searchField, in: app) + searchField.typeText("docker") + XCTAssertTrue(app.staticTexts["Docker Cleanup"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.staticTexts["Vim Motions"].exists) + capture(app, "11a-search-filtered-docker") + + if let value = searchField.value as? String, !value.isEmpty { + // The screenshot above is a synchronous round trip long enough for + // .searchable to drop first responder, so re-focus before clearing. + focusForTyping(searchField, in: app) + searchField.typeText(String(repeating: "\u{8}", count: value.count)) + } + XCTAssertTrue(app.staticTexts["Vim Motions"].waitForExistence(timeout: 10), "Full list should return after clearing search.") + capture(app, "11b-search-cleared") + dismissSearchIfActive(in: app) + + // --- Item 10: trash flow (restore) --- + app.staticTexts["Swift Concurrency"].firstMatch.tap() + XCTAssertTrue(app.textFields["note-title-field"].waitForExistence(timeout: 10)) + tapToolbarAction(identifier: "move-to-trash-button", label: "Move to Trash", in: app) + tapToolbarAction(identifier: "toggle-trash-button", label: "Show Trash", in: app) + XCTAssertTrue(app.staticTexts["Swift Concurrency"].waitForExistence(timeout: 10)) + capture(app, "12a-trash-list") + app.staticTexts["Swift Concurrency"].firstMatch.tap() + XCTAssertTrue(app.buttons["restore-note-button"].waitForExistence(timeout: 10)) + capture(app, "12b-trash-detail-countdown") + + // Restoring already navigates back to the active list on its own + // (TrashNoteView's restoreAction calls showNotes()), so no extra + // toggle-trash tap is needed -- or findable, since by the time it + // would run the toolbar button has already flipped back to "Show Trash". + let restoreButton = app.buttons["restore-note-button"] + restoreButton.tap() + XCTAssertTrue(app.staticTexts["Swift Concurrency"].waitForExistence(timeout: 10), "Restored note should be back in the active list.") + capture(app, "12c-note-restored") + assertNoPersistenceBanner(app) + + // --- Item 10 continued: trash flow (permanent delete, different note) --- + app.staticTexts["Vim Motions"].firstMatch.tap() + XCTAssertTrue(app.textFields["note-title-field"].waitForExistence(timeout: 10)) + tapToolbarAction(identifier: "move-to-trash-button", label: "Move to Trash", in: app) + tapToolbarAction(identifier: "toggle-trash-button", label: "Show Trash", in: app) + XCTAssertTrue(app.staticTexts["Vim Motions"].waitForExistence(timeout: 10)) + app.staticTexts["Vim Motions"].firstMatch.tap() + + let deleteButton = app.buttons["delete-note-button"] + XCTAssertTrue(deleteButton.waitForExistence(timeout: 10)) + deleteButton.tap() + capture(app, "13a-delete-confirmation-dialog") + + confirmPermanentDelete(in: app) + XCTAssertFalse(noteList(in: app).staticTexts["Vim Motions"].waitForExistence(timeout: 5), "Permanently deleted note should be gone from Trash.") + capture(app, "13b-note-deleted-from-trash") + assertNoPersistenceBanner(app) + } + + private func capture(_ app: XCUIApplication, _ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + private func assertNoPersistenceBanner(_ app: XCUIApplication) { + XCTAssertFalse(app.otherElements["persistence-status-banner"].exists, "The storage failure banner must not appear during a healthy run.") + } + + /// Taps a `.secondaryAction` toolbar item by its accessibility identifier + /// when it's directly visible, falling back to the "More" overflow menu + /// when it collapses there (which iPad's narrower detail-pane toolbar + /// does routinely). Once collapsed into "More", iOS renders the item as a + /// plain menu row and its SwiftUI `accessibilityIdentifier` no longer + /// resolves it -- only its visible label does -- so the fallback must + /// look up `label`, not `identifier`. + private func tapToolbarAction(identifier: String, label: String, in app: XCUIApplication) { + let direct = app.buttons[identifier] + if direct.waitForExistence(timeout: 2), direct.isHittable { + direct.tap() + return + } + let moreButton = app.buttons["More"] + if moreButton.waitForExistence(timeout: 2) { + moreButton.tap() } - return field + let menuItem = app.buttons[label] + XCTAssertTrue(menuItem.waitForExistence(timeout: 10), "Could not find toolbar action '\(label)' (\(identifier)), even behind More.") + menuItem.tap() } } diff --git a/CheatSheetUITests/Sources/MarketingScreenshotTests.swift b/CheatSheetUITests/Sources/MarketingScreenshotTests.swift new file mode 100644 index 0000000..dbdc284 --- /dev/null +++ b/CheatSheetUITests/Sources/MarketingScreenshotTests.swift @@ -0,0 +1,162 @@ +import XCTest + +/// Captures App Store screenshots against the curated demo content. +/// +/// Deliberately separate from `CheatSheetiOSUITests`: that suite's QA sweep +/// creates a throwaway "QA Sweep Note" with placeholder body text, so every +/// capture after its third checkpoint shows test data. Apple treats +/// placeholder content in a store listing as grounds for rejection, so +/// marketing captures need their own path that only ever shows +/// `ScreenshotDemoContent`. +/// +/// Not a correctness gate. It asserts only enough to know a capture is of the +/// screen it claims. CI skips it; run it on demand: +/// xcodebuild test -scheme CheatSheetiOSUI \ +/// -only-testing:CheatSheetiOSUITests/MarketingScreenshotTests +@MainActor +final class MarketingScreenshotTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = true // one awkward state must not cost the rest of the set + } + + private func launchSeeded(_ extra: [String] = []) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-cheatsheet-ui-testing", + "-cheatsheet-skip-onboarding", + "-cheatsheet-seed-screenshot-demo", + "-showWidgetHints", "NO", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US" + ] + extra + app.launch() + return app + } + + private func shoot(_ app: XCUIApplication, _ name: String) { + let a = XCTAttachment(screenshot: app.screenshot()) + a.name = name + a.lifetime = .keepAlways + add(a) + } + + private var isPad: Bool { XCUIApplication().collectionViews["Sidebar"].exists } + + func testCaptureMarketingSet() throws { + let app = launchSeeded() + + // M1: the library. Real developer reference notes, colour-coded. + XCTAssertTrue(app.staticTexts["Git Rescue"].waitForExistence(timeout: 20)) + shoot(app, "M1-library") + + // M2: a real note open, with a heading, commands, checkboxes, and monospace text. + app.staticTexts["Git Rescue"].firstMatch.tap() + XCTAssertTrue(app.textFields["note-title-field"].waitForExistence(timeout: 15)) + shoot(app, "M2-note-open") + + // Show a different, task-focused note and the editor's full palette. + let back = app.navigationBars.firstMatch.buttons.firstMatch + back.tap() + app.staticTexts["Ship Checklist"].firstMatch.tap() + XCTAssertTrue(app.textFields["note-title-field"].waitForExistence(timeout: 15)) + shoot(app, "M3-checklist") + + // M4: font menu open over real content. + let fontPicker = app.buttons["font-style-picker"].firstMatch + guard fontPicker.waitForExistence(timeout: 10), fontPicker.isHittable else { + XCTFail("Font picker is unavailable for the marketing capture") + return + } + fontPicker.tap() + guard app.buttons["Serif"].firstMatch.waitForExistence(timeout: 5) else { + XCTFail("Font menu did not open for the marketing capture") + return + } + shoot(app, "M4-font-menu") + // Leave the note on its default Mono rather than mutating it. + if app.buttons["Mono"].firstMatch.exists { app.buttons["Mono"].firstMatch.tap() } + + let sizePicker = app.buttons["font-size-picker"].firstMatch + guard sizePicker.waitForExistence(timeout: 5), sizePicker.isHittable else { + XCTFail("Text size picker is unavailable for the marketing capture") + return + } + sizePicker.tap() + guard app.buttons["Medium"].firstMatch.waitForExistence(timeout: 5) else { + XCTFail("Text size menu did not open for the marketing capture") + return + } + shoot(app, "M8-size-menu") + app.buttons["Medium"].firstMatch.tap() + + // Each note uses a different palette tint and useful sample content. + for (title, name) in [ + ("Swift Concurrency", "M9-concurrency"), + ("Docker Cleanup", "M10-docker"), + ("Xcode Shortcuts", "M11-shortcuts"), + ("HTTP Status Codes", "M12-http") + ] { + if back.waitForExistence(timeout: 5), back.isHittable { back.tap() } + XCTAssertTrue(app.staticTexts[title].waitForExistence(timeout: 10)) + app.staticTexts[title].firstMatch.tap() + XCTAssertTrue(app.textFields["note-title-field"].waitForExistence(timeout: 10)) + shoot(app, name) + } + } + + func testCaptureSearch() throws { + let app = launchSeeded() + XCTAssertTrue(app.staticTexts["Git Rescue"].waitForExistence(timeout: 20)) + + var field = app.searchFields.firstMatch + if !field.waitForExistence(timeout: 3) { + app.swipeDown() + field = app.searchFields.firstMatch + } + if !field.waitForExistence(timeout: 3) { + let sidebar = app.collectionViews["Sidebar"] + if sidebar.waitForExistence(timeout: 3) { sidebar.swipeDown() } + field = app.searchFields.firstMatch + } + XCTAssertTrue(field.waitForExistence(timeout: 15)) + field.tap() + XCTAssertTrue(app.keyboards.element.waitForExistence(timeout: 10)) + field.typeText("git") + _ = app.staticTexts["Git Rescue"].waitForExistence(timeout: 10) + shoot(app, "M5-search") + } + + func testCaptureTrash() throws { + // Trash needs an archived note. Archive a demo note, then show Trash -- + // so the shot carries real content instead of an empty state. + let app = launchSeeded() + XCTAssertTrue(app.staticTexts["Vim Motions"].waitForExistence(timeout: 20)) + app.staticTexts["Vim Motions"].firstMatch.tap() + XCTAssertTrue(app.textFields["note-title-field"].waitForExistence(timeout: 15)) + + let trashButton = app.buttons["move-to-trash-button"].firstMatch + if trashButton.waitForExistence(timeout: 5), trashButton.isHittable { + trashButton.tap() + } else if app.buttons["More"].firstMatch.waitForExistence(timeout: 5) { + app.buttons["More"].firstMatch.tap() + app.buttons["Move to Trash"].firstMatch.tap() + } + + let toggle = app.buttons["toggle-trash-button"].firstMatch + if toggle.waitForExistence(timeout: 5), toggle.isHittable { + toggle.tap() + } else if app.buttons["More"].firstMatch.waitForExistence(timeout: 5) { + app.buttons["More"].firstMatch.tap() + app.buttons["Show Trash"].firstMatch.tap() + } + + XCTAssertTrue(app.staticTexts["Vim Motions"].waitForExistence(timeout: 15)) + shoot(app, "M6-trash-list") + + app.staticTexts["Vim Motions"].firstMatch.tap() + guard app.buttons["restore-note-button"].waitForExistence(timeout: 10) else { + XCTFail("Trash detail did not open for the marketing capture") + return + } + shoot(app, "M7-trash-detail") + } +} diff --git a/CheatSheetWidgets/Resources/Localizable.xcstrings b/CheatSheetWidgets/Resources/Localizable.xcstrings new file mode 100644 index 0000000..4e07c50 --- /dev/null +++ b/CheatSheetWidgets/Resources/Localizable.xcstrings @@ -0,0 +1,132 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CheatSheet" : { + "comment" : "Widget gallery configuration display name. Product/brand name, do not translate.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheatSheet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheatSheet" + } + } + } + }, + "Keep a pinned coding note nearby." : { + "comment" : "Widget gallery description shown under the widget name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keep a pinned coding note nearby." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ten cerca una nota de programación fijada." + } + } + } + }, + "Pin or create a note in CheatSheet to show it here." : { + "comment" : "Widget empty-state message shown when no note is pinned.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin or create a note in CheatSheet to show it here." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fija o crea una nota en CheatSheet para mostrarla aquí." + } + } + } + }, + "checklist.line.complete" : { + "comment" : "Accessibility label prefix for a completed checklist line. %@ is the line's text content (user-authored, not translated).", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Complete, %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completada, %@" + } + } + } + }, + "checklist.line.incomplete" : { + "comment" : "Accessibility label prefix for an open checklist line. %@ is the line's text content (user-authored, not translated).", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Incomplete, %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pendiente, %@" + } + } + } + }, + "widget.emptyState.title" : { + "comment" : "Widget headline shown when no note is pinned.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Widget Note" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin nota en el widget" + } + } + } + }, + "widget.moreLines.count" : { + "comment" : "Shown below the visible checklist lines in a widget when the note has more lines than fit. %lld is the count of additional lines not shown.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld more" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld más" + } + } + } + } + }, + "version" : "1.1" +} diff --git a/CheatSheetWidgets/Sources/CheatSheetWidgetView.swift b/CheatSheetWidgets/Sources/CheatSheetWidgetView.swift index 2b71636..5c6e9ba 100644 --- a/CheatSheetWidgets/Sources/CheatSheetWidgetView.swift +++ b/CheatSheetWidgets/Sources/CheatSheetWidgetView.swift @@ -5,6 +5,8 @@ struct CheatSheetWidgetView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.widgetFamily) private var family @Environment(\.widgetRenderingMode) private var renderingMode + @AppStorage(CheatSheetFontSize.storageKey, store: CheatSheetFontSize.sharedDefaults) + private var fontSize: CheatSheetFontSize = .medium let entry: CheatSheetEntry var body: some View { @@ -24,12 +26,26 @@ struct CheatSheetWidgetView: View { } private func noteView(_ note: CheatSheetNote) -> some View { - VStack(alignment: .leading, spacing: family.verticalSpacing) { + let limit = family.lineLimit(for: fontSize) + let allLines = note.displayLines + let shownLines = Array(allLines.prefix(limit)) + let remainingCount = allLines.count - shownLines.count + + return VStack(alignment: .leading, spacing: family.verticalSpacing) { header(title: note.displayTitle, systemImage: "text.page.fill", tint: note.tint, fontStyle: note.fontStyle) VStack(alignment: .leading, spacing: family.lineSpacing) { - ForEach(note.displayLines(limit: family.lineLimit)) { line in - WidgetLineView(line: line, fontStyle: note.fontStyle) + ForEach(shownLines) { line in + WidgetLineView(line: line, fontStyle: note.fontStyle, fontSize: fontSize) + } + + if remainingCount > 0 { + Text(String( + localized: "widget.moreLines.count", + defaultValue: "+\(remainingCount) more" + )) + .font(.system(.caption2, design: note.fontStyle.design)) + .foregroundStyle(primaryTextStyle.opacity(0.7)) } } @@ -40,7 +56,7 @@ struct CheatSheetWidgetView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: family.verticalSpacing) { header( - title: "No Widget Note", + title: String(localized: "widget.emptyState.title", defaultValue: "No Widget Note"), systemImage: "note.text.badge.plus", tint: CheatSheetPalette.blue.color, fontStyle: .rounded @@ -81,31 +97,42 @@ struct CheatSheetWidgetView: View { @ViewBuilder private var widgetBackground: some View { if renderingMode == .fullColor { - switch colorScheme { - case .dark: - LinearGradient( - colors: [ - Color(red: 0.12, green: 0.14, blue: 0.18), - Color(red: 0.05, green: 0.06, blue: 0.08) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - default: - LinearGradient( - colors: [ - Color(red: 0.94, green: 0.97, blue: 1.0), - Color(red: 0.82, green: 0.89, blue: 0.98) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + // Onboarding tells the user the widget "follows" their note's + // color; the base gradient supplies contrast/structure, and this + // tint wash is what actually carries the note's own color into it. + ZStack { + baseGradient + (entry.note?.tint ?? CheatSheetPalette.blue.color) + .opacity(colorScheme == .dark ? 0.24 : 0.16) } } else { Color.clear } } + private var baseGradient: LinearGradient { + switch colorScheme { + case .dark: + LinearGradient( + colors: [ + Color(red: 0.12, green: 0.14, blue: 0.18), + Color(red: 0.05, green: 0.06, blue: 0.08) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + default: + LinearGradient( + colors: [ + Color(red: 0.94, green: 0.97, blue: 1.0), + Color(red: 0.82, green: 0.89, blue: 0.98) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + private var primaryTextStyle: Color { renderingMode == .fullColor ? .primary : .white } diff --git a/CheatSheetWidgets/Sources/WidgetFamily+CheatSheetLayout.swift b/CheatSheetWidgets/Sources/WidgetFamily+CheatSheetLayout.swift index 3b8bad7..8172910 100644 --- a/CheatSheetWidgets/Sources/WidgetFamily+CheatSheetLayout.swift +++ b/CheatSheetWidgets/Sources/WidgetFamily+CheatSheetLayout.swift @@ -10,11 +10,20 @@ extension WidgetFamily { } } - var lineLimit: Int { + /// Base line count per family, scaled down at larger note text sizes so a + /// bigger font doesn't overflow the widget's fixed content box. + func lineLimit(for fontSize: CheatSheetFontSize) -> Int { + let base: Int switch self { - case .systemSmall: 4 - case .systemMedium: 5 - default: 9 + case .systemSmall: base = 4 + case .systemMedium: base = 5 + default: base = 9 + } + + switch fontSize { + case .small: return base + 1 + case .medium: return base + case .large: return max(2, base - 2) } } diff --git a/CheatSheetWidgets/Sources/WidgetLineView.swift b/CheatSheetWidgets/Sources/WidgetLineView.swift index 0106b0d..b446bdf 100644 --- a/CheatSheetWidgets/Sources/WidgetLineView.swift +++ b/CheatSheetWidgets/Sources/WidgetLineView.swift @@ -5,17 +5,18 @@ struct WidgetLineView: View { @Environment(\.widgetRenderingMode) private var renderingMode let line: DisplayLine let fontStyle: CheatSheetFontStyle + let fontSize: CheatSheetFontSize var body: some View { HStack(alignment: .firstTextBaseline, spacing: 6) { Image(systemName: line.isComplete ? "checkmark.circle.fill" : "circle") .font(.caption) .foregroundStyle(iconStyle) - .opacity(line.isTask ? 1 : 0.35) + .opacity(line.isTask ? 1 : 0.18) .accessibilityHidden(true) Text(line.text) - .font(.system(.caption, design: fontStyle.design).weight(line.isHeading ? .semibold : .regular)) + .font(.system(fontSize.widgetLineTextStyle, design: fontStyle.design).weight(line.isHeading ? .semibold : .regular)) .foregroundStyle(textStyle) .strikethrough(line.isComplete) .lineLimit(2) @@ -27,7 +28,9 @@ struct WidgetLineView: View { private var accessibilityLabel: String { guard line.isTask else { return line.text } - return line.isComplete ? "Complete, \(line.text)" : "Incomplete, \(line.text)" + return line.isComplete + ? String(localized: "checklist.line.complete", defaultValue: "Complete, \(line.text)") + : String(localized: "checklist.line.incomplete", defaultValue: "Incomplete, \(line.text)") } private var textStyle: Color { diff --git a/Docs/RELEASE-1.2.md b/Docs/RELEASE-1.2.md new file mode 100644 index 0000000..fd626bf --- /dev/null +++ b/Docs/RELEASE-1.2.md @@ -0,0 +1,107 @@ +# CheatSheet 1.2: Release Checklist + +Master index for shipping 1.2 (build 14). Everything text-based is done and +linked below; the remaining steps happen in Xcode / App Store Connect and +need you, since they involve your Apple ID and signing. + +## Documents in this release kit + +| Document | Contents | +| --- | --- | +| [`app-store-release-kit-1.2.md`](app-store-release-kit-1.2.md) | Every App Store Connect field: name, subtitle, description, keywords, promo text, what's new, category, age rating, review notes, URLs | +| [`app-store-localization-es.md`](app-store-localization-es.md) | The same fields in Spanish (Mexico) | +| [`testflight-1.2-what-to-test.md`](testflight-1.2-what-to-test.md) | The TestFlight "What to Test" tester message (full + short versions) | +| [`press-kit-1.2.md`](press-kit-1.2.md) | Fact sheet, journalist pitch email, Product Hunt copy, Apple editorial nomination text, social launch posts | +| [`../AppStoreScreenshots/final/`](../AppStoreScreenshots/final/README.md) | 20 store screenshots: 10 iPhone (1320×2868) and 10 Mac (2880×1800). The iPad images are from an earlier set | + +## Final test pass: 1.2 / build 14 + +Run against a clean XcodeGen-generated project, both device families, both +platforms, on `release/1.2`. + +Confirmed green (latest run, shared-machine load ~700–900): + +| Check | Result | +| --- | --- | +| Config gates (project / localization / SDK) | 3/3 PASS | +| Unit tests (macOS) | 69 PASS | +| Unit tests (iOS) | 69 PASS | +| UI tests (iPhone) | 11 PASS | +| UI tests (iPad) | (running, prior full-session run: 11 PASS) | +| Release builds, universal binary, archives | (running, prior full-session run: all PASS, lipo arm64 + x86_64) | +| 20 shipped screenshot assets | exact sizes, no alpha | + +App code changed since `f99d35b`: `e49be08` keeps the iPhone editor header controls on screen. Unit tests (69, macOS and iOS), the iPhone UI suite (11), and a universal Release build were re-run after it on 2026-10-01 against the Xcode 27 beta SDK, which verifies behavior but not submission readiness. Update this table from the +latest `finalsuite2` run if any step regresses. + +## Signing and distribution: Xcode Cloud + +CheatSheet builds, tests, and archives on Xcode Cloud. It signs on Apple's +servers using the certificates and profiles tied to your Apple Developer +account, so this does not depend on any certificate installed in a local +keychain. `CODE_SIGN_STYLE` is `Automatic` for every target in `project.yml`, +which Xcode Cloud's managed signing expects. + +If a workflow does not exist yet: + +1. Open `CheatSheet.xcodeproj` in Xcode (regenerate first if it does not + exist: `xcodegen generate`), then **Product → Xcode Cloud → Create + Workflow**. Xcode Cloud walks through connecting the repository if this is + the first workflow on the project. +2. Pick the **CheatSheetiOS** target for this release's workflow. +3. **Environment tab**: select a specific stable Xcode build from the version + dropdown. Do not leave it on "Latest Release" or "Latest Beta"; both drift + without warning as Apple ships new versions. `.xcode-version` at the + repository root records the version this project currently expects + (`26.6`) for reference, but the Environment tab setting is what actually + governs the build. +4. **Start Condition**: Branch Changes on `release/1.2` (or `main`, once this + merges there) for an archive-and-distribute workflow. A separate, + lighter-weight workflow triggered on Pull Request against `develop` and + `main` replaces the old GitHub Actions build/test gate; give it a Test + action instead of Archive. +5. **Actions**: Archive, with the **CheatSheetiOS** scheme. +6. **Post-Actions**: TestFlight Internal Testing. +7. Save, then **Start Build** to run it for the first time. + +Repeat for **CheatSheetApp** (macOS) once Mac App Store screenshots exist; +until then, a Test-only workflow for that scheme is enough to keep the Mac +build verified without shipping it. + +A build normally reaches TestFlight within fifteen to twenty-five minutes of +starting. + +## After upload: TestFlight + +1. App Store Connect → your app → **TestFlight** tab → the new build. +2. Fill in **Test Information → What to Test** with the message from + [`testflight-1.2-what-to-test.md`](testflight-1.2-what-to-test.md). +3. Add internal testers immediately (no review needed); external testers + require Apple's brief TestFlight review first. +4. Once testers confirm the build is solid, move to submission. + +## Submission for App Store review + +1. App Store Connect → **App Information**: fill in Category, Age Rating, + Content Rights per [`app-store-release-kit-1.2.md`](app-store-release-kit-1.2.md#1-app-information-one-time-or-confirm-unchanged). +2. The version page: paste in Name, Subtitle, Description, Keywords, + Promotional Text, What's New, Support/Privacy URLs from the same file. +3. Upload the iPhone and Mac screenshots from `AppStoreScreenshots/final/` in + filename order. +4. Select the build you just validated in TestFlight. +5. Paste the Review Notes block from the release kit. +6. Resolve any remaining open items flagged in the release kit before + submitting. +7. **Add for Review.** + +## Merge: after everything above is real and tested + +Merge the release pull request into `main`, then tag it (see `CONTRIBUTING.md`): + +```sh +git checkout main && git pull +git tag -a v -m "CheatSheet " +git push origin main --tags +``` + +Delete the release branch once its pull request has merged. diff --git a/Docs/app-store-localization-es.md b/Docs/app-store-localization-es.md new file mode 100644 index 0000000..907c035 --- /dev/null +++ b/Docs/app-store-localization-es.md @@ -0,0 +1,105 @@ +# CheatSheet: Spanish App Store Connect Metadata + +This is metadata copy for App Store Connect, not app code. Nothing here is +consumed by the build; add it manually under the app's "Spanish" localization +in App Store Connect > App Information / App Store tab. Apple offers **Spanish +(Mexico)** and **Spanish (Spain)** as separate metadata locales (there is no +generic "Spanish" or "es-419" option at the App Store Connect metadata level, +unlike the app binary's own runtime `es` resource, which does serve every +Spanish-preferring region generically). + +**Recommendation:** add only **Spanish (Mexico)** for now. It is the most +broadly understood choice across Latin American storefronts, and the app has +no region-specific content or vocabulary that would justify maintaining a +second, separately-tuned Spain listing. Add Spanish (Spain) later only if you +want Spain-specific store-listing copy. The runtime app UI itself doesn't +need it either way, since it already ships one neutral `es` translation that +covers both. + +## App Name + +Keep **CheatSheet**. Brand names are not translated. + +## Subtitle (30 characters max) + +``` +Notas y comandos a mano +``` +(23 characters. "Notes and commands at hand" mirrors the README's own +framing of the product.) + +## Promotional text (170 characters max, editable anytime without review) + +``` +Notas breves, comandos y listas de tareas siempre a mano, con widget para +macOS, iOS y iPadOS. Sin cuentas, sin anuncios, sin conexión necesaria. +``` + +## Description (4000 characters max) + +``` +CheatSheet es una app de código abierto para macOS, iOS y iPadOS que te +permite guardar notas breves, listas de tareas y comandos que usas todos los +días, siempre a mano. + +Con CheatSheet puedes: +• Crear y editar notas breves en texto sencillo. +• Buscar en los títulos y el contenido de tus notas. +• Ver títulos, viñetas y elementos de una lista (pendientes o completados) en + las vistas previas y en el widget. +• Elegir entre diez colores y cuatro estilos de fuente para cada nota. +• Fijar una nota para mostrarla en el widget de macOS, iOS y iPadOS, en + tamaño pequeño, mediano o grande. +• Mover notas a la papelera, restaurarlas, eliminarlas de inmediato o dejar + que se eliminen automáticamente después de 30 días. +• Capturar una nota y abrir las más recientes desde la barra de menús en + macOS. +• Usar una navegación adaptable: diseño compacto en iPhone y vista dividida + en iPad y Mac. + +CheatSheet guarda tus notas solo en tu dispositivo mediante SwiftData y un +grupo de aplicaciones compartido. No se necesita cuenta ni conexión a +internet, y la app no incluye analítica, publicidad ni servicios de terceros. + +Ligera, nativa y pensada para que anotar o consultar algo sea instantáneo. +``` + +## Keywords (100 characters max, comma-separated, no spaces needed) + +``` +notas,apuntes,lista,comandos,recordatorio,checklist,git,widget,productividad,codigo +``` +(Omit words already in the app name/subtitle per ASO convention; adjust if +the English keyword set is finalized differently and you want closer parity.) + +## What's New / release notes (this release) + +``` +CheatSheet ya está disponible en español. También incluye mejoras generales +de estabilidad. +``` + +## App Privacy ("Nutrition Label") + +This is a questionnaire in App Store Connect, not free text you translate. +Apple renders the resulting labels in the storefront's language automatically. +No action needed beyond keeping the existing "no data collected" answers +accurate (matches `PrivacyInfo.xcprivacy` and `PRIVACY.md`). + +## Support / Marketing / Privacy Policy URLs + +These fields accept one URL regardless of storefront language; the existing +English URLs (`https://github.com/weskcode/cheatsheet/issues`, etc.) work as-is +for the Spanish listing. If you want the linked privacy policy page itself to +read in Spanish, translate `PRIVACY.md` (or add `PRIVACY.es.md` and link +both). Apple doesn't require this; it's optional polish. The document is +short, so it's a reasonable follow-up task. + +## Screenshots + +App Store Connect lets a Spanish (Mexico) localization reuse the same +screenshot images as English if you don't want to caption them separately. +None of this app's screenshots contain burned-in English text per the +existing `Docs/Images/*.png` captures, so no new captures are required purely +for the Spanish listing. Only recapture if you later add Spanish caption +overlays via the `app-store-screenshots` skill. diff --git a/Docs/app-store-release-kit-1.2.md b/Docs/app-store-release-kit-1.2.md new file mode 100644 index 0000000..4378443 --- /dev/null +++ b/Docs/app-store-release-kit-1.2.md @@ -0,0 +1,196 @@ +# CheatSheet 1.2: App Store Release Kit + +Everything needed to fill out App Store Connect (or a submission checklist +tool like Kickstart Premium) for version **1.2, build 14**. Every text field +below is validated against Apple's character limits with +`app-store-aso/scripts/validate_metadata.py`. The count appears next to each +heading. Copy each block as-is; nothing needs editing unless you want to +change the wording. + +Spanish (Mexico) localization metadata is a separate, already-complete file: +[`Docs/app-store-localization-es.md`](app-store-localization-es.md). + +--- + +## 1. App Information (one-time, or confirm unchanged) + +| Field | Value | +| --- | --- | +| App Name | `CheatSheet` | +| Bundle ID | `com.wesleykeetch.wesleycheatsheet` | +| SKU | any stable internal ID, e.g. `cheatsheet-macios` (cannot be changed later) | +| Primary Language | English (U.S.) | +| Primary Category | **Productivity** | +| Secondary Category | **Developer Tools** (recommended; not verified against Apple's live category list, so confirm at submission time) | +| Content Rights | **No**: the app contains no third-party content (all content is the user's own local notes) | + +## 2. Age Rating: 4+ + +Every questionnaire descriptor should be answered **None** / **No**: +violence, mature themes, gambling, horror, alcohol/drugs, unrestricted web +access, user-generated content shared with others, contests. CheatSheet has +no accounts and no way for one user to see another user's content. Nothing +in the app scores above 4+ on any axis. If Apple's expanded age-rating +questionnaire (13+/16+/18+ categories, required since Jan 31 2026) asks +about data-linked advertising or profiling, answer **No** to all. The +app's analytics are anonymous and not linked to identity. +`PrivacyInfo.xcprivacy` declares one collected data type (Product +Interaction usage analytics via TelemetryDeck), not linked to identity and +not used for tracking. + +## 3. App Name (10/30 characters) + +``` +CheatSheet +``` + +Keep the existing live name. This is a resubmission (v1.1.0 shipped +2026-06-10), and renaming a published app's Name field loses search-ranking +history for no benefit here. + +## 4. Subtitle (25/30 characters) + +``` +Notes & Commands, At Hand +``` + +Mirrors the Spanish subtitle's "a mano" (at hand) framing and adds two +keywords not present in the App Name. + +## 5. Promotional Text (147/170 characters) + +Editable any time without a new build or review. Update seasonally if useful. + +``` +Short notes, commands, and checklists always at hand, with widgets for macOS, iOS, and iPadOS. No account, no ads, no internet connection required. +``` + +## 6. Description (1,462/4,000 characters) + +``` +CheatSheet is an open-source app for macOS, iOS, and iPadOS that keeps the short notes, checklists, and commands you use every day close at hand. + +With CheatSheet you can: +- Write short, plain-text notes: no special syntax to learn. +- Search titles and note content instantly. +- See headings, bullets, and checklist items (open or done) rendered automatically in previews and widgets. +- Choose from ten note colors and four font styles to organize by project at a glance. +- Pin one note to a Home Screen, menu bar, or desktop widget on macOS, iOS, and iPadOS, in small, medium, or large sizes. +- Move notes to Trash, restore them, delete them immediately, or let them expire automatically after 30 days. +- Capture a note or jump to a recent one straight from the macOS menu bar. +- Use adaptive layouts built for each device: compact on iPhone, split-view on iPad and Mac. + +CheatSheet stores everything only on your device using SwiftData and a shared app group between the app and its widget. There's no account and no internet connection required to use the app. It sends a small number of anonymous, aggregate usage signals via TelemetryDeck (for example, that a note was pinned) that are never linked to your identity. Nothing you write ever leaves your device. + +Fully localized in English and Spanish, including onboarding, starter notes, and accessibility labels. + +Lightweight, native, and built so jotting something down or looking something up is instant. +``` + +## 7. Keywords (77/100 characters, comma-separated) + +``` +cheatsheet,checklist,reminder,git,widget,productivity,code,snippets,todo,ipad +``` + +Deliberately excludes "notes" and "commands": both already appear in the +App Name/Subtitle, which Apple indexes separately, so repeating them here +would waste characters per standard ASO practice. + +## 8. What's New in This Version (310/4,000 characters) + +``` +What's new in 1.2: +- Added Spanish localization throughout the app and widget +- Fixed a bug where headings ending in # (like C# or F#) lost the trailing character in the editor and widget +- Fixed several iPad layout issues affecting search, Trash, and note navigation +- Built and tested for iOS 26 and macOS 26 +``` + +Note: iPad has been a supported device family since before 1.2 +(`TARGETED_DEVICE_FAMILY = "1,2"`). This release fixes iPad-specific bugs +that testing newly caught; it doesn't add iPad as a new platform. + +## 9. URLs and contact email + +| Field | Value | +| --- | --- | +| Support URL | `https://github.com/weskcode/cheatsheet/issues` | +| Marketing URL | `https://cheatsheet.apphq.online/` (branded landing page) | +| Privacy Policy URL | `https://cheatsheet.apphq.online/privacy` (live; full text, verified accurate) | +| Contact email | `weskcode@duck.com` | + +Both pages are the branded AppHQ site, verified 2026-09-08: the landing page +lists all three platforms (Mac, iPhone, iPad) and the privacy page carried the +full policy (effective date, contact, local-only storage, no accounts, no +third parties). That verification predates the TelemetryDeck analytics +addition to `PRIVACY.md`: the live AppHQ and GitHub Pages copies need to be +re-published from the current `PRIVACY.md` before submission so they match +the App Privacy answers below. +The GitHub Pages copy (`weskcode.github.io/cheatsheet/`) remains as a mirror/ +fallback. + +## 10. App Privacy ("Nutrition Label") questionnaire + +Declare one collected data type: **Usage Data → Product Interaction**, +Not Linked to your identity, Not Used for Tracking, purpose **App +Functionality**. This is TelemetryDeck's aggregate usage signal (e.g. "a +note was pinned") and matches +`CheatSheetApp/Resources/PrivacyInfo.xcprivacy` (one collected data type, +`NSPrivacyCollectedDataTypeLinked = false`, +`NSPrivacyCollectedDataTypeTracking = false`) and `PRIVACY.md`. Answer +**"Data Not Collected"** for every other category. The WidgetKit +extension's own `CheatSheetWidgets/Resources/PrivacyInfo.xcprivacy` still +declares zero collected data types (it doesn't call TelemetryDeck), so +list it separately in App Store Connect if Apple asks per-extension. +`NSPrivacyTracking = false` in both manifests. No Required Reason API is +used beyond `UserDefaults`, correctly declared. + +## 11. Screenshots + +Ready in [`AppStoreScreenshots/final/`](../AppStoreScreenshots/final/README.md): +6 iPhone (1320×2868, 6.9" slot) + 3 iPad (2064×2752, 13" slot) + 3 Mac +(2880×1800, 16:10 slot). Upload in filename order. + +## 12. Review Notes (paste into the "App Review Information" notes field) + +``` +CheatSheet has no login, no account system, and no server component of any kind. Every screen is reachable immediately after install. There is nothing to configure before reviewing. + +To see note formatting: open any starter note, or create one and type a line starting with "#" (heading), "- " (bullet/checklist), or "- [ ]" / "- [x]" (open/done task). + +To see the widget: long-press the Home Screen (iOS/iPadOS) or open the Notification Center widget gallery (macOS), add the CheatSheet widget, then pin a note in-app via the pin icon in the editor toolbar. + +The app has no login, no server component, and stores all data locally via SwiftData in an App Group container shared with the widget extension. It sends anonymous, non-tracking usage analytics via TelemetryDeck; no note content or user data is ever transmitted. +``` + +App Store Connect also asks for App Review Contact Information: a separate +first name, last name, phone number, and email, kept for Apple's internal +use during review. Use `weskcode@duck.com` for the email field there. + +--- + +## Nice-to-haves (not required, cheap if you want them) + +- **Enable App Store review status notifications**: App Store Connect → + Users and Access → notification settings. Free, one toggle, tells you the + moment a review outcome lands instead of you polling. +- **Apple's "App of the Day" / featuring nomination**: App Store Connect → + your app version → "Nominate for Editorial Consideration." Costs nothing + and takes two minutes, with no downside to submitting it. +- **Release type**: decide **Automatically release** vs **Manually release** + this version before submitting. Manual release lets you pick the exact + moment it goes live after approval (useful if you want to coordinate an + announcement); automatic ships the moment Apple approves it. + +## Open items: need you or App Store Connect access + +- ✅ **Privacy Policy URL**: RESOLVED. Live at + `https://cheatsheet.apphq.online/privacy`, verified accurate. The GitHub + Pages copy remains as a mirror. +- ❔ **EU Digital Services Act trader status**: required for EU distribution since Feb 2025; verified only inside App Store Connect, not from this repo. +- ✅ **Mac App Store screenshots**: RESOLVED. Three 2880×1800 (16:10) + screenshots exist in `AppStoreScreenshots/final/mac-0{1,2,3}.png`, captured + from a seeded Debug build with the same demo content the iPhone/iPad shots + use (see `AppStoreScreenshots/raw/mac-raw-*.png`). +- ❔ **First-ever submission vs. update**: if App Store Connect doesn't already have this app's record, category/age-rating/URLs above are first-time setup, not just an edit. diff --git a/Docs/os-support-policy.md b/Docs/os-support-policy.md new file mode 100644 index 0000000..5ab65a8 --- /dev/null +++ b/Docs/os-support-policy.md @@ -0,0 +1,211 @@ +# OS Support Policy and iOS 27 Plan + +How CheatSheet decides which OS versions it runs on, which SDK it ships against, +and how a new major iOS release gets adopted without destabilising the version +that is on the App Store. + +## Current support matrix + +| Platform | Minimum (deployment target) | Built against (SDK) | Toolchain | Test runtime | +| ------------- | --------------------------- | ------------------- | --------- | ------------ | +| iOS / iPadOS | 26.0 | 26.x | Xcode 26 | iOS 26.5+ | +| macOS | 26.0 | 26.x | Xcode 26 | macOS 26+ | + +## Two versions that are easy to confuse + +These are independent knobs, and conflating them is the most common way a small +app either sheds users or gets rejected at upload. + +- **Deployment target**: the oldest OS that can install the app. Raising it + removes users. It is a product decision, never a housekeeping one. +- **SDK**: what the code is compiled against. Apple periodically raises the + minimum SDK it will accept for submission, and it never accepts a beta SDK. + Raising the SDK removes nobody. + +The floor may never exceed the SDK: `xcodebuild` refuses to build when the +deployment target is later than the SDK it is given. A floor *below* the SDK is +the normal, supported case. + +## Why the floor is iOS 26 / macOS 26 + +Set 2026-09-01. Every OS-conditional path in the app was a Liquid Glass gate: +`GlassEffectContainer`, `glassEffect(_:in:)`, and the `.glass` / +`.glassProminent` button styles, all introduced in 26.0. With the floor at 26.0 +those gates are unconditionally true, so they were removed: +`CheatSheetApp/Sources/LiquidGlassGroup.swift` and +`CheatSheetApp/Sources/ViewModifiers.swift` now call the Liquid Glass API +directly, and the two platform arms collapsed into one `#if os(macOS) || os(iOS)` +branch. There are no `#available` or `@available` checks left in the codebase. + +**26.0, not a point release.** Nothing in the app requires a 26.x point release, +so a 26.1–26.5 floor would delete exactly the same code while supporting fewer +devices. Pick the lowest floor that makes the code you actually want to delete +unreachable. + +Raise the floor again only when a specific feature the app needs demands it and +the gated fallback has become a maintenance burden. A newer OS existing is not +enough on its own. The previous floor (iOS 18 / macOS 15) was retired because it had +stopped being a real configuration: every gate behind it was a fallback nobody +was shipping against, and no iOS 18 simulator remained to test it on. + +## Tests run on iOS 26.5 or newer + +`Scripts/resolve-ios-simulator.sh` picks the newest available simulator **at or +above a floor**, defaulting to 26.5 and overridable with +`CHEATSHEET_MIN_IOS_RUNTIME`. Below the floor it fails with a per-runtime +breakdown rather than substituting an older runtime. A green suite that ran +against an unsupported configuration certifies nothing. + +There is a ceiling as well as a floor: runtimes from a newer major than the +active simulator SDK are excluded. `simctl` keeps listing a beta OS runtime as +available after you switch back to the release Xcode, and "newest wins" would +otherwise send every shipping test run onto the beta OS. Because the ceiling +comes from the toolchain, an iOS 27 branch needs no special case: selecting +Xcode 27 raises it automatically. + +It matches device family on `deviceTypeIdentifier`, not the display name: a +simulator can be renamed to anything and still be an iPhone, and name matching +silently hid four real iPhone simulators on this machine. + +## Never submit from a beta toolchain + +A machine that carries an Xcode beta will silently build against the beta SDK, +and the rejection only surfaces at upload. `Scripts/verify-build-sdk.sh` asserts +the active toolchain matches the SDK major this branch ships against. + +```sh +Scripts/verify-build-sdk.sh # hard fail on mismatch, run before archiving +Scripts/verify-build-sdk.sh --warn # warn only, used by Scripts/verify-macos.sh +``` + +The expected major is a branch-level contract, defaulting to `26` and overridable: + +```sh +CHEATSHEET_EXPECTED_SDK_MAJOR=27 Scripts/verify-build-sdk.sh +``` + +`ci_scripts/ci_post_clone.sh` runs the hard-fail form as the first thing Xcode +Cloud does after cloning, so the pin is asserted rather than assumed. Local +verification warns instead of failing, because developing against a beta is +fine, but archiving against one is not. + +`Scripts/verify-project-config.sh` separately asserts the deployment floors in +`project.yml`, so a regression to an older target fails the Xcode Cloud build +instead of shipping. See "CI provider" below for how the pieces fit together. + +## CI provider: Xcode Cloud + +CheatSheet builds and tests on Xcode Cloud. It previously ran on GitHub +Actions, which was dropped because macOS runners bill Actions minutes at a +10x multiplier on every GitHub plan, and a free-tier account has no minutes +left over for iOS work once that multiplier applies. Xcode Cloud comes with +its own free monthly build-hour allotment tied to the Apple Developer Program +and produces an archive that can go straight to TestFlight, something GitHub +Actions never did for this project. + +`CheatSheet.xcodeproj` is gitignored, so the first thing any CI provider needs +to do is regenerate it from `project.yml`. `ci_scripts/ci_post_clone.sh` does +that, then runs `Scripts/verify-build-sdk.sh`, +`Scripts/verify-project-config.sh`, and `Scripts/verify-localization.sh`, the +same fast, toolchain-independent checks the old GitHub Actions pipeline ran +before its simulator work. + +Xcode version selection is a workflow setting in App Store Connect, not a +repository file: open the workflow's Environment tab and choose a specific +Xcode build from the version dropdown, never "Latest Beta," for any workflow +that produces a submission archive. A `.xcode-version` file at the repository +root pins the version for local tooling and for opening the project in Xcode +directly; treat the Environment tab setting as the one that actually governs +an Xcode Cloud build, since real-world reports of Xcode Cloud not always +honoring that file mean it should not be relied on alone. + +## iOS 27 readiness: measured, not assumed + +Measured 2026-08-31 on Xcode 27.0 beta (build 27A5252f), iOS 27.0 SDK: + +- **The app compiles clean against the iOS 27 SDK.** `CheatSheetiOS` built + against `iPhoneSimulator27.0.sdk`: 91 Swift compile actions, **0 warnings, + 0 deprecations**. (Measured while the floor was still 18.0; the floor has + since risen to 26.0, which only removes code.) +- **Every Liquid Glass API the app uses survives iOS 27**, with no deprecation + or renaming: `GlassEffectContainer`, `glassEffect(_:in:)`, `Glass.tint(_:)`, + `Glass.interactive(_:)`, `.glass`, `.glassProminent`. +- `GlassButtonStyle.init(_ glass:)` is marked `@available(iOS 26.1, *)`, an + opt-in refinement the app does not currently need. + +**Caveat:** this is a beta SDK and can change before release. The finding means +there is no known iOS 27 migration debt today; it is not a guarantee. Re-run the +trial build against each new beta and against the GM. + +Reproduce the trial build: + +```sh +xcodegen generate --spec project.yml +DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer xcodebuild build \ + -project CheatSheet.xcodeproj -scheme CheatSheetiOS \ + -destination "id=$(Scripts/resolve-ios-simulator.sh)" \ + -derivedDataPath /tmp/CheatSheet-iOS27-DD CODE_SIGNING_ALLOWED=NO +``` + +## Branching + +The iOS 26 line stays shippable at all times; iOS 27 work happens beside it. +This sits on top of the single-branch flow in `CONTRIBUTING.md`. + +| Branch | Floor | SDK contract | Purpose | +| -------------------------- | ----- | ------------ | -------------------------------------------- | +| `main` | 26.0 | 26 | Released and next release. Always submittable. | +| `feature/ios-27-readiness` | 26.0 | 27 | Long-lived OS adoption branch off `main`. | + +**Why a feature branch and not a permanent parallel branch.** A permanent +`ios-27` branch rots: it accumulates conflicts against every release, and the +merge back becomes a big-bang event exactly when time is shortest. Keeping it as +a long-lived but *thin* feature branch keeps the merge cheap. + +Rules for the readiness branch: + +- Keep it thin. Only the SDK contract, CI variant, and genuinely 27-gated + adoption belong on it. Ordinary features go to `main` as usual. +- Merge `main` into it on a regular cadence (weekly, or after every merge to + `main`). Never let it drift. +- It may be red while a beta is broken. `main` may never be red. + +Note that adopting the iOS 27 **SDK** does not imply an iOS 27 **floor**. Those +are separate decisions, governed by the rule above. + +## Phases + +**Phase 0: baseline (done, 2026-08-31).** iOS 26 SDK contract made explicit and +enforced in CI. iOS 27 trial build proves zero migration debt. + +**Phase 1: modern-only floor (done, 2026-09-01).** Floor raised to iOS 26.0 / +macOS 26.0, all availability gates removed, test-runtime floor set to iOS 26.5, +CI moved to the `macos-26` runner so the macOS job can host a macOS 26 app. + +**Phase 2: beta season (now until iOS 27 GM).** Create +`feature/ios-27-readiness` off `main` when there is something to put on it. +On that branch set `CHEATSHEET_EXPECTED_SDK_MAJOR=27` as an environment +variable on a dedicated Xcode Cloud workflow for the branch, and set that +workflow's Environment tab to the Xcode 27 beta. Re-run the trial build +against each beta. `main` does not move. + +**Phase 3: iOS 27 released, Xcode 27 released.** Flip the SDK contract on +`main`: `CHEATSHEET_EXPECTED_SDK_MAJOR` default `26` → `27`, the `.xcode-version` +file and the Environment tab on the Xcode Cloud workflows +`26` → `27`, and update the support matrix above plus `README.md` and +`CONTRIBUTING.md`. Merge `feature/ios-27-readiness` into `main`, run the full device sweep, and tag `main`. Leave the floor at 26.0 +unless a needed feature forces it higher. + +## Checklist for the day iOS 27 ships + +1. Install the released Xcode 27; confirm `Scripts/verify-build-sdk.sh` passes + with `CHEATSHEET_EXPECTED_SDK_MAJOR=27`. +2. Flip the SDK contract on `main` (see Phase 3) and merge the readiness branch. +3. Regenerate the project and run the full suite: `Scripts/verify-macos.sh`, + iOS unit tests, iOS UI tests. +4. Verify the widgets on an iOS 27 device: App Group sharing and timeline + reloads are the parts most likely to regress across a major release. +5. Check Liquid Glass rendering on 27 against the 26 screenshots. +6. Confirm the app still installs on a simulator at the declared floor (iOS 26.0), + not only on the newest runtime. +7. Bump the build number, archive, and confirm the archive's SDK before upload. diff --git a/Docs/press-kit-1.2.md b/Docs/press-kit-1.2.md new file mode 100644 index 0000000..4b00332 --- /dev/null +++ b/Docs/press-kit-1.2.md @@ -0,0 +1,144 @@ +# CheatSheet: Press Kit & Launch Materials (1.2) + +Copy-paste materials for a press kit page, journalist outreach, Product Hunt, +Apple's editorial nomination, and social launch posts. Every claim below is +verified against the current codebase. + +## Fact sheet + +| | | +| --- | --- | +| Name | CheatSheet | +| Tagline | Notes & commands, at hand | +| One-liner | An open-source notes app for macOS, iOS, and iPadOS that keeps your everyday commands and checklists one glance away. | +| Developer | Wesley Keetch | +| Platforms | macOS, iOS, iPadOS (+ WidgetKit widgets on all three) | +| Price | Free (adjust if you've set a price in App Store Connect) | +| Category | Productivity | +| Version | 1.2 | +| License | MIT, open source | +| Website / source | https://github.com/weskcode/cheatsheet | +| Support | https://github.com/weskcode/cheatsheet/issues | +| Email | weskcode@duck.com | +| Icon | `CheatSheetApp/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024.png` | +| Screenshots | `AppStoreScreenshots/final/` (9 images) and `Docs/Images/` (5 marketing photos) | + +## Boilerplate (for a press kit "About" section or article footer) + +``` +CheatSheet is a free, open-source notes app for macOS, iOS, and iPadOS built +around one idea: the commands and checklists you use every day should be one +glance away, not buried in a search history. It auto-formats plain-text +headings, bullets, and checkboxes as you type, pins a note to a Home Screen +or menu bar widget, and stores everything locally with no account and no +ads. The only network calls are a few anonymous, aggregate analytics +signals that never include note content. CheatSheet is free and open +source under the MIT license. +``` + +## Elevator pitch (one sentence) + +``` +CheatSheet is the notes app for people who keep forgetting the same git +command: type it once, pin it to a widget, never look it up again. +``` + +## Journalist / newsletter pitch email + +``` +Subject: CheatSheet, an open-source cheat-sheet app that never phones home + +Hi [name], + +I'm launching CheatSheet 1.2, a free, open-source notes app for macOS, iOS, +and iPadOS built for one specific habit: looking up the same git command, +keyboard shortcut, or checklist over and over. + +What makes it different from a generic notes app: +- Plain text auto-formats into headings, bullets, and checkboxes as you type +- Pin one note to a Home Screen or menu bar widget so it's visible without + opening the app +- No account, no ads, and only anonymous, non-tracking usage analytics + (verified in the source, since the whole app is MIT-licensed and open + source) +- Native on Mac, iPhone, and iPad, with an adaptive layout on each + +I'd love for you to try it. I'm happy to send a TestFlight link, or it's +already live on the App Store: [App Store link]. + +Thanks for your time, +Wesley +``` + +## Product Hunt + +**Tagline (60 char max):** +``` +The cheat sheet app for commands you keep forgetting +``` + +**Maker's first comment:** +``` +Hey Product Hunt 👋 + +I built CheatSheet because I was tired of re-Googling the same `git +rebase` incantation every few weeks. It's a small, native notes app for +Mac, iPhone, and iPad that auto-formats plain text into checklists and +headings, and lets you pin one note to a widget so the thing you always +forget is just... visible. + +It's free, open source (MIT), and keeps everything on your device. The +only network calls are a few anonymous, non-tracking usage signals. Would +love your feedback, especially on what other "I keep forgetting this" use +cases it should cover. +``` + +## Apple editorial nomination (App Store Connect → "Nominate for Editorial Consideration") + +``` +CheatSheet is a native, open-source notes app for Mac, iPhone, and iPad +built around a narrow, well-solved problem: the commands and checklists a +developer or power user looks up again and again. Plain text auto-formats +into headings, bullets, and checkboxes with no markdown syntax to learn, one +note can be pinned to a Home Screen or menu bar widget, and the app keeps +everything on-device: no account, no ads, and only anonymous, non-tracking +usage analytics. It's fully native SwiftUI, built for iOS 26 and macOS 26, +with adaptive layouts for each device and full Spanish localization. +``` + +## Social launch posts + +**Short (X / Mastodon, ~280 chars):** +``` +Shipped CheatSheet 1.2 today: a free, open-source notes app for Mac, +iPhone, and iPad. Type plain text, get real checklists. Pin one note to a +widget. No account, no ads, ever. Now with Spanish localization and iPad +fixes. 🔗 [App Store link] +``` + +**Longer (LinkedIn / blog-style):** +``` +I just shipped CheatSheet 1.2. + +CheatSheet is a small, native notes app for Mac, iPhone, and iPad built +around one habit: looking up the same command or checklist over and over. +Write plain text, it auto-formats into headings and checkboxes as you go. +Pin one note to a widget so it's visible without opening the app. + +It's free, open source under the MIT license, with no account and no ads. +Only a few anonymous, non-tracking analytics signals leave the device, +your notes never do. + +1.2 adds Spanish localization, fixes a handful of iPad-specific bugs, and +is now built for iOS 26 and macOS 26. + +[App Store link] · [GitHub link] +``` + +## What NOT to claim + +Keep every public claim inside what's actually true of this build: no +review counts, ratings, "#1", or award language until they're real. The +open-source and on-device-storage claims above are directly verifiable in +the source, so lead with them. Don't claim "zero network calls": the app +sends anonymous, non-tracking analytics signals via TelemetryDeck. diff --git a/Docs/testflight-1.2-what-to-test.md b/Docs/testflight-1.2-what-to-test.md new file mode 100644 index 0000000..3865aa2 --- /dev/null +++ b/Docs/testflight-1.2-what-to-test.md @@ -0,0 +1,30 @@ +# TestFlight "What to Test" (1.2, build 14) + +Paste into App Store Connect → TestFlight → (build) → **Test Information → +What to Test**. Validated at 1,659 / 4,000 characters. + +``` +Hi! Thanks for testing CheatSheet 1.2. Here's what changed and what to check: + +WHAT'S NEW +- Spanish localization: switch your device language to Spanish and confirm the app, onboarding, and widget read correctly. +- iPad fixes: search, Trash, and note navigation had layout bugs on iPad's split-view that are now fixed. Please try: searching from the sidebar, moving a note to Trash and restoring it, and permanently deleting a note from Trash. +- Heading fix: a note titled, say, "Swift Basics" with a body starting "# Swift Basics" used to show the title twice in the note list. Also, headings ending in # (like "# C#" or "# F#") used to lose the trailing #. Both are fixed. Check your existing notes look right, especially any with a heading ending in #. +- Now built for iOS 26 / macOS 26. + +PLEASE TEST +1. Create a note, add a heading (#), a bullet (-), and a checklist item (- [ ] and - [x]). Confirm they render correctly in the list preview and the widget. +2. Pin a note and add the CheatSheet widget to your Home Screen (iOS/iPadOS) or widget gallery (macOS). Confirm it shows the pinned note and updates when you edit it. +3. Move a note to Trash, restore it, then permanently delete a different one. Confirm both work and the note list updates immediately. +4. Try search on whichever device you have: iPhone, iPad, and Mac all have different layouts. +5. Switch color and font style on a note and confirm the widget picks up the change. +6. If you can, briefly test in Spanish (Settings > General > Language & Region). + +Found a bug or something confusing? Please include your device model and iOS/macOS version when you report it. Thanks for helping test! +``` + +## Shorter version (if you want a lighter-weight message) + +``` +CheatSheet 1.2: added Spanish localization, fixed iPad search/Trash/navigation bugs, and fixed a heading bug that dropped a trailing # (e.g. "C#"). Please test: creating a note with headings/checklists, pinning a note to the widget, Trash restore/delete, and search on your device. Report bugs with your device model + OS version. Thanks! +``` diff --git a/PRIVACY.md b/PRIVACY.md index 31d4e2c..93678e7 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,7 +4,9 @@ Effective August 26, 2026 CheatSheet does not collect, transmit, sell, or share personal data. -Notes, preferences, and the selected widget note are stored locally in the app's sandbox and Apple App Group container. CheatSheet has no accounts, analytics, advertising, tracking, third-party SDKs, or network service. The app does not request access to location, contacts, photos, camera, microphone, or other sensitive system data. +Notes, preferences, and the selected widget note are stored locally in the app's sandbox and Apple App Group container. CheatSheet has no accounts, advertising, or tracking. The app does not request access to location, contacts, photos, camera, microphone, or other sensitive system data. + +CheatSheet uses TelemetryDeck, a third-party analytics service, to send a small number of anonymous, aggregate usage signals (for example, that a note was pinned). These signals never include note content, are not linked to your identity, and are not used to track you across other companies' apps or websites. Deleting the app removes its local app data according to the platform's normal behavior. Content may remain in operating-system backups controlled by the user and Apple. @@ -14,4 +16,6 @@ The source code is publicly available under the MIT License. Privacy-relevant ch Support: +Email: + Source: diff --git a/README.md b/README.md index e0b49b9..e88e929 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,26 @@ CheatSheet includes WidgetKit extensions for macOS, iOS, and iPadOS. Widgets fol - Use adaptive navigation for compact iPhone layouts and split-view iPad and Mac layouts. - Store notes locally with SwiftData and app-group persistence; no account or network connection is required. - See recoverable storage failures in the app and retry loading without overwriting stored notes. -- Use native Liquid Glass controls on macOS and iOS/iPadOS 26 with system-material fallbacks on earlier supported releases. +- Use native Liquid Glass controls throughout on macOS and iOS/iPadOS 26. - Use semantic text styles, accessibility labels, and non-color selection indicators. +- Available in English and Spanish, with locale-aware date and time formatting throughout. + +## Localization + +CheatSheet ships fully localized in English and neutral international Spanish +(`es`), including onboarding, the first-run starter notes, accessibility labels, +error messages, and the WidgetKit extension. Strings live in String Catalogs: + +```text +CheatSheetApp/Resources/Localizable.xcstrings App and shared-model strings +CheatSheetWidgets/Resources/Localizable.xcstrings Widget extension strings +``` + +`Scripts/verify-localization.sh` checks both catalogs for missing translations, +mismatched `%@`/`%lld` placeholders between languages, and untranslated stable +keys. It runs as part of every Xcode Cloud build alongside +`Scripts/verify-project-config.sh`. Adding a new user-visible string requires +adding both an English and a Spanish entry to the relevant catalog. ## Project Layout @@ -65,8 +83,9 @@ project.yml XcodeGen project definition ## Requirements -- macOS 15 or later, or iOS/iPadOS 18 or later -- Xcode 26 or later +- macOS 26 or later, or iOS/iPadOS 26 or later +- Xcode 26 for submission builds. An Xcode 27 beta is fine for development only. + See the [OS support policy](Docs/os-support-policy.md). - XcodeGen Install XcodeGen with Homebrew: @@ -83,6 +102,13 @@ Generate the project: xcodegen generate ``` +Confirm the active toolchain ships the SDK this branch targets. Required before +archiving for the App Store: + +```sh +Scripts/verify-build-sdk.sh +``` + Build from the command line: ```sh @@ -109,11 +135,14 @@ xcodebuild test -project CheatSheet.xcodeproj -scheme CheatSheetiOS -destination ``` `Scripts/resolve-ios-simulator.sh` prints the newest available iPhone simulator -UDID. Prefer it over a device name: a bare `name=iPhone 16` destination resolves -to `OS:latest`, which fails once the installed runtime is newer than that device -(iOS 26 ships the iPhone 17 family, not the iPhone 16). +UDID that meets the minimum test runtime (iOS 26.5; override with +`CHEATSHEET_MIN_IOS_RUNTIME`). Prefer it over a device name: a bare +`name=iPhone 17` destination resolves to `OS:latest`, which fails once the +installed runtime is newer than that device. It fails loudly rather than falling +back to a runtime below the floor, so the suite cannot pass against a +configuration the app does not support. -To check the iPad layout, resolve the newest installed iPad simulator: +To check the iPad layout, resolve the newest qualifying iPad simulator: ```sh xcodebuild build -project CheatSheet.xcodeproj -scheme CheatSheetiOS -destination "id=$(Scripts/resolve-ios-simulator.sh ipad)" -derivedDataPath /tmp/CheatSheet-iPad-DD CODE_SIGNING_ALLOWED=NO @@ -147,7 +176,7 @@ After running the app once, add the CheatSheet widget from the macOS widget gall ## Privacy -CheatSheet stores notes locally on device. The app does not include analytics, accounts, sync, or network services. +CheatSheet stores notes locally on device. It has no accounts or sync. It uses TelemetryDeck to send a small number of anonymous, aggregate usage signals (for example, that a note was pinned); these never include note content and are not linked to your identity. See the public [Privacy Policy](PRIVACY.md) for the complete disclosure. ## Product Principles diff --git a/Scripts/resolve-ios-simulator.sh b/Scripts/resolve-ios-simulator.sh index e8210ea..6868aa2 100755 --- a/Scripts/resolve-ios-simulator.sh +++ b/Scripts/resolve-ios-simulator.sh @@ -1,12 +1,23 @@ #!/bin/zsh -# Prints the UDID of the newest available iPhone or iPad simulator. +# Prints the UDID of the newest available iPhone or iPad simulator that meets a +# minimum iOS runtime. # Usage: Scripts/resolve-ios-simulator.sh [iphone|ipad] # # A bare `-destination 'platform=iOS Simulator,name=iPhone 16'` resolves to -# OS:latest. When the installed runtime is newer than that device (for example -# iOS 26, which ships the iPhone 17 family), xcodebuild matches nothing and the -# build fails. Resolving a concrete UDID keeps CI and local runs working across -# Xcode upgrades. +# OS:latest. When the installed runtime is newer than that device, xcodebuild +# matches nothing and the build fails. Resolving a concrete UDID keeps CI and +# local runs working across Xcode upgrades. +# +# The floor matters as much as the pick: without it, the newest *available* +# runtime may still be older than the app supports, and the whole suite runs +# green against a configuration nobody ships. Below the floor we fail loudly +# rather than substituting an older runtime. +# +# There is a ceiling too. simctl keeps listing a beta OS runtime as available +# after you switch back to the release Xcode, and "newest wins" would then send +# every shipping test run onto the beta OS. Runtimes from a newer major than the +# active SDK are excluded, so the toolchain sets the ceiling and an iOS 27 +# branch needs no special case. set -euo pipefail @@ -20,6 +31,14 @@ case "$device_family" in ;; esac +min_runtime="${CHEATSHEET_MIN_IOS_RUNTIME:-26.5}" + +if ! sdk_version=$(xcrun --sdk iphonesimulator --show-sdk-version 2>/dev/null); then + print -u2 "error: no iOS simulator SDK in $(xcode-select -p)" + exit 2 +fi +sdk_major="${sdk_version%%.*}" + udid=$(xcrun simctl list devices available --json | python3 -c ' import json, re, sys @@ -29,21 +48,69 @@ def version(runtime): return (int(match.group(1)), int(match.group(2) or 0)) if match else (0, 0) -prefix = sys.argv[1] +def family(device, prefix): + # Match on deviceTypeIdentifier, not the display name: a simulator can be + # renamed to anything ("QA-Standard-17") and still be an iPhone. + identifier = device.get("deviceTypeIdentifier", "") + marker = "SimDeviceType." + if marker in identifier: + return identifier.split(marker, 1)[1].startswith(prefix) + return device["name"].startswith(prefix) + + +prefix, raw_floor, sdk_major = sys.argv[1], sys.argv[2], int(sys.argv[3]) + +parts = raw_floor.split(".") +if not (1 <= len(parts) <= 2) or not all(p.isdigit() for p in parts): + sys.stderr.write( + "error: CHEATSHEET_MIN_IOS_RUNTIME must look like 26 or 26.5, got %r\n" % raw_floor + ) + raise SystemExit(2) +floor = (int(parts[0]), int(parts[1]) if len(parts) > 1 else 0) + devices = json.load(sys.stdin)["devices"] +runtimes = {r: e for r, e in devices.items() if "iOS-" in r} + +# Rank: newest runtime, then prefer a stock-named device over a renamed one, +# then by name so the pick is reproducible. Renamed simulators are typically +# scratch devices and are likelier to be stale or mid-teardown. candidates = [ - (version(runtime), device["udid"]) - for runtime, entries in devices.items() - if "iOS" in runtime + (version(runtime), device["name"].startswith(prefix), device["name"], device["udid"]) + for runtime, entries in runtimes.items() for device in entries - if device.get("isAvailable") and device["name"].startswith(prefix) + if device.get("isAvailable") + and family(device, prefix) + and version(runtime) >= floor + and version(runtime)[0] <= sdk_major ] -print(max(candidates)[1] if candidates else "") -' "$device_prefix") -if [[ -z "$udid" ]]; then - print -u2 "error: no available $device_prefix simulator found" - exit 1 -fi +if candidates: + print(max(candidates)[3]) + raise SystemExit(0) + +lines = [ + "error: no %s simulator available on iOS %s or newer" % (prefix, raw_floor), + " required: iOS >= %s (override with CHEATSHEET_MIN_IOS_RUNTIME)" % raw_floor, + " ceiling: iOS major <= %d (the active simulator SDK)" % sdk_major, + " found, by runtime:", +] +if runtimes: + for runtime in sorted(runtimes, key=version, reverse=True): + matching = [d for d in runtimes[runtime] if d.get("isAvailable") and family(d, prefix)] + major, minor = version(runtime) + if major > sdk_major: + note = " (newer than the active SDK)" + elif (major, minor) < floor: + note = " (below floor)" + else: + note = "" + lines.append(" iOS %d.%d %d %s%s" % (major, minor, len(matching), prefix, note)) +else: + lines.append(" (no iOS runtimes installed)") +lines.append(" fix: install a runtime via Xcode > Settings > Components, then create a device:") +lines.append(" xcrun simctl create ") +sys.stderr.write("\n".join(lines) + "\n") +raise SystemExit(1) +' "$device_prefix" "$min_runtime" "$sdk_major") print "$udid" diff --git a/Scripts/verify-build-sdk.sh b/Scripts/verify-build-sdk.sh new file mode 100755 index 0000000..2761ed6 --- /dev/null +++ b/Scripts/verify-build-sdk.sh @@ -0,0 +1,74 @@ +#!/bin/zsh +# Asserts the active Xcode exposes the SDK major version this branch ships against. +# Usage: Scripts/verify-build-sdk.sh [--warn] +# +# App Store submissions must be built with a released SDK. A machine that also +# carries an Xcode beta will silently build against the beta SDK, and the +# rejection only surfaces at upload time. Run this before archiving. +# +# The expected major is a branch-level contract: the iOS 26 line pins 26, and an +# iOS 27 adoption branch overrides it with CHEATSHEET_EXPECTED_SDK_MAJOR=27. + +set -euo pipefail + +expected_major="${CHEATSHEET_EXPECTED_SDK_MAJOR:-26}" + +warn_only=0 +if [[ "${1:-}" == "--warn" ]]; then + warn_only=1 +elif [[ -n "${1:-}" ]]; then + print -u2 "error: unknown argument '${1}' (expected --warn or no argument)" + exit 2 +fi + +if ! [[ "$expected_major" == <-> ]]; then + print -u2 "error: CHEATSHEET_EXPECTED_SDK_MAJOR must be an integer, got '$expected_major'" + exit 2 +fi + +developer_dir=$(xcode-select -p) +# Take the first line without a pipe: `| head -n 1` can SIGPIPE xcodebuild, +# and pipefail would turn that race into an intermittent exit 141. +xcode_version=$(xcodebuild -version 2>/dev/null || true) +xcode_version="${xcode_version%%$'\n'*}" +: "${xcode_version:=unknown (xcodebuild unavailable)}" + +mismatches=() + +for sdk in macosx iphoneos iphonesimulator; do + if ! sdk_version=$(xcrun --sdk "$sdk" --show-sdk-version 2>/dev/null); then + mismatches+=("$sdk: SDK not installed in $developer_dir") + continue + fi + + sdk_major="${sdk_version%%.*}" + if [[ "$sdk_major" != "$expected_major" ]]; then + mismatches+=("$sdk: found $sdk_version, expected ${expected_major}.x") + fi +done + +if (( ${#mismatches} == 0 )); then + print "SDK check passed: ${xcode_version} exposes the expected ${expected_major}.x SDKs." + exit 0 +fi + +label="error" +(( warn_only )) && label="warning" + +print -u2 "${label}: active toolchain does not match the expected SDK major (${expected_major})." +print -u2 " Xcode: ${xcode_version}" +print -u2 " DEVELOPER_DIR: ${developer_dir}" +for mismatch in "${mismatches[@]}"; do + print -u2 " - ${mismatch}" +done + +if (( warn_only )); then + print -u2 "Continuing anyway (--warn). Do not archive for submission from this toolchain." + exit 0 +fi + +print -u2 "" +print -u2 "Select a matching Xcode before building for submission, for example:" +print -u2 " sudo xcode-select -s /Applications/Xcode.app" +print -u2 " DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer Scripts/verify-build-sdk.sh" +exit 1 diff --git a/Scripts/verify-localization.sh b/Scripts/verify-localization.sh new file mode 100755 index 0000000..a4bc8bf --- /dev/null +++ b/Scripts/verify-localization.sh @@ -0,0 +1,94 @@ +#!/bin/zsh +set -euo pipefail + +repo_root="${CHEATSHEET_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" + +cd "$repo_root" + +ruby <<'RUBY' +require "json" + +CATALOGS = { + "CheatSheetApp/Resources/Localizable.xcstrings" => Dir.glob("CheatSheetApp/Sources/*.swift") + %w[ + Shared/Sources/CheatSheetNote.swift + Shared/Sources/DisplayLine.swift + Shared/Sources/CheatSheetNote+Trash.swift + Shared/Sources/CheatSheetNoteRepository.swift + ], + "CheatSheetWidgets/Resources/Localizable.xcstrings" => Dir.glob("CheatSheetWidgets/Sources/*.swift") +}.freeze + +LANGUAGES = %w[en es].freeze +PLACEHOLDER_PATTERN = /%(?:\d+\$)?[@dlfsu]+/.freeze +STABLE_KEY_CALL_PATTERN = /String\(\s*localized:\s*"([a-zA-Z0-9.]+)",\s*defaultValue:/m.freeze +# Catches the common SwiftUI call sites (Text/Button/Label/Toggle) that take a +# literal string as their first argument, which becomes a LocalizedStringKey +# keyed by that literal text. Interpolated strings ("\(...)") aren't literal +# keys, so they're intentionally left to STABLE_KEY_CALL_PATTERN above. +LITERAL_UI_STRING_PATTERN = /\b(?:Text|Button|Label|Toggle)\(\s*"((?:[^"\\]|\\.)*)"/.freeze + +errors = [] + +CATALOGS.each do |catalog_path, source_files| + catalog = JSON.parse(File.read(catalog_path)) + + errors << "#{catalog_path}: sourceLanguage must be 'en'" unless catalog["sourceLanguage"] == "en" + + strings = catalog["strings"] || {} + errors << "#{catalog_path}: has no strings" if strings.empty? + + strings.each do |key, entry| + localizations = entry["localizations"] || {} + values = {} + + LANGUAGES.each do |language| + value = localizations.dig(language, "stringUnit", "value") + if value.nil? || value.empty? + errors << "#{catalog_path}: '#{key}' is missing a non-empty '#{language}' translation" + else + values[language] = value + end + end + + next unless values["en"] && values["es"] + + if key.include?(".") && values["es"] == key + errors << "#{catalog_path}: '#{key}' was not translated -- the Spanish value equals the raw key" + end + + en_placeholders = values["en"].scan(PLACEHOLDER_PATTERN).sort + es_placeholders = values["es"].scan(PLACEHOLDER_PATTERN).sort + if en_placeholders != es_placeholders + errors << "#{catalog_path}: '#{key}' has mismatched format specifiers " \ + "(en: #{en_placeholders.inspect}, es: #{es_placeholders.inspect})" + end + end + + source_files.each do |source_file| + source = File.read(source_file) + source.scan(STABLE_KEY_CALL_PATTERN).each do |(key)| + unless strings.key?(key) + errors << "#{source_file} references key '#{key}' which is missing from #{catalog_path}" + end + end + + source.scan(LITERAL_UI_STRING_PATTERN).each do |(raw_key)| + # SwiftUI's LocalizedStringKey folds each "\(...)" interpolation into a + # %@ format specifier before the catalog lookup, so "\(x) note color" + # is keyed as "%@ note color", not the raw interpolation source. + key = raw_key.gsub(/\\\(.*?\)/, "%@") + unless strings.key?(key) + errors << "#{source_file} references literal string '#{key}' which is missing from #{catalog_path}" + end + end + end +end + +unless errors.empty? + warn "Localization verification failed:" + errors.each { |message| warn " - #{message}" } + abort +end +RUBY + +echo "Localization verified." diff --git a/Scripts/verify-macos.sh b/Scripts/verify-macos.sh index 28dcef4..dadcb47 100755 --- a/Scripts/verify-macos.sh +++ b/Scripts/verify-macos.sh @@ -13,6 +13,10 @@ if ! command -v xcodegen >/dev/null 2>&1; then exit 1 fi +# Local machines may carry an Xcode beta. Warn rather than fail: beta-SDK +# builds are fine for development, but must never be archived for submission. +"$repo_root/Scripts/verify-build-sdk.sh" --warn + print "Generating a local project outside File Provider..." xcodegen generate \ --spec "$repo_root/project.yml" \ diff --git a/Scripts/verify-project-config.sh b/Scripts/verify-project-config.sh index e1d5bd6..50a3459 100755 --- a/Scripts/verify-project-config.sh +++ b/Scripts/verify-project-config.sh @@ -33,6 +33,26 @@ abort "archive preActions must not mutate project.yml" if project.include?("preA hardened_runtime_count = project.scan(/ENABLE_HARDENED_RUNTIME:\s*YES/).length abort "expected Hardened Runtime on macOS app and widget Release settings" unless hardened_runtime_count >= 2 +# The shipping floor is a contract. Nothing else in the build asserts it: the +# SDK check only sees the toolchain, and the simulator resolver only sees +# runtimes, so a silent regression to an older deployment target would pass +# every other gate. +minimum_floor = Gem::Version.new("26.0") +{ "iOS" => /^\s*iOS:\s*"([0-9.]+)"/, "macOS" => /^\s*macOS:\s*"([0-9.]+)"/ }.each do |platform, pattern| + match = project.match(pattern) + abort "project.yml must declare a #{platform} deployment target" if match.nil? + actual = Gem::Version.new(match[1]) + if actual < minimum_floor + abort "#{platform} deployment target #{actual} is below the supported floor #{minimum_floor}" + end +end + +# options.deploymentTarget.macOS is the single source of truth. A second +# MACOSX_DEPLOYMENT_TARGET in settings.base silently overrides it. +if project.include?("MACOSX_DEPLOYMENT_TARGET") + abort "remove MACOSX_DEPLOYMENT_TARGET from settings.base; options.deploymentTarget.macOS owns the macOS floor" +end + ios_group = "group.com.wesleykeetch.wesleycheatsheet" mac_group = "HD39MR492X.com.wesleykeetch.wesleycheatsheet" diff --git a/Shared/Sources/CheatSheetNote+Trash.swift b/Shared/Sources/CheatSheetNote+Trash.swift index 94062af..2805a1c 100644 --- a/Shared/Sources/CheatSheetNote+Trash.swift +++ b/Shared/Sources/CheatSheetNote+Trash.swift @@ -7,22 +7,28 @@ public extension CheatSheetNote { func trashStatusText(referenceDate: Date) -> String { guard let archivedAt else { - return "This note is not in Trash." + return String(localized: "trash.status.notInTrash", defaultValue: "This note is not in Trash.") } let deletionDate = archivedAt.addingTimeInterval(NoteTrashPolicy.retentionInterval) let remainingSeconds = deletionDate.timeIntervalSince(referenceDate) if remainingSeconds <= 0 { - return "This note is ready for permanent deletion." + return String( + localized: "trash.status.readyForDeletion", + defaultValue: "This note is ready for permanent deletion." + ) } let remainingDays = max(1, Int(ceil(remainingSeconds / NoteTrashPolicy.dayInterval))) if remainingDays == 1 { - return "Deletes automatically in 1 day." + return String(localized: "trash.status.oneDay", defaultValue: "Deletes automatically in 1 day.") } - return "Deletes automatically in \(remainingDays) days." + return String( + localized: "trash.status.manyDays", + defaultValue: "Deletes automatically in \(remainingDays) days." + ) } } diff --git a/Shared/Sources/CheatSheetNote.swift b/Shared/Sources/CheatSheetNote.swift index 3ecc2b2..3f94556 100644 --- a/Shared/Sources/CheatSheetNote.swift +++ b/Shared/Sources/CheatSheetNote.swift @@ -83,7 +83,12 @@ public struct CheatSheetNote: Codable, Identifiable, Equatable, Sendable { public var displayTitle: String { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "Untitled Note" : trimmed + return trimmed.isEmpty ? String(localized: "note.untitled", defaultValue: "Untitled Note") : trimmed + } + + /// Subtitle shown beneath the title in list rows. + public var previewLine: String { + body.notePreviewLine(skippingLeadingTitle: displayTitle) } public var fontStyle: CheatSheetFontStyle { @@ -108,8 +113,8 @@ public struct CheatSheetNote: Codable, Identifiable, Equatable, Sendable { public extension CheatSheetNote { static let starterNotes: [CheatSheetNote] = [ CheatSheetNote( - title: "Widget Formatting Demo", - body: """ + title: String(localized: "starterNote.widgetDemo.title", defaultValue: "Widget Formatting Demo"), + body: String(localized: "starterNote.widgetDemo.body", defaultValue: """ # Widget Formatting Demo Plain lines show as simple reminder text. - Bullets become quick checklist rows. @@ -119,20 +124,20 @@ public extension CheatSheetNote { - Pin one note to show it in the widget. - Pick a color and font above the editor. - Short lines read best on the desktop. - """, + """), tintHex: CheatSheetPalette.indigo.rawValue, isPinned: true ), CheatSheetNote( - title: "Git Flow", - body: """ + title: String(localized: "starterNote.gitFlow.title", defaultValue: "Git Flow"), + body: String(localized: "starterNote.gitFlow.body", defaultValue: """ # Git Flow - git status - git switch -c feature/name - git add . - git commit -m "Describe change" - git push -u origin HEAD - """, + """), tintHex: CheatSheetPalette.cyan.rawValue ) ] @@ -164,16 +169,16 @@ public enum CheatSheetPalette: String, CaseIterable, Identifiable, Sendable { public var displayName: String { switch self { - case .blue: "Blue" - case .cyan: "Cyan" - case .violet: "Violet" - case .mint: "Mint" - case .lime: "Lime" - case .amber: "Amber" - case .coral: "Coral" - case .rose: "Rose" - case .indigo: "Indigo" - case .graphite: "Graphite" + case .blue: String(localized: "colorName.blue", defaultValue: "Blue") + case .cyan: String(localized: "colorName.cyan", defaultValue: "Cyan") + case .violet: String(localized: "colorName.violet", defaultValue: "Violet") + case .mint: String(localized: "colorName.mint", defaultValue: "Mint") + case .lime: String(localized: "colorName.lime", defaultValue: "Lime") + case .amber: String(localized: "colorName.amber", defaultValue: "Amber") + case .coral: String(localized: "colorName.coral", defaultValue: "Coral") + case .rose: String(localized: "colorName.rose", defaultValue: "Rose") + case .indigo: String(localized: "colorName.indigo", defaultValue: "Indigo") + case .graphite: String(localized: "colorName.graphite", defaultValue: "Graphite") } } @@ -204,10 +209,10 @@ public enum CheatSheetFontStyle: String, CaseIterable, Identifiable, Codable, Se public var displayName: String { switch self { - case .monospaced: "Mono" - case .system: "System" - case .rounded: "Rounded" - case .serif: "Serif" + case .monospaced: String(localized: "fontStyle.monospaced", defaultValue: "Mono") + case .system: String(localized: "fontStyle.system", defaultValue: "System") + case .rounded: String(localized: "fontStyle.rounded", defaultValue: "Rounded") + case .serif: String(localized: "fontStyle.serif", defaultValue: "Serif") } } @@ -221,6 +226,48 @@ public enum CheatSheetFontStyle: String, CaseIterable, Identifiable, Codable, Se } } +/// App-wide note text size, shared between the app and widget via the App Group +/// suite so a change in one reflects in the other. +public enum CheatSheetFontSize: String, CaseIterable, Identifiable, Codable, Sendable { + case small + case medium + case large + + public static let storageKey = "cheatSheet.noteFontSize" + + public static var sharedDefaults: UserDefaults { + (try? CheatSheetAppGroup.defaults()) ?? .standard + } + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .small: String(localized: "fontSize.small", defaultValue: "Small") + case .medium: String(localized: "fontSize.medium", defaultValue: "Medium") + case .large: String(localized: "fontSize.large", defaultValue: "Large") + } + } + + /// Text style used for note body copy in the app's editor. + public var bodyTextStyle: Font.TextStyle { + switch self { + case .small: .callout + case .medium: .body + case .large: .title3 + } + } + + /// Text style used for note lines rendered inside the widget. + public var widgetLineTextStyle: Font.TextStyle { + switch self { + case .small: .caption2 + case .medium: .caption + case .large: .footnote + } + } +} + public extension Color { init(hex: String) { let scanner = Scanner(string: CheatSheetPalette.normalizedHex(hex)) diff --git a/Shared/Sources/CheatSheetNoteRepository.swift b/Shared/Sources/CheatSheetNoteRepository.swift index f9956ba..cb20591 100644 --- a/Shared/Sources/CheatSheetNoteRepository.swift +++ b/Shared/Sources/CheatSheetNoteRepository.swift @@ -16,17 +16,23 @@ public enum CheatSheetStorageError: Error, Equatable, LocalizedError { public var errorDescription: String? { switch self { case let .appGroupUnavailable(identifier): - "App Group is unavailable: \(identifier)" + String(localized: "storageError.appGroupUnavailable", defaultValue: "App Group is unavailable: \(identifier)") case .repositoryUnavailable: - "Note storage is unavailable." + String(localized: "storageError.repositoryUnavailable", defaultValue: "Note storage is unavailable.") case .noteEncodingFailed: - "Could not encode notes for storage." + String(localized: "storageError.noteEncodingFailed", defaultValue: "Could not encode notes for storage.") case .noteDecodingFailed: - "Stored notes could not be read." + String(localized: "storageError.noteDecodingFailed", defaultValue: "Stored notes could not be read.") case .widgetSnapshotDecodingFailed: - "The widget note snapshot could not be read." + String( + localized: "storageError.widgetSnapshotDecodingFailed", + defaultValue: "The widget note snapshot could not be read." + ) case .widgetSnapshotEncodingFailed: - "Could not encode the widget note snapshot." + String( + localized: "storageError.widgetSnapshotEncodingFailed", + defaultValue: "Could not encode the widget note snapshot." + ) } } } @@ -41,6 +47,8 @@ public enum CheatSheetAppGroup { } } +// @unchecked: `UserDefaults` is a thread-safe class; this struct's other +// stored property is an immutable `String`. public struct UserDefaultsCheatSheetNoteRepository: CheatSheetNoteRepository, @unchecked Sendable { private let defaults: UserDefaults private let notesKey: String @@ -97,6 +105,7 @@ public struct UnavailableCheatSheetNoteRepository: CheatSheetNoteRepository { } } +// @unchecked: same reasoning as `UserDefaultsCheatSheetNoteRepository` above. public struct WidgetNoteSnapshotRepository: @unchecked Sendable { private let defaults: UserDefaults private let noteKey: String @@ -139,6 +148,7 @@ public struct WidgetNoteSnapshotRepository: @unchecked Sendable { } } +// @unchecked: same reasoning as `UserDefaultsCheatSheetNoteRepository` above. public struct CheatSheetStoreMetadataRepository: @unchecked Sendable { private let defaults: UserDefaults private let initializedKey: String diff --git a/Shared/Sources/DisplayLine.swift b/Shared/Sources/DisplayLine.swift index 2c2580f..9a28046 100644 --- a/Shared/Sources/DisplayLine.swift +++ b/Shared/Sources/DisplayLine.swift @@ -39,14 +39,36 @@ public extension CheatSheetNote { public extension String { var notePreviewLine: String { + notePreviewLine(skippingLeadingTitle: nil) + } + + /// The row subtitle for this body. + /// + /// Pass the note's title to skip a leading heading that merely repeats it. + /// Opening a note with `# ` is the natural thing to write -- + /// both shipped starter notes do it -- and without this the row renders the + /// same string twice, which reads as placeholder data. Only an exact match + /// is skipped, so a heading that carries real information is still shown. + func notePreviewLine(skippingLeadingTitle title: String?) -> String { + var hasSkippedTitleHeading = false + for rawLine in split(whereSeparator: \.isNewline) { - let text = String(rawLine).parsedChecklistLine.text - if !text.isEmpty { - return text + let parsed = String(rawLine).parsedChecklistLine + let text = parsed.text + if text.isEmpty { continue } + + if !hasSkippedTitleHeading, + parsed.isHeading, + let title, + text.caseInsensitiveCompare(title) == .orderedSame { + hasSkippedTitleHeading = true + continue } + + return text } - return "Empty note" + return String(localized: "note.previewEmpty", defaultValue: "Empty note") } var parsedChecklistLine: (text: String, isTask: Bool, isComplete: Bool, isHeading: Bool) { @@ -57,7 +79,10 @@ public extension String { if line.hasPrefix("#") { isHeading = true - line = line.trimmingCharacters(in: CharacterSet(charactersIn: "# ")) + // Strip only the LEADING hashes. trimmingCharacters(in:) works on both + // ends, which silently ate the trailing "#" of headings like "# C#" + // and "# F#" -- on a developer cheat sheet those are content. + line = String(line.drop(while: { $0 == "#" })).trimmingCharacters(in: .whitespaces) } let markers = ["- [x] ", "* [x] ", "[x] ", "- [X] ", "* [X] ", "[X] "] diff --git a/Shared/Sources/SwiftDataCheatSheetNoteRepository.swift b/Shared/Sources/SwiftDataCheatSheetNoteRepository.swift index 3cd315c..f1f4a58 100644 --- a/Shared/Sources/SwiftDataCheatSheetNoteRepository.swift +++ b/Shared/Sources/SwiftDataCheatSheetNoteRepository.swift @@ -1,6 +1,9 @@ import Foundation +import OSLog import SwiftData +private let repositoryLogger = Logger(subsystem: "com.wesleykeetch.wesleycheatsheet", category: "Persistence") + public enum CheatSheetNoteRepositoryFactory { public static func live() -> any CheatSheetNoteRepository { let metadataRepository = try? CheatSheetStoreMetadataRepository.appGroup() @@ -33,6 +36,9 @@ public enum CheatSheetNoteRepositoryFactory { } } +// @unchecked: `ModelContainer` itself is `Sendable`; every `ModelContext` used +// here is created fresh per call rather than stored, so no non-Sendable state +// crosses actor boundaries. public final class SwiftDataCheatSheetNoteRepository: CheatSheetNoteRepository, @unchecked Sendable { private let container: ModelContainer private let legacyRepository: (any CheatSheetNoteRepository)? @@ -107,7 +113,17 @@ public final class SwiftDataCheatSheetNoteRepository: CheatSheetNoteRepository, duplicateExistingNotes.forEach(context.delete) try context.save() - try widgetSnapshotRepository?.saveNote(notes.widgetDisplayNote) + + // The notes themselves are durably saved at this point. A failure to + // also update the widget's snapshot is a lesser, best-effort failure + // that must not be reported to the user as "your note didn't save" -- + // it did -- nor skip marking the store initialized below. + do { + try widgetSnapshotRepository?.saveNote(notes.widgetDisplayNote) + } catch { + repositoryLogger.error("Failed to update widget snapshot: \(error.localizedDescription, privacy: .private)") + } + metadataRepository?.markSwiftDataStoreInitialized() } diff --git a/ci_scripts/Package.resolved b/ci_scripts/Package.resolved new file mode 100644 index 0000000..adc6da1 --- /dev/null +++ b/ci_scripts/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "fa7ab4bc9518bd495b4c17d8a96bebc401eb5a2f99ae8b4bc316b04b7b2aea05", + "pins" : [ + { + "identity" : "swiftsdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TelemetryDeck/SwiftSDK", + "state" : { + "revision" : "58f436299d3f6710bcedc18aff26480aaf1879fc", + "version" : "2.14.2" + } + } + ], + "version" : 3 +} diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh new file mode 100755 index 0000000..40e0fd7 --- /dev/null +++ b/ci_scripts/ci_post_clone.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Xcode Cloud post-clone hook. +# +# CheatSheet.xcodeproj is gitignored (XcodeGen output, regenerated from +# project.yml; see the top of .gitignore). Xcode Cloud clones the repository +# fresh on every build and has no project file at all until this step +# generates one. +# +# Unlike a shared CI runner image carrying several Xcode installs side by +# side, an Xcode Cloud build machine has exactly one active Xcode version: +# whatever the workflow's Environment tab selected. There is nothing to pick +# here, only to verify. +set -e + +if ! command -v xcodegen >/dev/null 2>&1; then + brew install xcodegen +fi + +cd "$CI_PRIMARY_REPOSITORY_PATH" +xcodegen generate --spec project.yml --project . --project-root . + +# Xcode Cloud disables automatic SPM resolution and requires a resolved +# manifest to already exist at this path before it will build +# (https://developer.apple.com/documentation/xcode/making-dependencies-available-to-xcode-cloud). +# CheatSheet.xcodeproj is gitignored, so this pin can't live at its normal +# location in the repo; keep the source of truth in ci_scripts/ and copy it +# into place right after the project is generated. +mkdir -p CheatSheet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm +cp ci_scripts/Package.resolved CheatSheet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + +# Fail loudly if the workflow's Environment tab picked an SDK major CheatSheet +# does not expect (a beta, or a version this branch has not been updated for), +# rather than silently archiving something that will bounce at App Store +# upload. Override with CHEATSHEET_EXPECTED_SDK_MAJOR as an environment +# variable on the workflow if a branch intentionally targets a different SDK. +Scripts/verify-build-sdk.sh + +# Cheap, toolchain-independent checks that used to run before the simulator +# work in the retired GitHub Actions pipeline. Keep failing fast on these +# rather than only finding out after a build and archive have run. +Scripts/verify-project-config.sh + +if [ -x Scripts/verify-localization.sh ]; then + Scripts/verify-localization.sh +fi diff --git a/project.yml b/project.yml index f849988..b0fa7b2 100644 --- a/project.yml +++ b/project.yml @@ -1,18 +1,21 @@ name: CheatSheet +packages: + TelemetryDeck: + url: https://github.com/TelemetryDeck/SwiftSDK + from: "2.14.2" options: bundleIdPrefix: com.wesleykeetch deploymentTarget: - macOS: "15.0" - iOS: "18.0" + macOS: "26.0" + iOS: "26.0" createIntermediateGroups: true settings: base: SWIFT_VERSION: "6.0" - MACOSX_DEPLOYMENT_TARGET: "15.0" GENERATE_INFOPLIST_FILE: YES ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor - MARKETING_VERSION: "1.1" - CURRENT_PROJECT_VERSION: "13" + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "16" VERSIONING_SYSTEM: apple-generic targets: CheatSheet: @@ -37,6 +40,7 @@ targets: dependencies: - target: CheatSheetWidgets embed: true + - package: TelemetryDeck entitlements: path: CheatSheetApp/CheatSheet.entitlements properties: @@ -125,6 +129,10 @@ targets: - path: CheatSheetTests/Sources - path: Shared/Sources - path: CheatSheetApp/Sources/NoteStore.swift + - path: CheatSheetApp/Sources/ScreenshotDemoContent.swift + - path: CheatSheetApp/Sources/AnalyticsService.swift + dependencies: + - package: TelemetryDeck settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.wesleykeetch.wesleycheatsheet.tests @@ -163,6 +171,7 @@ targets: dependencies: - target: CheatSheetiOSWidgets embed: true + - package: TelemetryDeck entitlements: path: CheatSheetApp/CheatSheet-iOS.entitlements properties: @@ -184,6 +193,10 @@ targets: - path: CheatSheetTests/Sources - path: Shared/Sources - path: CheatSheetApp/Sources/NoteStore.swift + - path: CheatSheetApp/Sources/ScreenshotDemoContent.swift + - path: CheatSheetApp/Sources/AnalyticsService.swift + dependencies: + - package: TelemetryDeck settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.wesleykeetch.wesleycheatsheet.ios.tests