From 5aba59dd7d281009d6cfc8ac32783c28d4862ea1 Mon Sep 17 00:00:00 2001 From: keford <44002405+kellylford@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:09:13 -0500 Subject: [PATCH 1/6] The Card Place iOS gets ready for the App Store: listing in the repo, privacy manifest, screenshots by script The store listing lives in thecardplace/ios/store/ the way the TestFlight test information lives in wire-testflight.py: the prose as one text file per field, everything else in app-store.json. scripts/wire-appstore.py shows what differs from App Store Connect and only writes with --apply; price and availability need --set-price as well. The build gains PrivacyInfo.xcprivacy (no tracking, nothing collected, UserDefaults for the settings, CA92.1) and goes to build 4. scripts/take-screenshots.sh runs the UI tests on a 6.9" iPhone and a 13" iPad simulator, which it creates if missing, and keeps the moments they already photograph; upload-screenshots.py replaces each device's set. The euchre test no longer fails when we deal and a computer orders up at once, and screenshots wait out a label's cross-fade. thecardplace/privacy.html is the privacy policy the listing points at, linked from the Card Place footer. RELEASE.md gains an App Store section. Part of #202. Co-Authored-By: Claude Opus 5.5 --- thecardplace/index.html | 3 +- .../TheCardPlace.xcodeproj/project.pbxproj | 12 +- .../ios/TheCardPlace/PrivacyInfo.xcprivacy | 29 ++ .../TheCardPlaceUITests/EuchreUITests.swift | 15 +- .../ScreenshotUITests.swift | 18 ++ .../ios/TheCardPlaceUITests/Support.swift | 4 + thecardplace/ios/project.yml | 2 +- thecardplace/ios/scripts/RELEASE.md | 49 ++++ thecardplace/ios/scripts/take-screenshots.sh | 111 ++++++++ .../ios/scripts/upload-screenshots.py | 96 +++++++ thecardplace/ios/scripts/wire-appstore.py | 257 ++++++++++++++++++ thecardplace/ios/store/app-store.json | 66 +++++ thecardplace/ios/store/en-US/description.txt | 32 +++ thecardplace/ios/store/en-US/keywords.txt | 1 + .../ios/store/en-US/promotional_text.txt | 1 + thecardplace/ios/store/en-US/review_notes.txt | 7 + thecardplace/ios/store/en-US/subtitle.txt | 1 + thecardplace/privacy.html | 54 ++++ 18 files changed, 750 insertions(+), 8 deletions(-) create mode 100644 thecardplace/ios/TheCardPlace/PrivacyInfo.xcprivacy create mode 100644 thecardplace/ios/TheCardPlaceUITests/ScreenshotUITests.swift create mode 100755 thecardplace/ios/scripts/take-screenshots.sh create mode 100755 thecardplace/ios/scripts/upload-screenshots.py create mode 100755 thecardplace/ios/scripts/wire-appstore.py create mode 100644 thecardplace/ios/store/app-store.json create mode 100644 thecardplace/ios/store/en-US/description.txt create mode 100644 thecardplace/ios/store/en-US/keywords.txt create mode 100644 thecardplace/ios/store/en-US/promotional_text.txt create mode 100644 thecardplace/ios/store/en-US/review_notes.txt create mode 100644 thecardplace/ios/store/en-US/subtitle.txt create mode 100644 thecardplace/privacy.html diff --git a/thecardplace/index.html b/thecardplace/index.html index 821879a..e589286 100644 --- a/thecardplace/index.html +++ b/thecardplace/index.html @@ -276,7 +276,8 @@

Why these exist

diff --git a/thecardplace/ios/TheCardPlace.xcodeproj/project.pbxproj b/thecardplace/ios/TheCardPlace.xcodeproj/project.pbxproj index 3bede78..c0095cf 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 = 4; 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 = 4; INFOPLIST_FILE = TheCardPlace/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; 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/TheCardPlaceUITests/EuchreUITests.swift b/thecardplace/ios/TheCardPlaceUITests/EuchreUITests.swift index 4f2da84..fec1719 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"] 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..1a25fad 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: 4 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..bfe628f 100644 --- a/thecardplace/ios/scripts/RELEASE.md +++ b/thecardplace/ios/scripts/RELEASE.md @@ -52,3 +52,52 @@ 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 4 + 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). + +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..443a23b --- /dev/null +++ b/thecardplace/ios/scripts/take-screenshots.sh @@ -0,0 +1,111 @@ +#!/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: r["version"])[-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 + 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..282b8d4 --- /dev/null +++ b/thecardplace/ios/scripts/upload-screenshots.py @@ -0,0 +1,96 @@ +#!/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 +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"} + + +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 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"] + for s in old: + call("DELETE", f"/v1/appScreenshots/{s['id']}") + ids = [upload(set_id, f) for f in files] + 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..056291a --- /dev/null +++ b/thecardplace/ios/scripts/wire-appstore.py @@ -0,0 +1,257 @@ +#!/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") + for loc in get(f"/v1/appInfos/{info['id']}/appInfoLocalizations"): + if loc["attributes"]["locale"] == locale: + patch("appInfoLocalizations", loc["id"], loc["attributes"], want, "app info localization") + + print("Categories") + rels = {} + for rel, key in (("primaryCategory", "primary_category"), ("secondaryCategory", "secondary_category")): + cur = get(f"/v1/appInfos/{info['id']}/{rel}") + have = cur["id"] if cur else None + if have != cfg[key]: + print(f" {rel}: {have!r} -> {cfg[key]!r}") + rels[rel] = {"data": {"type": "appCategories", "id": cfg[key]}} + 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") + want = {**cfg["review_contact"], "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): + 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}") + + +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") + 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") + + print("Availability: every territory") + territories = get("/v1/territories", limit=200) + 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("--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) + 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/store/app-store.json b/thecardplace/ios/store/app-store.json new file mode 100644 index 0000000..00a8587 --- /dev/null +++ b/thecardplace/ios/store/app-store.json @@ -0,0 +1,66 @@ +{ + "_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", + "primary_category": "GAMES_CARD", + "secondary_category": "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": { + "contactFirstName": "Kelly", + "contactLastName": "Ford", + "contactPhone": "425-381-9165", + "contactEmail": "kelly@kellford.com", + "demoAccountRequired": false + }, + "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..4271837 --- /dev/null +++ b/thecardplace/ios/store/en-US/description.txt @@ -0,0 +1,32 @@ +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 favourite 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 colour alone, contrast is high, dark mode works, and nothing moves on screen except what you choose. + +The Card Place started as a set of free web games. This app is a native version of them, with the same rules, the same computer players and the same words. 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..6ba65a4 --- /dev/null +++ b/thecardplace/privacy.html @@ -0,0 +1,54 @@ + + + + + + +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 8 October 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.

+ +

Questions

+

Ask on GitHub.

+
+
+

Built by Kelly Ford.

+
+ + From f02169fda91451919a3a641d781bbbbfb878209b Mon Sep 17 00:00:00 2001 From: keford <44002405+kellylford@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:58:44 -0500 Subject: [PATCH 2/6] American spelling everywhere a player or reader sees it; support email on the pages; build 5 Color, behavior, favorite, summarized, recognize, labeled and the rest, in the iOS app's hub and help text, the store description, the Card Place and euchre pages, and the READMEs. Code comments and names such as sameColour are left. The privacy page and the Card Place footer give support@theideaplace.net. The description loses its last paragraph about the web games. The app's own text changed, so this is build 5. Co-Authored-By: Claude Opus 5.5 --- thecardplace/Cribbage/WEB-ACCESSIBILITY-AUDIT.md | 6 +++--- thecardplace/TESTING.md | 10 +++++----- thecardplace/cribbage-multiplayer/README.md | 6 +++--- thecardplace/euchre/README.md | 14 +++++++------- thecardplace/euchre/index.html | 6 +++--- thecardplace/euchre/tests/rules-oracle.js | 2 +- thecardplace/index.html | 5 +++-- .../Sources/EuchreEngine/EuchreHelp.swift | 2 +- .../EuchreEngineTests/EuchreRulesOracleTests.swift | 2 +- thecardplace/ios/README.md | 14 +++++++------- .../ios/TheCardPlace.xcodeproj/project.pbxproj | 4 ++-- thecardplace/ios/TheCardPlace/Hub/GameKind.swift | 2 +- .../ios/TheCardPlace/Shared/HelpViews.swift | 2 +- thecardplace/ios/project.yml | 2 +- thecardplace/ios/store/en-US/description.txt | 6 ++---- thecardplace/privacy.html | 5 +++-- thecardplace/sheephead-multiplayer/PLAN.md | 2 +- thecardplace/sheephead-multiplayer/README.md | 10 +++++----- thecardplace/sheephead/README.md | 10 +++++----- 19 files changed, 55 insertions(+), 55 deletions(-) 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/cribbage-multiplayer/README.md b/thecardplace/cribbage-multiplayer/README.md index a8481a8..0529c34 100644 --- a/thecardplace/cribbage-multiplayer/README.md +++ b/thecardplace/cribbage-multiplayer/README.md @@ -1,4 +1,4 @@ -> **This is the multiplayer fork.** It was copied in behaviour, not in code, from +> **This is the multiplayer fork.** It was copied in behavior, not in code, from > `Cribbage/`. The stable single-player game is **not modified** by any work here > — that is the whole point of the fork, so the game people actually play cannot > regress while multiplayer is built. A CI guard fails the build if a branch @@ -72,12 +72,12 @@ The whole pleasure of cribbage is in the counting, and handing over a total is handing over the game. You count your own hand, as you would at a table. **The arithmetic of the play is done for you.** During the pegging every card in -your hand is labelled with what it is worth, what count it would make, and what +your hand is labeled with what it is worth, what count it would make, and what it would score: *"Seven of Clubs, worth seven, makes twenty-two, and scores a pair for two, card 2 of 4."* A sighted player reads that off the table in a second; by ear it is a running sum plus four subtractions on every single turn. It is not coaching — it tells you nothing you could not work out from cards that -are face up in front of everybody — it removes arithmetic, not judgement. +are face up in front of everybody — it removes arithmetic, not judgment. **Everything is a real control.** Cards are `