diff --git a/.gitignore b/.gitignore index 1e4693e..ebacaa8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,8 @@ 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/ 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..80435ac --- /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` | — | +| `{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 | — | +| `{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` in CI | `CheatSheetApp/Resources/Localizable.xcstrings`, `CheatSheetWidgets/Resources/Localizable.xcstrings`, `.github/workflows/ci.yml` | + +## 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..cfe2c0f --- /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..4d771df --- /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..22ba68c --- /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..516b529 --- /dev/null +++ b/AppStoreScreenshots/templates/compose_screenshot.py @@ -0,0 +1,210 @@ +#!/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 +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. + bezel_radius = int(phone_w * (0.115 if device == "iphone" else 0.06)) + 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.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)) + 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 + + +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"]) + args = p.parse_args() + w, h = (int(v) for v in args.canvas_size.split("x")) + out = compose(args.screenshot, args.eyebrow, args.headline, w, h, args.out, args.device) + print("wrote", out) diff --git a/CheatSheetApp/Resources/Localizable.xcstrings b/CheatSheetApp/Resources/Localizable.xcstrings new file mode 100644 index 0000000..23e02bc --- /dev/null +++ b/CheatSheetApp/Resources/Localizable.xcstrings @@ -0,0 +1,1831 @@ +{ + "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" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "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/Sources/CheatSheetLaunchEnvironment.swift b/CheatSheetApp/Sources/CheatSheetLaunchEnvironment.swift index c20ff64..2364652 100644 --- a/CheatSheetApp/Sources/CheatSheetLaunchEnvironment.swift +++ b/CheatSheetApp/Sources/CheatSheetLaunchEnvironment.swift @@ -10,11 +10,29 @@ 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..9580889 100644 --- a/CheatSheetApp/Sources/ChecklistLineView.swift +++ b/CheatSheetApp/Sources/ChecklistLineView.swift @@ -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..fcb4803 100644 --- a/CheatSheetApp/Sources/ContentView.swift +++ b/CheatSheetApp/Sources/ContentView.swift @@ -10,7 +10,7 @@ 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] = [] var body: some View { diff --git a/CheatSheetApp/Sources/MenuBarQuickAccessView.swift b/CheatSheetApp/Sources/MenuBarQuickAccessView.swift index 0190226..d682d29 100644 --- a/CheatSheetApp/Sources/MenuBarQuickAccessView.swift +++ b/CheatSheetApp/Sources/MenuBarQuickAccessView.swift @@ -185,7 +185,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) }) diff --git a/CheatSheetApp/Sources/NoteStore.swift b/CheatSheetApp/Sources/NoteStore.swift index 3006530..c78b7a3 100644 --- a/CheatSheetApp/Sources/NoteStore.swift +++ b/CheatSheetApp/Sources/NoteStore.swift @@ -153,10 +153,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( diff --git a/CheatSheetApp/Sources/OnboardingStepRow.swift b/CheatSheetApp/Sources/OnboardingStepRow.swift index 5dbcd96..a727145 100644 --- a/CheatSheetApp/Sources/OnboardingStepRow.swift +++ b/CheatSheetApp/Sources/OnboardingStepRow.swift @@ -2,8 +2,8 @@ import SwiftUI struct OnboardingStepRow: View { let symbol: String - let title: String - let description: String + let title: LocalizedStringKey + let description: LocalizedStringKey var body: some View { HStack(alignment: .top, spacing: 12) { diff --git a/CheatSheetApp/Sources/PersistenceStatusBanner.swift b/CheatSheetApp/Sources/PersistenceStatusBanner.swift index 02f5530..5a363b0 100644 --- a/CheatSheetApp/Sources/PersistenceStatusBanner.swift +++ b/CheatSheetApp/Sources/PersistenceStatusBanner.swift @@ -46,17 +46,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..a2279ad --- /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/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/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..956ec27 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 @@ -191,6 +195,48 @@ 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." + ) + } + private func revealSearchField(in app: XCUIApplication) -> XCUIElement { var field = app.searchFields.firstMatch if !field.waitForExistence(timeout: 2) { @@ -199,4 +245,221 @@ final class CheatSheetiOSUITests: XCTestCase { } return field } + + // 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") + + 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() + XCTAssertFalse(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() + } + 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/CheatSheetWidgets/Resources/Localizable.xcstrings b/CheatSheetWidgets/Resources/Localizable.xcstrings new file mode 100644 index 0000000..d392fea --- /dev/null +++ b/CheatSheetWidgets/Resources/Localizable.xcstrings @@ -0,0 +1,114 @@ +{ + "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" + } + } + } + } + }, + "version" : "1.1" +} diff --git a/CheatSheetWidgets/Sources/CheatSheetWidgetView.swift b/CheatSheetWidgets/Sources/CheatSheetWidgetView.swift index 2b71636..9394a4c 100644 --- a/CheatSheetWidgets/Sources/CheatSheetWidgetView.swift +++ b/CheatSheetWidgets/Sources/CheatSheetWidgetView.swift @@ -40,7 +40,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 diff --git a/CheatSheetWidgets/Sources/WidgetLineView.swift b/CheatSheetWidgets/Sources/WidgetLineView.swift index 0106b0d..983bbb1 100644 --- a/CheatSheetWidgets/Sources/WidgetLineView.swift +++ b/CheatSheetWidgets/Sources/WidgetLineView.swift @@ -27,7 +27,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/app-store-localization-es.md b/Docs/app-store-localization-es.md new file mode 100644 index 0000000..8abaab6 --- /dev/null +++ b/Docs/app-store-localization-es.md @@ -0,0 +1,107 @@ +# 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 for Spanish-speaking users, that means translating +`PRIVACY.md` (or adding `PRIVACY.es.md` and linking both) — optional polish, +not an App Store requirement. It's a short document; flagging it here as a +reasonable follow-up rather than doing it unprompted, since it's a public repo +doc rather than app-bundled content. + +## 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/README.md b/README.md index aed9b7e..5ee59ca 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,24 @@ CheatSheet includes WidgetKit extensions for macOS, iOS, and iPadOS. Widgets fol - See recoverable storage failures in the app and retry loading without overwriting stored notes. - 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 in CI 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 diff --git a/Scripts/verify-localization.sh b/Scripts/verify-localization.sh new file mode 100755 index 0000000..fda3d1e --- /dev/null +++ b/Scripts/verify-localization.sh @@ -0,0 +1,86 @@ +#!/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" => %w[ + CheatSheetApp/Sources/NoteStore.swift + CheatSheetApp/Sources/PersistenceStatusBanner.swift + CheatSheetApp/Sources/MenuBarQuickAccessView.swift + CheatSheetApp/Sources/ChecklistLineView.swift + Shared/Sources/CheatSheetNote.swift + Shared/Sources/DisplayLine.swift + Shared/Sources/CheatSheetNote+Trash.swift + Shared/Sources/CheatSheetNoteRepository.swift + ], + "CheatSheetWidgets/Resources/Localizable.xcstrings" => %w[ + CheatSheetWidgets/Sources/CheatSheetWidgetView.swift + CheatSheetWidgets/Sources/WidgetLineView.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 + +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 + end +end + +unless errors.empty? + warn "Localization verification failed:" + errors.each { |message| warn " - #{message}" } + abort +end +RUBY + +echo "Localization verified." 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..ef5dfbb 100644 --- a/Shared/Sources/CheatSheetNote.swift +++ b/Shared/Sources/CheatSheetNote.swift @@ -83,7 +83,7 @@ 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 } public var fontStyle: CheatSheetFontStyle { @@ -108,8 +108,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 +119,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 +164,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 +204,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") } } diff --git a/Shared/Sources/CheatSheetNoteRepository.swift b/Shared/Sources/CheatSheetNoteRepository.swift index f9956ba..ad80547 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." + ) } } } diff --git a/Shared/Sources/DisplayLine.swift b/Shared/Sources/DisplayLine.swift index 75a8109..04bbd04 100644 --- a/Shared/Sources/DisplayLine.swift +++ b/Shared/Sources/DisplayLine.swift @@ -46,7 +46,7 @@ public extension String { } } - return "Empty note" + return String(localized: "note.previewEmpty", defaultValue: "Empty note") } var parsedChecklistLine: (text: String, isTask: Bool, isComplete: Bool, isHeading: Bool) { diff --git a/project.yml b/project.yml index aad740c..f7d9611 100644 --- a/project.yml +++ b/project.yml @@ -124,6 +124,7 @@ targets: - path: CheatSheetTests/Sources - path: Shared/Sources - path: CheatSheetApp/Sources/NoteStore.swift + - path: CheatSheetApp/Sources/ScreenshotDemoContent.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.wesleykeetch.wesleycheatsheet.tests @@ -183,6 +184,7 @@ targets: - path: CheatSheetTests/Sources - path: Shared/Sources - path: CheatSheetApp/Sources/NoteStore.swift + - path: CheatSheetApp/Sources/ScreenshotDemoContent.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.wesleykeetch.wesleycheatsheet.ios.tests