diff --git a/thecardplace/Cribbage/WEB-ACCESSIBILITY-AUDIT.md b/thecardplace/Cribbage/WEB-ACCESSIBILITY-AUDIT.md index 4923401..772ff57 100644 --- a/thecardplace/Cribbage/WEB-ACCESSIBILITY-AUDIT.md +++ b/thecardplace/Cribbage/WEB-ACCESSIBILITY-AUDIT.md @@ -71,13 +71,13 @@ after the game had all three. **A page nothing looks at is a page that rots.** ### Still open -- **#3 colour-only play count** — the count is given as text ("Count: 15"), so +- **#3 color-only play count** — the count is given as text ("Count: 15"), so this is not a 1.4.1 failure as written, but there is still no textual cue when the count approaches 31. - **#6, #8, #10, #15, #16** — board focus indicator, redundant ARIA on the board, focus restoration, ARIA descriptions, gradient background. - **No interface tests at all.** There is nothing equivalent to Sheephead's - `ui-dom.js`: announcements, labels, focus behaviour and keyboard handling are + `ui-dom.js`: announcements, labels, focus behavior and keyboard handling are untested. That is the layer this audit is about, and it is the most valuable thing left to build. See `../TESTING.md`. @@ -89,7 +89,7 @@ hands, under by three to nine points — and that the last card of every play ph scored nothing at all. No accessibility audit would ever have caught either. The game announced its -wrong answer perfectly clearly, in a well-labelled live region, at better than +wrong answer perfectly clearly, in a well-labeled live region, at better than 4.5:1 contrast. **It was accessible and wrong**, which for a player relying on that announcement is the worse of the two failures. diff --git a/thecardplace/TESTING.md b/thecardplace/TESTING.md index 6938e52..036964f 100644 --- a/thecardplace/TESTING.md +++ b/thecardplace/TESTING.md @@ -184,7 +184,7 @@ Cribbage's found three real bugs, all now fixed: The first is worth dwelling on: a 4-5-6-6 hand scored 9 instead of 12, and had done since the game was written. It is not subtle, it is not rare, and no amount of accessibility auditing, geometry measurement or interface testing would ever have found it — because the game announced its wrong -answer perfectly clearly, in a well-labelled live region, at 4.5:1 contrast. +answer perfectly clearly, in a well-labeled live region, at 4.5:1 contrast. ### Mutation tested @@ -269,8 +269,8 @@ waits the deadline out, and it does catch the fault. |---|---| | `sheephead/tests/layout.js` | Horizontal overflow, card size and aspect ratio, tap targets, the setup screen, two-column reading order — 6 viewports × 2 font sizes | | `sheephead/tests/card-overlap.js` | Every index, pip and court glyph measured for intersection | -| `sheephead/tests/appearance.js` | Both skins × both colour schemes: the chosen skin actually applies, red suits render red, an unplayable card keeps its colour and has nothing painted over it, a played card is readable at half size, and a blocked card still looks different from a playable one | -| `sheephead/tests/contrast.js` | Every text element's real colour against what is actually painted behind it, WCAG AA by size | +| `sheephead/tests/appearance.js` | Both skins × both color schemes: the chosen skin actually applies, red suits render red, an unplayable card keeps its color and has nothing painted over it, a played card is readable at half size, and a blocked card still looks different from a playable one | +| `sheephead/tests/contrast.js` | Every text element's real color against what is actually painted behind it, WCAG AA by size | | `Cribbage/tests/audit.js` | **Both pages** — overflow, card geometry and collisions, contrast, tap targets, duplicate ids, heading order, main landmark, table captions, `th` scope, emoji or arrows in link text | These found a formal accessibility audit's blind spot: Cribbage scored 78/100 in a review that @@ -357,7 +357,7 @@ Listed because an undocumented gap reads as coverage. connections to the in-process server, but only one of them is a real `Table`, because `Table` is a module singleton. Two browsers racing for the same seat, or both pressing Deal, is covered at the room level and not at the interface level. -- **Nothing checks colour contrast.** Sheephead has `contrast.js`; euchre inherits its stylesheet +- **Nothing checks color contrast.** Sheephead has `contrast.js`; euchre inherits its stylesheet and has no equivalent, so a palette change here would go unmeasured. ### All of them @@ -369,7 +369,7 @@ Listed because an undocumented gap reads as coverage. Two things that suite learned the hard way, worth knowing before extending it. **A CSS filter is invisible to `getComputedStyle`** — it changes the pixels and nothing else, so a greyscale that - flattens the red suits cannot be found by reading colours; the filter itself has to be asserted + flattens the red suits cannot be found by reading colors; the filter itself has to be asserted on. And **measuring only the first suit glyph on a card missed a real break**, because a card shows its suit in three places and the corner index was still red while the pips were not. - **No shared harness.** The measurement logic in `sheephead/tests/*.js` and `Cribbage/tests/audit.js` diff --git a/thecardplace/euchre/README.md b/thecardplace/euchre/README.md index f3d4c0c..0794708 100644 --- a/thecardplace/euchre/README.md +++ b/thecardplace/euchre/README.md @@ -18,12 +18,12 @@ deployed to Cloudflare from CI. Everything else runs in the browser. Once a suit is trump, the two highest cards in the game are the **right bower** (the jack of trump) and the **left bower** — the jack of the *other suit of the -same colour*. And while it is the left bower, **it is not a card of its printed +same color*. And while it is the left bower, **it is not a card of its printed suit at all**. With spades trump, the jack of clubs is a spade: it follows spades, and you may not play it on a club lead if you hold any spade. A sighted player absorbs that from five cards at a glance. Read out card by card, -it is a memory exercise with a colour rule in it, performed while three other +it is a memory exercise with a color rule in it, performed while three other people wait. So: - **Both bowers are named as bowers** wherever a card is read out — in your hand, @@ -72,7 +72,7 @@ down a per-player table — a number printed twice invites being read as two numbers. S says the tricks each side has this hand, the game score, the match, and how many more tricks you need to make it or to euchre them. -**The dealer's discard is shown, not summarised.** When a hand ends, the actual +**The dealer's discard is shown, not summarized.** When a hand ends, the actual cards are laid out: the upcard, what the dealer put back, and the three cards nobody ever sees at a real table. "The dealer discarded" tells you nothing. @@ -83,7 +83,7 @@ it will never play. Its Cards column says "out". becomes your turn — and to the bidding buttons when the decision is a bid rather than a card — but not while you are in a text field, reading the help, or reading back through the log. The announcement still tells you it is your turn. You can -turn the behaviour off in Settings. +turn the behavior off in Settings. **Pace is configurable, in real seconds.** Computer turns can advance instantly, after four seconds, after ten, or only when you press Continue. The timed @@ -101,9 +101,9 @@ toolbar under the table — nothing depends on a shortcut being available, and Tab, Enter and Space alone are enough to play a whole game. -Also: visible focus outlines, no colour-only meaning (trump is labelled as well +Also: visible focus outlines, no color-only meaning (trump is labeled as well as tinted), a skip link, semantic headings and tables, and support for -`prefers-reduced-motion`, `prefers-contrast` and forced-colours mode. +`prefers-reduced-motion`, `prefers-contrast` and forced-colors mode. ### Keys @@ -184,7 +184,7 @@ Measured over 4,000 hands per row against the hard opponents (`npm run balance`) | normal | 95.5% | 13.8% | 12.2% | 8.1% | 67.2% | | hard | 94.1% | 12.9% | 14.2% | 9.1% | 65.3% | -Those are the numbers a euchre player would recognise, and they are the reason +Those are the numbers a euchre player would recognize, and they are the reason `tests/balance.js` exists. The first draft of the bidding thresholds threw more than two hands in five straight in the bin — a game nobody would sit through, and completely invisible to every test that only checks the rules are obeyed. diff --git a/thecardplace/euchre/index.html b/thecardplace/euchre/index.html index 51319f5..a59e42f 100644 --- a/thecardplace/euchre/index.html +++ b/thecardplace/euchre/index.html @@ -454,7 +454,7 @@

The bowers — the one rule that surprises everybody

@@ -568,7 +568,7 @@

Reading your hand while you bid

Pressing H during the bidding tells you what your hand would look like with the suit on offer as trump — which cards would be trump, and which jack would become the left bower. A sighted player works that out at a glance from cards they can - see all at once. Having to hold four jacks and a colour rule in your head while a timer runs + see all at once. Having to hold four jacks and a color rule in your head while a timer runs is a different game, and a harder one, so the program does the mapping and you make the decision.

@@ -589,7 +589,7 @@

Announcements

Focus

Focus moves to your cards when it becomes your turn, but never while you are typing in a field, reading this help, or reading back through the log. The announcement still tells you it - is your turn. You can turn the behaviour off in Settings.

+ is your turn. You can turn the behavior off in Settings.

Playing with other people

At an online table you cannot see the other players, so the game says what silence means: diff --git a/thecardplace/euchre/tests/rules-oracle.js b/thecardplace/euchre/tests/rules-oracle.js index 0b933a4..cfa936b 100644 --- a/thecardplace/euchre/tests/rules-oracle.js +++ b/thecardplace/euchre/tests/rules-oracle.js @@ -50,7 +50,7 @@ function check(cond, msg) { checks++; if (!cond) fails.push(msg); } const SUITS = ['C', 'S', 'H', 'D']; const RANKS = ['A', 'K', 'Q', 'J', 'T', '9']; -/* "the jack of the other suit of the same colour" */ +/* "the jack of the other suit of the same color" */ const SAME_COLOUR = { C: 'S', S: 'C', H: 'D', D: 'H' }; /* "with spades trump the order is: jack of spades, jack of clubs, ace, king, diff --git a/thecardplace/index.html b/thecardplace/index.html index 821879a..1c2904a 100644 --- a/thecardplace/index.html +++ b/thecardplace/index.html @@ -185,7 +185,7 @@

Euchre

4 players · on your own or with other people

-

The Midwest and Ontario standard. Two partnerships, five tricks a hand, first to ten points — and the jack of the same colour as trump quietly becomes a trump too.

+

The Midwest and Ontario standard. Two partnerships, five tricks a hand, first to ten points — and the jack of the same color as trump quietly becomes a trump too.

Why these exist

diff --git a/thecardplace/ios/CardGamesKit/Sources/EuchreEngine/EuchreHelp.swift b/thecardplace/ios/CardGamesKit/Sources/EuchreEngine/EuchreHelp.swift index b2e82de..a9832fb 100644 --- a/thecardplace/ios/CardGamesKit/Sources/EuchreEngine/EuchreHelp.swift +++ b/thecardplace/ios/CardGamesKit/Sources/EuchreEngine/EuchreHelp.swift @@ -14,7 +14,7 @@ public enum EuchreHelp { ("The bowers — the one rule that surprises everybody", "Once a suit is trump, the two highest cards in the game are the right bower — the jack of the " + - "trump suit — and the left bower — the jack of the other suit of the same colour. And while it " + + "trump suit — and the left bower — the jack of the other suit of the same color. And while it " + "is the left bower, it is a trump card and not a card of its printed suit at all. With spades " + "trump, the jack of clubs is a spade: it follows spades, and you may not play it on a club lead " + "if you hold any spade. " + diff --git a/thecardplace/ios/CardGamesKit/Tests/EuchreEngineTests/EuchreRulesOracleTests.swift b/thecardplace/ios/CardGamesKit/Tests/EuchreEngineTests/EuchreRulesOracleTests.swift index 815e9e3..a23f5a3 100644 --- a/thecardplace/ios/CardGamesKit/Tests/EuchreEngineTests/EuchreRulesOracleTests.swift +++ b/thecardplace/ios/CardGamesKit/Tests/EuchreEngineTests/EuchreRulesOracleTests.swift @@ -15,7 +15,7 @@ final class EuchreRulesOracleTests: XCTestCase { static let suits: [Suit] = [.clubs, .spades, .hearts, .diamonds] static let ranks = ["A", "K", "Q", "J", "T", "9"] - /// "the jack of the other suit of the same colour" + /// "the jack of the other suit of the same color" static let sameColour: [Suit: Suit] = [.clubs: .spades, .spades: .clubs, .hearts: .diamonds, .diamonds: .hearts] /// "with spades trump the order is: jack of spades, jack of clubs, ace, king, diff --git a/thecardplace/ios/README.md b/thecardplace/ios/README.md index aba0aeb..e62472b 100644 --- a/thecardplace/ios/README.md +++ b/thecardplace/ios/README.md @@ -155,8 +155,8 @@ once. (`Shared/PaceGate.swift`) 12, tricks 3" — and partnership games score by side, not by a number repeated in two columns. -**Nothing by colour alone.** Red suits are red, but the suit is printed as a -symbol, trump and points are written on the card, an unplayable card is greyed +**Nothing by color alone.** Red suits are red, but the suit is printed as a +symbol, trump and points are written on the card, an unplayable card is grayed and marked with a cross, a selected card has a thick border and a tick. The red is 6.6:1 on white. Cards follow Dynamic Type up to the accessibility sizes and wrap onto more rows rather than shrinking; every control is at least 44 @@ -173,11 +173,11 @@ yet. | Criterion | How | | --- | --- | -| 1.1.1 Non-text content | Card faces are hidden from VoiceOver; the button label carries everything the face shows. Icons are decorative or labelled. | -| 1.3.1 Info and relationships | Headings carry the header trait; tables are read as rows; the hand is a labelled group. | -| 1.3.3 Sensory characteristics | No instruction depends on position or colour. | -| 1.4.1 Use of colour | Suit symbols, text badges, cross and tick marks alongside colour. | -| 1.4.3 / 1.4.11 Contrast | Card ink 6.6:1 or better on its ground; system colours elsewhere; Increase Contrast thickens borders. | +| 1.1.1 Non-text content | Card faces are hidden from VoiceOver; the button label carries everything the face shows. Icons are decorative or labeled. | +| 1.3.1 Info and relationships | Headings carry the header trait; tables are read as rows; the hand is a labeled group. | +| 1.3.3 Sensory characteristics | No instruction depends on position or color. | +| 1.4.1 Use of color | Suit symbols, text badges, cross and tick marks alongside color. | +| 1.4.3 / 1.4.11 Contrast | Card ink 6.6:1 or better on its ground; system colors elsewhere; Increase Contrast thickens borders. | | 1.4.4 / 1.4.12 Resize text | Dynamic Type everywhere, including inside cards via `@ScaledMetric`. | | 1.4.10 Reflow | Cards wrap; nothing scrolls sideways. | | 2.1.1 Keyboard | Every action is a button; shortcuts exist for review and Continue. | diff --git a/thecardplace/ios/TheCardPlace.xcodeproj/project.pbxproj b/thecardplace/ios/TheCardPlace.xcodeproj/project.pbxproj index 3bede78..ecd973e 100644 --- a/thecardplace/ios/TheCardPlace.xcodeproj/project.pbxproj +++ b/thecardplace/ios/TheCardPlace.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 03D04F7FE8F58D9184B73A56 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3F405F1AD88DD8A5285E1281 /* PrivacyInfo.xcprivacy */; }; 044EB7E107C60C0C207EB597 /* CribbageEngine in Frameworks */ = {isa = PBXBuildFile; productRef = F94700B9513EA796740F09B3 /* CribbageEngine */; }; 0D1B0DD6377E1CD0052394A7 /* Announcer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6568E1912006FA72B3DAA2C /* Announcer.swift */; }; 0FDB6B6AEB144C7A27E905E0 /* CribbageTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF045FB25E4ADB2CA67D558 /* CribbageTableView.swift */; }; @@ -20,6 +21,7 @@ 30DB4DC415FC689D02F002B1 /* CribbageSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F892E0A3B6953A3B37E00A59 /* CribbageSession.swift */; }; 4594862BDB9DA6803DBF36D8 /* SheepheadSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A121D0F39113DF5761205 /* SheepheadSession.swift */; }; 53641618134321494D720AA1 /* EuchreTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ACA079333595E3D760851C7 /* EuchreTableView.swift */; }; + 56E5ABE0C3D57BE5FA5FF190 /* ScreenshotUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3126D727AE0DE59B7E914C00 /* ScreenshotUITests.swift */; }; 5C9E10A35902374CB686FEFB /* EuchreUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12686483D5D0F698A83F0DDE /* EuchreUITests.swift */; }; 6710621CF6DA3E7983B4BA6C /* SpadesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F2D50DBA2115DF077A2CBF /* SpadesUITests.swift */; }; 684B07A81624AD6E30F4A099 /* HeartsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B915DFA27181FB20DE3E72 /* HeartsUITests.swift */; }; @@ -62,6 +64,8 @@ 22AD0206ACBAF4A849EA37DF /* EuchreSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EuchreSession.swift; sourceTree = ""; }; 2D4B17D881934D07D0ACB538 /* HeartsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartsSession.swift; sourceTree = ""; }; 2DD2B004D362E811BB4314AB /* GameDispatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameDispatch.swift; sourceTree = ""; }; + 3126D727AE0DE59B7E914C00 /* ScreenshotUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotUITests.swift; sourceTree = ""; }; + 3F405F1AD88DD8A5285E1281 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3FE0B1F95772E2B4F7FAF40A /* GameKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameKind.swift; sourceTree = ""; }; 4AC3B197FBCAF26199F2B6EA /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; 4FB405CFABFA389A83F32338 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = ""; }; @@ -114,6 +118,7 @@ children = ( 6DD4E6714CC3174B232DEBED /* Assets.xcassets */, BA5231380811FB9C801986A6 /* Info.plist */, + 3F405F1AD88DD8A5285E1281 /* PrivacyInfo.xcprivacy */, DEC4F04C6B855D0E3A80F318 /* TheCardPlaceApp.swift */, 1A613F1CEB72A94C322F8404 /* Games */, 50DF580E37675B6BEE01C71C /* Hub */, @@ -185,6 +190,7 @@ D40675BACF55461868978BF6 /* CribbageUITests.swift */, 12686483D5D0F698A83F0DDE /* EuchreUITests.swift */, E7B915DFA27181FB20DE3E72 /* HeartsUITests.swift */, + 3126D727AE0DE59B7E914C00 /* ScreenshotUITests.swift */, CDA075E69F49B49AAB03E8E2 /* SheepheadUITests.swift */, D1F2D50DBA2115DF077A2CBF /* SpadesUITests.swift */, 4FB405CFABFA389A83F32338 /* Support.swift */, @@ -340,6 +346,7 @@ buildActionMask = 2147483647; files = ( B93E79FD2C9DF036FE4E993B /* Assets.xcassets in Resources */, + 03D04F7FE8F58D9184B73A56 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -383,6 +390,7 @@ E8408F656A481472872DE22F /* CribbageUITests.swift in Sources */, 5C9E10A35902374CB686FEFB /* EuchreUITests.swift in Sources */, 684B07A81624AD6E30F4A099 /* HeartsUITests.swift in Sources */, + 56E5ABE0C3D57BE5FA5FF190 /* ScreenshotUITests.swift in Sources */, 88439E0C853714EA8B2AC48C /* SheepheadUITests.swift in Sources */, 6710621CF6DA3E7983B4BA6C /* SpadesUITests.swift in Sources */, 6FE6B97889EA9F7152F0E6FD /* Support.swift in Sources */, @@ -503,7 +511,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; INFOPLIST_FILE = TheCardPlace/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -592,7 +600,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; INFOPLIST_FILE = TheCardPlace/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; diff --git a/thecardplace/ios/TheCardPlace/Hub/GameKind.swift b/thecardplace/ios/TheCardPlace/Hub/GameKind.swift index e59f03b..fc2c631 100644 --- a/thecardplace/ios/TheCardPlace/Hub/GameKind.swift +++ b/thecardplace/ios/TheCardPlace/Hub/GameKind.swift @@ -23,7 +23,7 @@ enum GameKind: String, CaseIterable, Identifiable, Codable, Hashable { case .hearts: return "Four players. Every heart is a point and the queen of spades is thirteen, and the lowest score wins." case .euchre: - return "Four players in two partnerships, first to ten. The jack of trump and its colour partner are the two highest cards." + return "Four players in two partnerships, first to ten. The jack of trump and its color partner are the two highest cards." case .spades: return "Four players in two partnerships. Bid before a card is played; missing the contract costs the whole bid." case .cribbage: diff --git a/thecardplace/ios/TheCardPlace/PrivacyInfo.xcprivacy b/thecardplace/ios/TheCardPlace/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..1f49b94 --- /dev/null +++ b/thecardplace/ios/TheCardPlace/PrivacyInfo.xcprivacy @@ -0,0 +1,29 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/thecardplace/ios/TheCardPlace/Shared/HelpViews.swift b/thecardplace/ios/TheCardPlace/Shared/HelpViews.swift index 2a0f770..004bd35 100644 --- a/thecardplace/ios/TheCardPlace/Shared/HelpViews.swift +++ b/thecardplace/ios/TheCardPlace/Shared/HelpViews.swift @@ -94,7 +94,7 @@ struct AccessibilityHintsView: View { ), HelpSection( heading: "Seeing the cards", - body: "Cards follow your text size, including the largest accessibility sizes, and wrap onto more rows rather than shrinking. Nothing depends on colour: red suits are red, but the suit is printed as a symbol, trump and points are written on the card, a card you may not play is greyed and marked with a cross, and a selected card has a thick border and a tick. Increase Contrast, Bold Text, Reduce Motion and dark mode are all respected." + body: "Cards follow your text size, including the largest accessibility sizes, and wrap onto more rows rather than shrinking. Nothing depends on color: red suits are red, but the suit is printed as a symbol, trump and points are written on the card, a card you may not play is grayed and marked with a cross, and a selected card has a thick border and a tick. Increase Contrast, Bold Text, Reduce Motion and dark mode are all respected." ), HelpSection( heading: "Offline", diff --git a/thecardplace/ios/TheCardPlaceUITests/EuchreUITests.swift b/thecardplace/ios/TheCardPlaceUITests/EuchreUITests.swift index 4f2da84..c8d8853 100644 --- a/thecardplace/ios/TheCardPlaceUITests/EuchreUITests.swift +++ b/thecardplace/ios/TheCardPlaceUITests/EuchreUITests.swift @@ -42,10 +42,17 @@ final class EuchreUITests: XCTestCase { XCTAssert(app.staticTexts["Players"].exists) app.assertControlBar() - // Five cards, each saying where it sits, and the bidding under way. - XCTAssertEqual(app.handCards.count, 5) - XCTAssert(app.handCards.matching(NSPredicate(format: "label CONTAINS %@", "card 1 of 5")).firstMatch.exists) - XCTAssert(app.status(startsWith: "Bidding")) + // Five cards, each saying where it sits, and the bidding under way; + // unless we dealt and a computer ordered the upcard up at once, in + // which case we already hold six and are choosing what to put back. + if app.status(startsWith: "You took the upcard", timeout: 1) { + XCTAssertEqual(app.handCards.count, 6) + XCTAssert(app.handCards.matching(NSPredicate(format: "label CONTAINS %@", "card 1 of 6")).firstMatch.exists) + } else { + XCTAssertEqual(app.handCards.count, 5) + XCTAssert(app.handCards.matching(NSPredicate(format: "label CONTAINS %@", "card 1 of 5")).firstMatch.exists) + XCTAssert(app.status(startsWith: "Bidding")) + } app.attachScreenshot("euchre-bidding", to: self) let dealButton = app.buttons["Deal the next hand"] @@ -95,7 +102,9 @@ final class EuchreUITests: XCTestCase { XCTAssert(app.staticTexts["The dealer's discard"].exists, "the face-down cards are shown once the hand is over") if sawTrumpLabel || newGameButton.exists { break outer } dealButton.tap() - XCTAssert(app.status(startsWith: "Bidding"), "the next hand is dealt") + // As at the first deal, a computer may order up before we look. + XCTAssert(app.status(startsWith: "Bidding") || app.status(startsWith: "You took the upcard", timeout: 1), + "the next hand is dealt") } XCTAssertGreaterThanOrEqual(handsFinished, 1) diff --git a/thecardplace/ios/TheCardPlaceUITests/ScreenshotUITests.swift b/thecardplace/ios/TheCardPlaceUITests/ScreenshotUITests.swift new file mode 100644 index 0000000..03244d4 --- /dev/null +++ b/thecardplace/ios/TheCardPlaceUITests/ScreenshotUITests.swift @@ -0,0 +1,18 @@ +import XCTest + +/// The App Store screenshot of the hub. The game screenshots come from the +/// games' own UI tests, at the moments they already attach; see +/// scripts/take-screenshots.sh for which ones. +final class ScreenshotUITests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + func testHub() { + let app = XCUIApplication.forTesting(game: "hearts") + app.launch() + let row = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "Sheephead")).firstMatch + XCTAssert(row.waitForExistence(timeout: 5), "the hub lists the games") + app.attachScreenshot("hub", to: self) + } +} diff --git a/thecardplace/ios/TheCardPlaceUITests/Support.swift b/thecardplace/ios/TheCardPlaceUITests/Support.swift index dd2c113..a5fb7cb 100644 --- a/thecardplace/ios/TheCardPlaceUITests/Support.swift +++ b/thecardplace/ios/TheCardPlaceUITests/Support.swift @@ -92,7 +92,11 @@ extension XCUIApplication { return q.waitForExistence(timeout: timeout) } + /// These pictures become the App Store screenshots, so give a label that + /// is changing (Throw becoming Continue, say) time to finish its + /// cross-fade rather than catching both words on top of each other. func attachScreenshot(_ name: String, to test: XCTestCase) { + Thread.sleep(forTimeInterval: 0.6) let a = XCTAttachment(screenshot: screenshot()) a.name = name a.lifetime = .keepAlways diff --git a/thecardplace/ios/project.yml b/thecardplace/ios/project.yml index 14d487a..4820f36 100644 --- a/thecardplace/ios/project.yml +++ b/thecardplace/ios/project.yml @@ -65,7 +65,7 @@ targets: PRODUCT_NAME: The Card Place CODE_SIGN_STYLE: Automatic MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: 3 + CURRENT_PROJECT_VERSION: 5 ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor TARGETED_DEVICE_FAMILY: "1,2" diff --git a/thecardplace/ios/scripts/RELEASE.md b/thecardplace/ios/scripts/RELEASE.md index cee6f13..453e8c5 100644 --- a/thecardplace/ios/scripts/RELEASE.md +++ b/thecardplace/ios/scripts/RELEASE.md @@ -52,3 +52,58 @@ including the test information and the beta groups, this script does. and the Beta App Review contact, and rewrites them on every release, so they are in version control rather than typed into a web form once and forgotten. Change them there. + +## App Store release + +TestFlight and the App Store share the build; what the App Store adds is the +listing. The listing is in the repository, like the test information: + +- `store/en-US/*.txt` — description, keywords, subtitle, promotional text, + and the notes for App Review. Each file is the field, as it will appear. +- `store/app-store.json` — everything else: the version, copyright, release + type (manual, so approval does not publish the app), categories, URLs, + content rights, the App Review contact, the age rating answers, the + Accessibility Nutrition Labels, and the price. +- `TheCardPlace/PrivacyInfo.xcprivacy` — the privacy manifest in the build: + no tracking, no data collected, and `UserDefaults` for the settings. + +### Steps + +1. Put the build on TestFlight as above, and let it be tested. +2. Take the screenshots, on a 6.9" iPhone and a 13" iPad simulator (the script + creates them if they are missing). This plays a hand of every game, so it + takes a few minutes per device: + + ```bash + scripts/take-screenshots.sh + ``` + + They land in `build/screenshots/iphone/` and `build/screenshots/ipad/`, + numbered in store order, and are not committed. Look at them before they go up. +3. See what the listing would change, then write it and choose the build: + + ```bash + scripts/wire-appstore.py + scripts/wire-appstore.py --apply --build 5 + scripts/upload-screenshots.py --apply + ``` + + Price and availability are left alone unless `--set-price` is given, which + makes the app free in every territory, including ones Apple adds later. + That only has to happen once. +4. By hand in App Store Connect, because the API cannot do it: + - **App Privacy**: Get Started, then "No, we do not collect data from this + app", then Publish. That's true because the app has no network access + at all. If it ever gains any, this answer and the manifest change too. + - Look over the version page, then **Add for Review** and **Submit**. +5. When it is approved, release it from the version page (the release type is + manual). +6. Once it is on the store, publish the Accessibility Nutrition Labels. Apple + keeps them as drafts until the app is live: + + ```bash + scripts/wire-appstore.py --apply --publish-accessibility + ``` + +The privacy policy is `thecardplace/privacy.html` on the Card Place site; the +listing's privacy URL points at it, so it has to be live before submission. diff --git a/thecardplace/ios/scripts/take-screenshots.sh b/thecardplace/ios/scripts/take-screenshots.sh new file mode 100755 index 0000000..37f5478 --- /dev/null +++ b/thecardplace/ios/scripts/take-screenshots.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Take the App Store screenshots of The Card Place, on the two display sizes +# App Store Connect requires for an app that runs on iPhone and iPad. +# +# cd thecardplace/ios +# scripts/take-screenshots.sh # both devices +# scripts/take-screenshots.sh iphone # one of them +# REUSE=1 scripts/take-screenshots.sh # pick again from the last run's results +# +# The pictures come from the UI tests, which already attach a screenshot at +# a good moment in each game. This runs those tests on each device, exports +# the attachments from the result bundle, and copies the chosen ones, in +# store order, to build/screenshots//. Upload them with +# scripts/upload-screenshots.py. +# +# The deals are random, so each run's pictures differ a little. +set -euo pipefail + +cd "$(dirname "$0")/.." +OUT=build/screenshots + +# name | device type | display type in App Store Connect | width x height +DEVICES=( + "iphone|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max|APP_IPHONE_67|1320x2868" + "ipad|com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-12GB|APP_IPAD_PRO_3GEN_129|2064x2752" +) + +# Attachment names -> store file name, in the order the store shows them. +# The first attachment the run has wins: the tests play random deals, and a +# moment like "euchre-play" does not come if the player sits out the hand. +SHOTS=( + "hearts-trick,hearts-passing|01-hearts" + "euchre-play,euchre-bidding|02-euchre" + "spades-trick,spades-bidding|03-spades" + "cribbage-play,cribbage-cut|04-cribbage" + "sheephead-trick,sheephead-dealt|05-sheephead" + "hub|06-hub" +) + +TESTS=( + -only-testing:TheCardPlaceUITests/HeartsUITests/testPlaysAHandByLabels + -only-testing:TheCardPlaceUITests/EuchreUITests + -only-testing:TheCardPlaceUITests/SpadesUITests + -only-testing:TheCardPlaceUITests/CribbageUITests + -only-testing:TheCardPlaceUITests/SheepheadUITests + -only-testing:TheCardPlaceUITests/ScreenshotUITests +) + +runtime=$(xcrun simctl list runtimes -j | python3 -c ' +import json, sys +rs = [r for r in json.load(sys.stdin)["runtimes"] if r["platform"] == "iOS" and r["isAvailable"]] +print(sorted(rs, key=lambda r: [int(n) for n in r["version"].split(".")])[-1]["identifier"])') + +for entry in "${DEVICES[@]}"; do + IFS='|' read -r name devtype display size <<<"$entry" + [[ $# -gt 0 && " $* " != *" $name "* ]] && continue + sim="The Card Place screenshots ($name)" + udid=$(xcrun simctl list devices -j | python3 -c " +import json, sys +for rt, ds in json.load(sys.stdin)['devices'].items(): + for d in ds: + if d['name'] == '''$sim''' and d['isAvailable']: print(d['udid']); sys.exit()") + if [[ -z "$udid" ]]; then + udid=$(xcrun simctl create "$sim" "$devtype" "$runtime") + echo "Created simulator $sim" + fi + xcrun simctl boot "$udid" 2>/dev/null || true + # Put the real status bar back however this run ends. + trap "xcrun simctl status_bar '$udid' clear 2>/dev/null || true" EXIT + xcrun simctl bootstatus "$udid" -b >/dev/null + # The status bar Apple's own screenshots show: 9:41, full signal, full battery. + xcrun simctl status_bar "$udid" override --time 9:41 --dataNetwork wifi --wifiBars 3 \ + --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100 + xcrun simctl ui "$udid" appearance light + + result="build/screenshots-$name.xcresult" + rm -rf "build/attachments-$name" + if [[ -z "${REUSE:-}" || ! -d "$result" ]]; then + rm -rf "$result" + echo "Running the UI tests on $sim (this plays a hand of every game)" + xcodebuild -project TheCardPlace.xcodeproj -scheme TheCardPlace \ + -destination "id=$udid" -resultBundlePath "$result" "${TESTS[@]}" test -quiet \ + || { echo "The UI tests failed on $name; see $result"; exit 1; } + fi + + xcrun xcresulttool export attachments --path "$result" --output-path "build/attachments-$name" >/dev/null + mkdir -p "$OUT/$name" + rm -f "$OUT/$name"/*.png + for shot in "${SHOTS[@]}"; do + IFS='|' read -r attachment file <<<"$shot" + src=$(python3 - "build/attachments-$name" "$attachment" <<'EOF' +import json, os, sys +d, wants = sys.argv[1], sys.argv[2].split(",") +tests = json.load(open(os.path.join(d, "manifest.json"))) +for want in wants: + for test in tests: + for a in test["attachments"]: + if a["suggestedHumanReadableName"].startswith(want + "_"): + print(os.path.join(d, a["exportedFileName"])); sys.exit() +EOF +) + if [[ -z "$src" ]]; then echo "No $attachment screenshot on $name"; exit 1; fi + dest="$OUT/$name/$file.png" + cp "$src" "$dest" + got="$(sips -g pixelWidth "$dest" | awk '/pixelWidth/{print $2}')x$(sips -g pixelHeight "$dest" | awk '/pixelHeight/{print $2}')" + if [[ "$got" != "$size" ]]; then + echo "$dest is $got; App Store Connect wants $size for $display"; exit 1 + fi + echo " $dest ($got)" + done + xcrun simctl status_bar "$udid" clear +done +echo "Screenshots are in $OUT/." diff --git a/thecardplace/ios/scripts/upload-screenshots.py b/thecardplace/ios/scripts/upload-screenshots.py new file mode 100755 index 0000000..348b9bb --- /dev/null +++ b/thecardplace/ios/scripts/upload-screenshots.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Put the screenshots from scripts/take-screenshots.sh on the App Store listing. + +Each device's screenshot set is replaced as a whole, in file-name order, so +the store shows exactly what is in build/screenshots//. Without +--apply this only says what it would do. + + scripts/upload-screenshots.py # show what would happen + scripts/upload-screenshots.py --apply +""" +import argparse, glob, hashlib, json, os, sys, time +from urllib import request + +sys.path.insert(0, os.path.expanduser("~/.thecardplace-keys")) +os.environ.setdefault("ASC_CONFIG", "~/.thecardplace-keys/asc.json") +import asc # noqa: E402 + +HERE = os.path.dirname(os.path.abspath(__file__)) +SHOTS = os.path.join(HERE, "..", "build", "screenshots") +STORE = os.path.join(HERE, "..", "store") + +# The folder take-screenshots.sh writes -> App Store Connect's display type. +# APP_IPHONE_67 is the 6.9" slot; APP_IPAD_PRO_3GEN_129 the 13" one. +DISPLAY = {"iphone": "APP_IPHONE_67", "ipad": "APP_IPAD_PRO_3GEN_129"} +MAX_PER_SET = 10 + + +def ok(st): + return 200 <= st < 300 + + +def call(method, path, body=None, query=None): + st, j = asc.api(method, path, body=body, query=query) + if not ok(st): + raise SystemExit(f"{method} {path} failed ({st}): {json.dumps(j)[:600]}") + return j.get("data") if j else None + + +def upload(set_id, path): + data = open(path, "rb").read() + shot = call("POST", "/v1/appScreenshots", {"data": { + "type": "appScreenshots", + "attributes": {"fileName": os.path.basename(path), "fileSize": len(data)}, + "relationships": {"appScreenshotSet": {"data": {"type": "appScreenshotSets", "id": set_id}}}}}) + for op in shot["attributes"]["uploadOperations"]: + chunk = data[op["offset"]:op["offset"] + op["length"]] + req = request.Request(op["url"], data=chunk, method=op["method"]) + for h in op.get("requestHeaders", []): + req.add_header(h["name"], h["value"]) + with request.urlopen(req, timeout=120) as r: + r.read() + call("PATCH", f"/v1/appScreenshots/{shot['id']}", {"data": { + "type": "appScreenshots", "id": shot["id"], + "attributes": {"uploaded": True, "sourceFileChecksum": hashlib.md5(data).hexdigest()}}}) + return shot["id"] + + +def wait_until_processed(ids, timeout=600): + """Marking a screenshot uploaded only starts Apple's processing; this + waits for every one to come out COMPLETE, and stops on any that fail.""" + deadline = time.time() + timeout + pending = set(ids) + while pending: + for i in list(pending): + state = (call("GET", f"/v1/appScreenshots/{i}")["attributes"].get("assetDeliveryState") or {}) + if state.get("state") == "COMPLETE": + pending.discard(i) + elif state.get("state") == "FAILED": + raise SystemExit(f"screenshot {i} failed processing: {json.dumps(state.get('errors'))[:400]}") + if pending: + if time.time() > deadline: + raise SystemExit(f"{len(pending)} screenshots still processing after {timeout} seconds") + time.sleep(5) + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--apply", action="store_true") + p.add_argument("--locale", default="en-US") + a = p.parse_args() + + cfg = json.load(open(os.path.join(STORE, "app-store.json"))) + app_id = asc.find_app() + version = asc.find_appstore_version(app_id, cfg["version"]) + loc = next(x for x in call("GET", f"/v1/appStoreVersions/{version['id']}/appStoreVersionLocalizations") + if x["attributes"]["locale"] == a.locale) + sets = {s["attributes"]["screenshotDisplayType"]: s["id"] + for s in call("GET", f"/v1/appStoreVersionLocalizations/{loc['id']}/appScreenshotSets")} + + for device, display in DISPLAY.items(): + files = sorted(glob.glob(os.path.join(SHOTS, device, "*.png"))) + if not files: + raise SystemExit(f"no screenshots in build/screenshots/{device}; run scripts/take-screenshots.sh") + set_id = sets.get(display) + old = call("GET", f"/v1/appScreenshotSets/{set_id}/appScreenshots") if set_id else [] + print(f"{device} ({display}): replace {len(old)} with {len(files)}: " + + ", ".join(os.path.basename(f) for f in files)) + if not a.apply: + continue + if not set_id: + set_id = call("POST", "/v1/appScreenshotSets", {"data": { + "type": "appScreenshotSets", "attributes": {"screenshotDisplayType": display}, + "relationships": {"appStoreVersionLocalization": { + "data": {"type": "appStoreVersionLocalizations", "id": loc["id"]}}}}})["id"] + # The new pictures go up before the old ones come down, so a failure + # part way leaves as much of the old set as there is room for. A set + # holds at most ten, so only the old ones that leave no room go first. + if len(files) > MAX_PER_SET: + raise SystemExit(f"{device}: {len(files)} screenshots; a set holds at most {MAX_PER_SET}") + room = MAX_PER_SET - len(files) + for s in old[room:]: + call("DELETE", f"/v1/appScreenshots/{s['id']}") + ids = [upload(set_id, f) for f in files] + wait_until_processed(ids) + for s in old[:room]: + call("DELETE", f"/v1/appScreenshots/{s['id']}") + call("PATCH", f"/v1/appScreenshotSets/{set_id}/relationships/appScreenshots", + {"data": [{"type": "appScreenshots", "id": i} for i in ids]}) + print(f" uploaded {len(ids)}") + print("Done." if a.apply else "Nothing was changed. Run again with --apply to upload.") + + +if __name__ == "__main__": + main() diff --git a/thecardplace/ios/scripts/wire-appstore.py b/thecardplace/ios/scripts/wire-appstore.py new file mode 100755 index 0000000..cbe46fc --- /dev/null +++ b/thecardplace/ios/scripts/wire-appstore.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Fill in The Card Place's App Store listing from the files in store/. + +The listing is kept in the repository the way the TestFlight test information +is: the prose in store/en-US/*.txt, everything else in store/app-store.json. +This reads what App Store Connect has, prints every field that differs, and +with --apply writes them. Without --apply it changes nothing. + + scripts/wire-appstore.py # show what would change + scripts/wire-appstore.py --apply # write the listing + scripts/wire-appstore.py --apply --build 4 # and choose the build for 1.0 + scripts/wire-appstore.py --apply --set-price # and make it free everywhere + +Price and availability are only touched with --set-price, because they decide +who can get the app the moment it is approved. Nothing here submits for +review; that is done by hand, in App Store Connect. + +What the API cannot do, and has to be done on the website: the App Privacy +answers, screenshots' review (they are uploaded by upload-screenshots.py), and +Submit for Review. +""" +import argparse, json, os, sys + +sys.path.insert(0, os.path.expanduser("~/.thecardplace-keys")) +os.environ.setdefault("ASC_CONFIG", "~/.thecardplace-keys/asc.json") +import asc # noqa: E402 + +STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "store") +APPLY = False + + +def ok(st): + return 200 <= st < 300 + + +def text(locale, name): + with open(os.path.join(STORE, locale, name + ".txt")) as f: + return f.read().strip() + + +def get(path, **query): + st, j = asc.api("GET", path, query=query or None) + if not ok(st): + raise SystemExit(f"GET {path} failed ({st}): {json.dumps(j)[:400]}") + return j.get("data") + + +def write(method, path, body, what): + """Send one change, or only say it would be sent.""" + if not APPLY: + print(f" would {method} {path}") + return None + st, j = asc.api(method, path, body=body) + print(f" {method} {path}: {st}") + if not ok(st): + raise SystemExit(f"{what} failed: {json.dumps(j)[:600]}") + return j.get("data") + + +def changes(have, want): + """The fields of want that differ from have, each printed as it is found.""" + out = {} + for k, v in want.items(): + if have.get(k) != v: + old = have.get(k) + shown = (lambda x: x if not isinstance(x, str) or len(x) < 70 else x[:67] + "...") + print(f" {k}: {shown(old)!r} -> {shown(v)!r}") + out[k] = v + return out + + +def patch(kind, rid, have, want, what): + diff = changes(have, want) + if not diff: + print(" already as wanted") + return + write("PATCH", f"/v1/{kind}/{rid}", + {"data": {"type": kind, "id": rid, "attributes": diff}}, what) + + +def wire_version_text(version_id, cfg, locale): + print(f"Version text ({locale})") + want = { + "description": text(locale, "description"), + "keywords": text(locale, "keywords"), + "promotionalText": text(locale, "promotional_text"), + "supportUrl": cfg["urls"]["support"], + "marketingUrl": cfg["urls"]["marketing"], + } + for k, limit in (("description", 4000), ("keywords", 100), ("promotionalText", 170)): + if len(want[k]) > limit: + raise SystemExit(f"{k} is {len(want[k])} characters; the limit is {limit}") + for loc in get(f"/v1/appStoreVersions/{version_id}/appStoreVersionLocalizations"): + if loc["attributes"]["locale"] == locale: + return patch("appStoreVersionLocalizations", loc["id"], loc["attributes"], want, + "version localization") + raise SystemExit(f"version has no {locale} localization") + + +def wire_app_info(app_id, cfg, locale): + info = get(f"/v1/apps/{app_id}/appInfos")[0] + print(f"App information ({locale})") + want = {"subtitle": text(locale, "subtitle"), "privacyPolicyUrl": cfg["urls"]["privacy_policy"]} + if len(want["subtitle"]) > 30: + raise SystemExit(f"subtitle is {len(want['subtitle'])} characters; the limit is 30") + loc = next((x for x in get(f"/v1/appInfos/{info['id']}/appInfoLocalizations") + if x["attributes"]["locale"] == locale), None) + if not loc: + raise SystemExit(f"app info has no {locale} localization") + patch("appInfoLocalizations", loc["id"], loc["attributes"], want, "app info localization") + + print("Categories") + rels = {} + for rel, want in cfg["categories"].items(): + cur = get(f"/v1/appInfos/{info['id']}/{rel}") + have = cur["id"] if cur else None + if have != want: + print(f" {rel}: {have!r} -> {want!r}") + rels[rel] = {"data": {"type": "appCategories", "id": want}} + if rels: + write("PATCH", f"/v1/appInfos/{info['id']}", + {"data": {"type": "appInfos", "id": info["id"], "relationships": rels}}, "categories") + else: + print(" already as wanted") + + print("Age rating") + decl = get(f"/v1/appInfos/{info['id']}/ageRatingDeclaration") + want = {k: v for k, v in cfg["age_rating"].items() if not k.startswith("_")} + patch("ageRatingDeclarations", decl["id"], decl["attributes"], want, "age rating") + + +def wire_app(app_id, cfg): + print("Content rights") + app = get(f"/v1/apps/{app_id}") + patch("apps", app_id, app["attributes"], {"contentRightsDeclaration": cfg["content_rights"]}, + "content rights") + + +def wire_version(version, cfg, build): + print(f"Version {cfg['version']}") + patch("appStoreVersions", version["id"], version["attributes"], + {"copyright": cfg["copyright"], "releaseType": cfg["release_type"]}, "version") + if build: + app_id = asc.find_app() + found = asc.find_build(app_id, cfg["version"], str(build)) + if not found: + raise SystemExit(f"build {build} of {cfg['version']} not found") + build_id = found["id"] + cur = get(f"/v1/appStoreVersions/{version['id']}/build") + if cur and cur["id"] == build_id: + print(f" build {build} already chosen") + else: + print(f" build: {cur['attributes']['version'] if cur else None!r} -> {str(build)!r}") + write("PATCH", f"/v1/appStoreVersions/{version['id']}/relationships/build", + {"data": {"type": "builds", "id": build_id}}, "build") + + +def wire_review(version_id, cfg, locale): + print("App Review contact and notes") + with open(os.path.expanduser("~/.thecardplace-keys/review-contact.json")) as f: + contact = json.load(f) + want = {**contact, "demoAccountRequired": False, "notes": text(locale, "review_notes")} + cur = get(f"/v1/appStoreVersions/{version_id}/appStoreReviewDetail") + if cur: + return patch("appStoreReviewDetails", cur["id"], cur["attributes"], want, "review detail") + changes({}, want) + write("POST", "/v1/appStoreReviewDetails", {"data": { + "type": "appStoreReviewDetails", "attributes": want, + "relationships": {"appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}}}}}, + "review detail") + + +def wire_accessibility(app_id, cfg, publish=False): + """Written as drafts. Apple refuses to publish them until the app is on + the store, so --publish-accessibility is for after the first release.""" + print("Accessibility Nutrition Labels") + a = cfg["accessibility"] + flags = {k: v for k, v in a.items() if k.startswith("supports")} + have = {d["attributes"]["deviceFamily"]: d for d in get(f"/v1/apps/{app_id}/accessibilityDeclarations")} + for family in a["device_families"]: + print(f" {family}") + if family in have: + patch("accessibilityDeclarations", have[family]["id"], have[family]["attributes"], flags, + f"accessibility {family}") + else: + changes({}, flags) + write("POST", "/v1/accessibilityDeclarations", {"data": { + "type": "accessibilityDeclarations", + "attributes": {"deviceFamily": family, **flags}, + "relationships": {"app": {"data": {"type": "apps", "id": app_id}}}}}, + f"accessibility {family}") + if publish: + for d in get(f"/v1/apps/{app_id}/accessibilityDeclarations"): + if d["attributes"].get("state") != "PUBLISHED": + print(f" publish {d['attributes']['deviceFamily']}") + write("PATCH", f"/v1/accessibilityDeclarations/{d['id']}", {"data": { + "type": "accessibilityDeclarations", "id": d["id"], "attributes": {"publish": True}}}, + "publish accessibility") + + +def wire_price(app_id, cfg): + """Free, in every territory, including ones Apple adds later.""" + p = cfg["price"] + if not (p["free"] and p["all_territories"]): + raise SystemExit("only free in every territory is scripted; set anything else on the website") + print("Price: free") + st, cur = asc.api("GET", f"/v1/appPriceSchedules/{app_id}/manualPrices", + query={"include": "appPricePoint", "limit": 50}) + prices = [x for x in (cur.get("included") or []) if x["type"] == "appPricePoints"] if ok(st) else [] + if prices and all(float(x["attributes"]["customerPrice"]) == 0 for x in prices): + print(" already free") + else: + set_free(app_id, p) + + print("Availability: every territory") + territories = get("/v1/territories", limit=200) + st, av = asc.api("GET", f"/v1/apps/{app_id}/appAvailabilityV2") + if ok(st) and av.get("data"): + have = get(f"/v2/appAvailabilities/{av['data']['id']}/territoryAvailabilities", limit=200) + if (av["data"]["attributes"].get("availableInNewTerritories") + and sum(1 for t in have if t["attributes"].get("available")) >= len(territories)): + print(f" already available in all {len(territories)}, and new ones as Apple adds them") + return + # Apple accepts one availability per app; after that it is changed + # territory by territory, which is easier on the website. + raise SystemExit("availability exists but is not every territory; change it on the website") + set_everywhere(app_id, territories) + + +def set_free(app_id, p): + points = get(f"/v1/apps/{app_id}/appPricePoints", + **{"filter[territory]": p["base_territory"], "limit": 200}) + free = next((x for x in points if float(x["attributes"]["customerPrice"]) == 0), None) + if not free: + raise SystemExit("no free price point found") + write("POST", "/v1/appPriceSchedules", { + "data": {"type": "appPriceSchedules", "relationships": { + "app": {"data": {"type": "apps", "id": app_id}}, + "baseTerritory": {"data": {"type": "territories", "id": p["base_territory"]}}, + "manualPrices": {"data": [{"type": "appPrices", "id": "${free}"}]}}}, + "included": [{"type": "appPrices", "id": "${free}", "attributes": {"startDate": None}, + "relationships": {"appPricePoint": {"data": {"type": "appPricePoints", + "id": free["id"]}}}}]}, + "price") + + +def set_everywhere(app_id, territories): + print(f" {len(territories)} territories, and new ones as Apple adds them") + inline = [{"type": "territoryAvailabilities", "id": f"${{{t['id']}}}", + "attributes": {"available": True}, + "relationships": {"territory": {"data": {"type": "territories", "id": t["id"]}}}} + for t in territories] + write("POST", "/v2/appAvailabilities", { + "data": {"type": "appAvailabilities", "attributes": {"availableInNewTerritories": True}, + "relationships": { + "app": {"data": {"type": "apps", "id": app_id}}, + "territoryAvailabilities": {"data": [{"type": x["type"], "id": x["id"]} for x in inline]}}}, + "included": inline}, "availability") + + +def main(): + global APPLY + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--apply", action="store_true", help="write the changes; without it, only show them") + p.add_argument("--build", type=int, help="choose this build number for the version") + p.add_argument("--set-price", action="store_true", help="also set price and availability") + p.add_argument("--publish-accessibility", action="store_true", + help="publish the accessibility labels (only once the app is on the store)") + p.add_argument("--locale", default="en-US") + a = p.parse_args() + APPLY = a.apply + + with open(os.path.join(STORE, "app-store.json")) as f: + cfg = json.load(f) + app_id = asc.find_app() + print(f"App: {app_id} ({asc.BUNDLE}){'' if APPLY else ' - showing changes only'}") + found = asc.find_appstore_version(app_id, cfg["version"]) + if not found: + raise SystemExit(f"no App Store version {cfg['version']}") + version = get(f"/v1/appStoreVersions/{found['id']}") + + wire_version_text(version["id"], cfg, a.locale) + wire_app_info(app_id, cfg, a.locale) + wire_app(app_id, cfg) + wire_version(version, cfg, a.build) + wire_review(version["id"], cfg, a.locale) + wire_accessibility(app_id, cfg, a.publish_accessibility) + if a.set_price: + wire_price(app_id, cfg) + else: + print("Price and availability: not touched (--set-price)") + print("Done." if APPLY else "Nothing was changed. Run again with --apply to write this.") + + +if __name__ == "__main__": + main() diff --git a/thecardplace/ios/scripts/wire-testflight.py b/thecardplace/ios/scripts/wire-testflight.py index 0f27d70..d2776ba 100755 --- a/thecardplace/ios/scripts/wire-testflight.py +++ b/thecardplace/ios/scripts/wire-testflight.py @@ -27,9 +27,11 @@ "and where it sits, a card you may not play says why, and everything that " "happens is spoken and written down." ) -FEEDBACK_EMAIL = "kelly@kellford.com" -CONTACT = {"contactFirstName": "kelly", "contactLastName": "ford", - "contactPhone": "425-381-9165", "contactEmail": "kelly@kellford.com"} +# The contact for Beta App Review, and the feedback address, are kept with the +# keys rather than in this public repository. +with open(os.path.expanduser("~/.thecardplace-keys/review-contact.json")) as _f: + CONTACT = json.load(_f) +FEEDBACK_EMAIL = CONTACT["contactEmail"] def ok(st): diff --git a/thecardplace/ios/store/app-store.json b/thecardplace/ios/store/app-store.json new file mode 100644 index 0000000..25e9d01 --- /dev/null +++ b/thecardplace/ios/store/app-store.json @@ -0,0 +1,64 @@ +{ + "_comment": "Everything about the App Store listing that is not prose. The prose is in en-US/. scripts/wire-appstore.py writes all of this; price and availability need --set-price, which is Kelly's call.", + "version": "1.0", + "copyright": "2026 Kelly Ford", + "release_type": "MANUAL", + "_categories": "Card and Board are subcategories of Games, not categories of their own.", + "categories": { + "primaryCategory": "GAMES", + "primarySubcategoryOne": "GAMES_CARD", + "primarySubcategoryTwo": "GAMES_BOARD" + }, + "content_rights": "DOES_NOT_USE_THIRD_PARTY_CONTENT", + "urls": { + "support": "https://kellylford.github.io/TheWorkBench/thecardplace/", + "marketing": "https://kellylford.github.io/TheWorkBench/thecardplace/", + "privacy_policy": "https://kellylford.github.io/TheWorkBench/thecardplace/privacy.html" + }, + "_review_contact": "The App Review contact's name, phone and email are in ~/.thecardplace-keys/review-contact.json, not here: this repository is public.", + "age_rating": { + "_comment": "No wagering anywhere, so not simulated gambling. All None gives 4+.", + "advertising": false, + "alcoholTobaccoOrDrugUseOrReferences": "NONE", + "contests": "NONE", + "gambling": false, + "gamblingSimulated": "NONE", + "gunsOrOtherWeapons": "NONE", + "healthOrWellnessTopics": false, + "lootBox": false, + "medicalOrTreatmentInformation": "NONE", + "messagingAndChat": false, + "parentalControls": false, + "profanityOrCrudeHumor": "NONE", + "ageAssurance": false, + "sexualContentGraphicAndNudity": "NONE", + "sexualContentOrNudity": "NONE", + "socialMedia": false, + "horrorOrFearThemes": "NONE", + "matureOrSuggestiveThemes": "NONE", + "unrestrictedWebAccess": false, + "userGeneratedContent": false, + "violenceCartoonOrFantasy": "NONE", + "violenceRealisticProlongedGraphicOrSadistic": "NONE", + "violenceRealistic": "NONE" + }, + "accessibility": { + "_comment": "Claimed only where it has been built and checked. Voice Control is left off until somebody has played a whole game of each by voice. There is no video or audio content, so captions and audio descriptions do not apply.", + "device_families": ["IPHONE", "IPAD"], + "supportsVoiceover": true, + "supportsLargerText": true, + "supportsDarkInterface": true, + "supportsDifferentiateWithoutColorAlone": true, + "supportsSufficientContrast": true, + "supportsReducedMotion": true, + "supportsVoiceControl": false, + "supportsCaptions": false, + "supportsAudioDescriptions": false + }, + "price": { + "_comment": "Applied only with --set-price. Free, in every territory.", + "base_territory": "USA", + "free": true, + "all_territories": true + } +} diff --git a/thecardplace/ios/store/en-US/description.txt b/thecardplace/ios/store/en-US/description.txt new file mode 100644 index 0000000..8fc5f2e --- /dev/null +++ b/thecardplace/ios/store/en-US/description.txt @@ -0,0 +1,30 @@ +Five card games against the computer: hearts, euchre, spades, cribbage and sheephead. Everything is played on your device. There's no network, no account, no ads and nothing to buy. + +The Card Place was built from the first card to be played with VoiceOver, and it works just as well by sight. + +EVERY CARD SAYS WHAT IT IS +Each card in your hand is a button that says what it is, anything worth knowing about it right now, and where it sits in your hand. For example: "Jack of Clubs, left bower, second highest trump, card 2 of 5." A card you may not play stays in your hand and says why, such as "you must follow hearts", so you never wonder where it went. + +THE SAME LAYOUT IN EVERY GAME +Your hand and the controls are always at the bottom of the screen and never move. The button that moves the game on (Deal, Cut, Pass, Bid, Next, or Continue) is always the lower left corner. Log and Repeat are always the lower right. As you play, your remaining cards stay where they were. + +NOTHING SPOKEN OVER ANYTHING ELSE +Everything that happens is announced one message at a time, so nothing cuts off anything else. Repeat says the last message again. The log keeps every play, newest first, and with VoiceOver you can flick through the last five entries without leaving the button. + +PLAY AT YOUR PACE +Choose how long the game pauses between the computer's plays, from Immediate to Wait for me, which waits until you say so. Continue always moves on straight away. + +THE GAMES +- Hearts: four players, passing, shooting the moon. +- Euchre: partners, ordering up, going alone, the bowers. +- Spades: partners, bidding, nil, bags. +- Cribbage: two players, the crib, pegging and counting. +- Sheephead: the Wisconsin favorite for three to six players, with picking, burying, the jack of diamonds as secret partner, leasters and doublers. + +Euchre, cribbage and sheephead have three levels of computer player. Help in every game explains the rules, and the Review menu tells you the score, the trick, what's been played and more. + +BUILT FOR ACCESSIBILITY +- VoiceOver: full play, headings to jump between parts of the table, and your turn moves focus to your cards (you can turn that off). +- Larger Text: cards and controls grow with your text size, all the way to the accessibility sizes. +- Hardware keyboard: shortcuts for the review commands and for Continue. +- Nothing is shown by color alone, contrast is high, dark mode works, and nothing moves on screen except what you choose. diff --git a/thecardplace/ios/store/en-US/keywords.txt b/thecardplace/ios/store/en-US/keywords.txt new file mode 100644 index 0000000..373dd6a --- /dev/null +++ b/thecardplace/ios/store/en-US/keywords.txt @@ -0,0 +1 @@ +hearts,euchre,spades,cribbage,sheepshead,schafkopf,accessible,screen reader,blind,offline diff --git a/thecardplace/ios/store/en-US/promotional_text.txt b/thecardplace/ios/store/en-US/promotional_text.txt new file mode 100644 index 0000000..16abfc5 --- /dev/null +++ b/thecardplace/ios/store/en-US/promotional_text.txt @@ -0,0 +1 @@ +Hearts, euchre, spades, cribbage and sheephead against the computer. No network, no accounts, no ads, and every card is a button VoiceOver can read. diff --git a/thecardplace/ios/store/en-US/review_notes.txt b/thecardplace/ios/store/en-US/review_notes.txt new file mode 100644 index 0000000..f5111b0 --- /dev/null +++ b/thecardplace/ios/store/en-US/review_notes.txt @@ -0,0 +1,7 @@ +The Card Place is five card games (hearts, euchre, spades, cribbage, sheephead) played against computer opponents, entirely offline. There's no sign-in, no network access, no in-app purchase and no data collection. + +To try it: open the app, choose a game, then Deal. Play cards from your hand at the bottom of the screen. The button at the lower left always moves the game on. + +The app is designed for VoiceOver users, and that's the best way to review it (Settings, Accessibility, VoiceOver, or triple-click the side button if the Accessibility Shortcut is set up). With VoiceOver on, when it becomes your turn focus moves to the first card you can play. This is deliberate, and it can be turned off in the app's Settings. Announcements are queued so they never interrupt each other. + +Euchre, cribbage and sheephead offer three difficulty levels. Hearts and spades don't, and the setup screen doesn't offer one for them. diff --git a/thecardplace/ios/store/en-US/subtitle.txt b/thecardplace/ios/store/en-US/subtitle.txt new file mode 100644 index 0000000..112b096 --- /dev/null +++ b/thecardplace/ios/store/en-US/subtitle.txt @@ -0,0 +1 @@ +Five card games for VoiceOver diff --git a/thecardplace/privacy.html b/thecardplace/privacy.html new file mode 100644 index 0000000..351b578 --- /dev/null +++ b/thecardplace/privacy.html @@ -0,0 +1,57 @@ + + + + + + +Privacy - The Card Place + + + + +
+

Privacy

+

This covers The Card Place app for iPhone and iPad, and the card games on + The Card Place website. Last updated October 8, 2026.

+ +

The app

+

The Card Place app collects no data. It never connects to the network, so nothing you do + in it leaves your device. There are no accounts, no advertising, no analytics and no + tracking.

+

The app remembers the name you play under, your pace and focus settings, and the rules you + chose for each game, so they're the same next time. These are stored only on your device, + for the app alone. Deleting the app deletes them.

+

If you share crash reports or usage data with app developers in your device's settings, + Apple may pass anonymous crash reports on to us. That is Apple's choice to offer and yours to + make; the app adds nothing to it.

+ +

The web games

+

The games on the website run in your browser and send nothing anywhere until you open a + table with other people. From then on they send your moves and the name you chose, and + nothing else. A table is a five-letter code that stops existing when everybody leaves.

+

Your settings for the web games, such as the name you play under, are saved in your own browser so + they are there next time. They are not sent anywhere.

+ +

Questions

+

Email support@theideaplace.net, or ask on + GitHub.

+
+ + + diff --git a/thecardplace/sheephead/README.md b/thecardplace/sheephead/README.md index bddcfbe..0d415d4 100644 --- a/thecardplace/sheephead/README.md +++ b/thecardplace/sheephead/README.md @@ -53,7 +53,7 @@ item that takes real DOM focus — the screen reader reads it because focus move of any live region or label trickery. Number keys are ignored inside the log so you cannot play a card by accident while reading back. -**The blind is shown, not summarised.** When a hand ends, the actual cards are laid out — what was +**The blind is shown, not summarized.** When a hand ends, the actual cards are laid out — what was in the blind, and separately what the picker buried. "21 points buried" tells you nothing about what actually went down. In a leaster the blind is shown with who took it on the last trick. @@ -67,7 +67,7 @@ therefore playing alone. Only ever about your own position, never anyone else's. **Focus is never stolen out from under you.** Focus moves to your cards when it becomes your turn, but not while you are in a text field, reading the help, or reading back through the log. The -announcement still tells you it is your turn. You can turn the behaviour off entirely in setup. +announcement still tells you it is your turn. You can turn the behavior off entirely in setup. **Pace is configurable, in real seconds.** Opponent turns can advance instantly, after four seconds, after ten seconds, or only when you press Continue. The timed settings are a ceiling rather than a @@ -84,9 +84,9 @@ quick navigation, so the game's letter shortcuts will not reach the page. That i one of them also exists as a button in the "Review the game" group — nothing depends on a shortcut being available, and `Tab`/`Enter`/`Space` alone are enough to play a whole game. -Also: visible focus outlines, no colour-only meaning (trump is labelled as well as tinted), a skip +Also: visible focus outlines, no color-only meaning (trump is labeled as well as tinted), a skip link, semantic headings and tables, and support for `prefers-reduced-motion`, `prefers-contrast` -and forced-colours mode. +and forced-colors mode. ### Keys @@ -185,7 +185,7 @@ Three-handed is high because ten-card hands make pairs likely. One dialog holds every rule and preference, reachable from the setup screen and from the in-game toolbar. Settings are remembered, so a new game uses them again unless you change them. -Pace and speech changes take effect immediately. Rule changes — all-pass behaviour, opponent skill, +Pace and speech changes take effect immediately. Rule changes — all-pass behavior, opponent skill, doublers — take effect from the next hand, so a hand in progress is never scored under rules it did not start under.