iOS rules
Xcode-specific prohibitions
Section titled “Xcode-specific prohibitions”-
Never hand-edit
.pbxprojor.xcodeproj/contents. Create.swift/resource files only. How a new file joins the target depends on how the project file is produced, so check in this order:project.ymlat the component root — the project is generated by XcodeGen. The.pbxprojis a build artifact that happens to be committed. Don’t hand-edit it, and don’t add the file through Xcode — the nextxcodegen generateoverwrites both. Ifsources:declares a directory (the usual case), a new file inside it needs noproject.ymlchange at all: runxcodegen generateand commit the regenerated.pbxprojin the same change as the file that caused it. A new target does need aproject.ymledit first.PBXFileSystemSynchronizedRootGroupin the.pbxproj— Xcode 16 synchronized groups. Files under a synced folder are auto-included in the target; don’t add membership manually and don’t edit the.pbxproj.- Neither — ask the user to add the file to the target in Xcode rather than editing the project file.
Check the generator first. The two aren’t mutually exclusive, and the synchronized-groups test answers the wrong question for a generated project: an XcodeGen project reports no synchronized groups, so testing for those alone routes you to step 3 — and adding a file through Xcode is the one action guaranteed not to survive
xcodegen generate. -
Never modify
.xcworkspacecontents. -
Never add Swift Package Manager dependencies without explicit user permission. To bump existing deps (no new deps added), use
flowdeck project packages update— it re-resolvesPackage.resolvedto the latest versions allowed by the existingupToNextMajorVersionconstraints without touching the.pbxproj; build + test afterward. -
Never change the deployment target without explicit user request.
-
Never modify
.entitlementsfiles without explicit user request. -
Never use
NavigationView— alwaysNavigationStack. -
Never use
ObservableObject— always@Observable. -
Never use
@StateObject— always@Statewith@Observableobjects. -
Never use
@Published—@Observableproperties publish automatically.
App Store requirements
Section titled “App Store requirements”ITSAppUsesNonExemptEncryptionmust be set inInfo.plist(or asINFOPLIST_KEY_ITSAppUsesNonExemptEncryptionbuild setting).NOfor apps using only standard HTTPS;YESfor apps with custom encryption. A missing or wrong value causes export compliance failures on every TestFlight upload.- Terms of Use (EULA). Configure a custom EULA in App Store Connect → License Agreement (if you set none, Apple’s standard EULA applies automatically). Separately, App Review requires a functional Terms of Use link in the App Store description — link your custom EULA, or Apple’s standard EULA:
https://www.apple.com/legal/internet-services/itunes/dev/stdeula/. A missing/broken EULA link is a common rejection. - Privacy Policy link. Required for every app: set the Privacy Policy URL in App Store Connect → App Information, and make the policy reachable inside the app. Include the link in the App Store description too. A non-functional or missing privacy policy link is a frequent rejection.
- Subscription / IAP apps (Guideline 3.1.2): the paywall/purchase screen itself must clearly show price, duration, auto-renewal terms, and how to cancel — not just the description — and both the privacy policy and terms of use (EULA) links must be clickable on that screen (in the binary, not only in metadata). See Premium / subscription gating.
- No price in the app name or icon (Guideline 2.3.7). “Free”, “Lite (Free)”, a price, or a
FREEbadge burned into the icon artwork all get rejected — in the App Store name, the on-device name, and the icon image itself. The description is exempt. On a free/paid pair this bites the free product: set bothCFBundleDisplayNameandCFBundleName(the fallback iOS shows in Settings, which otherwise defaults to$(PRODUCT_NAME)), and check the 1024pt icon for baked-in badge text. - A version train closes permanently once its version reaches
READY_FOR_SALE. Uploading another build against that same marketing version fails with error 90186 (“Invalid Pre-Release Train”), no matter the build number. A shipped app needs a version bump to accept a new build. In a repo shipping several apps, this is why one release trigger must never fan out to every product: the already-shipped one can only fail.
Shipping more than one app from one repository
Section titled “Shipping more than one app from one repository”A repository that ships a paid and a free variant declares each as a
[[product]] in .lacquer.toml:
[[product]]name = "MyApp"scheme = "MyApp"bundle_id = "com.example.myapp"asc_app_id = "1234567890"
[[product]]name = "MyApp Lite"scheme = "MyApp Lite"bundle_id = "com.example.myapp.lite"asc_app_id = "0987654321"The release workflow becomes a matrix with one leg per product: separate archive, IPA, TestFlight upload and GitHub Release asset for each.
CI does the same. Build (Release) and Test get one leg per product, so
the free variant is compiled and its own test bundle is run. Four optional
fields drive the test leg, each defaulting to the historical single-app value:
[[product]]name = "MyApp Lite"scheme = "MyApp Lite"bundle_id = "com.example.myapp.lite"asc_app_id = "0987654321"tag_prefix = "myapplite"test_target = "MyApp LiteTests" # defaults to "<name>Tests"ui_test_target = "" # blank = this variant has no UI testsextra_test_targets = [] # local package suites to run as wellapp_target = "MyApp.app" # coverage target; defaults to "<name>.app"app_target is declared rather than derived because a scheme and its built
product genuinely differ — one app in this fleet builds A Bible Verse Daily.app
from a scheme named A Bible Verse Each Day Free. A derived value would select
no coverage row, and jq selecting nothing reports 0.0%, not an error.
ui_test_target is conditional in the shell rather than always passed: an empty
-only-testing: selector matches nothing and still exits 0.
extra_test_targets exists because -only-testing: is a whitelist. A local
Swift package’s test target that no selector names is run by nothing — the app’s
selector excludes it, xcodebuild exits 0, and a maintained suite (and any
coverage rule over the code it covers) is enforced by no one. The observed
workaround was copying an assertion into the app’s test target via @testable import purely so something would execute it.
extra_test_targets = ["CoreKitTests", "Feature KitTests"]Per-product, for the same reason test_target is: a package linked into one
scheme and not the other must not be selected on the leg that cannot run it,
where it would match nothing and pass. The selectors are built as a shell
array, so a target name containing a space stays one argument rather than
word-splitting into two selectors that each match nothing.
Declaring any turns on a Verify Test Selectors Matched step, which reads the
result bundle back and fails the job for any selector that produced no test
bundle — the only thing standing between an extra selector and a green run
over a suite that did not execute. It fails closed: an unreadable bundle, a
missing tool or a changed schema is red, not a pass. Opting in hardens
test_target and ui_test_target too, since the step checks every selector the
job passed. The pre-commit Swift Tests hook runs the same extras, so the fast
local loop does not cover less than CI.
Each leg’s simulator and uploaded test results are scoped by a slug derived from the product name. Two legs sharing one simulator name means the second leg’s stale-simulator cleanup deletes the simulator the first is mid-test on, which reports as “the test runner crashed before establishing connection” and reads like an app bug.
Declare nothing and you get exactly one product, synthesised from
[project] — not a special case in the workflow, a one-entry matrix down the
same code path. A single-app project’s CI workflow is rendered byte-for-byte as
it was before products existed.
fail-fast: false because the products are separate App Store submissions with
separate review outcomes: one failing validation must not cancel the other’s
upload. max-parallel: 1 because both legs sign on the same runner and share its
certificate directory.
Two things bite specifically on a paid/free pair, both learned the hard way: Guideline 2.3.7 rejects a price reference in the name or icon of the free product, and error 90186 means a release trigger that fans out to a product which has already shipped that version can only fail — see App Store requirements above.
Secrets & service keys
Section titled “Secrets & service keys”Two separate buckets — never mix them.
App-runtime keys → Secrets.xcconfig (compiled into the app)
Section titled “App-runtime keys → Secrets.xcconfig (compiled into the app)”Service keys the app needs at runtime (RevenueCat, Aptabase, …) live in a gitignored Secrets.xcconfig, never in source or the committed project.yml. The lacquer syncs a Secrets.xcconfig.example template into the component dir. See the ios-secrets-setup skill for wiring a new key through project.yml into Info.plist and reading it at runtime. Secrets.xcconfig values are build-time — they’re baked into the binary, so treat them as obfuscated, not secret. A truly sensitive secret belongs on a server, never in the app.
CI / server secrets → GitHub Actions (never in the app)
Section titled “CI / server secrets → GitHub Actions (never in the app)”The release and quality workflows — and any server-side job that calls a vendor REST API — read these from repo/org GitHub Actions secrets, never from an xcconfig.
Organization secrets only exist for organizations. gh secret set --org 404s
against a personal account, so check which {{GITHUB_ORG}} is before choosing:
gh api /orgs/{{GITHUB_ORG}} >/dev/null 2>&1 \ && gh secret set <NAME> --org {{GITHUB_ORG}} \ || gh secret set <NAME> -R {{GITHUB_ORG}}/<repo> # personal account: per repoThis is not a nitpick. Every repo in this fleet was missing
CLAUDE_CODE_OAUTH_TOKEN for months because the instruction here was
unconditionally org-level and the account owning them is personal — so the
command silently could not have worked, and four workflows failed on every run.
When a secret is per-repo, fan it out deliberately rather than one at a time:
for r in $(gh repo list <owner> --limit 200 --json name --jq '.[].name'); do gh secret set <NAME> -R <owner>/"$r" < secret.txtdone| Secret | Used by | Source |
|---|---|---|
ASC_KEY_ID |
release | App Store Connect → Users and Access → Integrations → API key |
ASC_ISSUER_ID |
release | same page (issuer ID) |
ASC_KEY_CONTENT |
release | the .p8 private key contents |
APPLE_TEAM_ID |
release | Apple Developer membership |
KEYCHAIN_PASSWORD |
release (signing) | the dedicated runner’s login-keychain password — set this as an org-level secret so every repo’s release can unlock the system keychain (release never creates its own, and its final always() step restores the keychain’s prior settings and re-locks it, so neither the unlocked window nor the timeout change outlives the run) |
CLAUDE_CODE_OAUTH_TOKEN |
claude, quality-review, dependency-audit | claude setup-token |
SENTRY_AUTH_TOKEN |
release (dSYM upload) | Sentry → Settings → Auth Tokens, scoped to project:releases |
SENTRY_ORG |
release (dSYM upload) | the Sentry org slug |
SENTRY_PROJECT |
release (dSYM upload) | the Sentry project slug — differs per repo, so this one is never org-level |
REVENUECAT_REST_API_KEY |
server/REST API calls | RevenueCat → API keys → secret key (sk_…) — full account access |
APP_STORE_CONNECT_FEEDBACK_KEY_IDENTIFIER |
testflight-feedback | a separate, least-privilege ASC API key id (read-only) |
APP_STORE_CONNECT_FEEDBACK_ISSUER_ID |
testflight-feedback | issuer id for that key |
APP_STORE_CONNECT_FEEDBACK_PRIVATE_KEY |
testflight-feedback | that key’s .p8 contents |
The TestFlight-feedback job uses its own App Store Connect key, distinct from the release/signing key (ASC_*) — it only needs read access to beta feedback, and it runs on a GitHub-hosted runner, so it must never carry the signing key.
Several of these are opt-in, and skip rather than fail when unset, so a project that hasn’t provisioned a vendor isn’t permanently red:
- TestFlight feedback. Without all three
APP_STORE_CONNECT_FEEDBACK_*secrets the daily run skips with a::notice::and stays green; a manualworkflow_dispatchfails loudly instead, because someone deliberately asked for feedback and a green check with zero results is indistinguishable from “no new feedback”. - Sentry dSYM upload. All three
SENTRY_*secrets must be present or the step skips — a project with no Sentry gets a clean release, not a red one. - The three Claude-powered workflows (
ios-claude.yml,ios-quality-review.yml,ios-dependency-audit.yml) hard-fail inside the action whenCLAUDE_CODE_OAUTH_TOKENis empty, so each checks for it first and behaves according to who is waiting. Unattended runs (quality-review, dependency-audit) skip with a::warning::and stay green, because a permanently red scheduled run trains you to stop reading the Actions tab. The interactive one (ios-claude.yml) fails and comments on the thread saying why — someone typed@claudeand is waiting.
GITHUB_TOKEN is provided automatically by Actions — do not set it.
The release job borrows your login keychain, so it must give it back. It
unlocks the login keychain to sign, and sets an auto-lock timeout to keep it open
across a 45-minute job. That timeout is a change to a keychain the job doesn’t
own, and it used to be permanent: a runner Mac that is also somebody’s personal
machine was left with lock-on-sleep timeout=3600s — macOS defaults to neither —
so it locked hourly and on every sleep, days later, with nothing in any run
saying why. The final always() step now captures the prior settings and
restores them. If your runner is dedicated hardware nobody logs into, none of
this is visible; if it’s also a machine you use, it’s worth knowing CI reaches
your login keychain at all.
CI runners
Section titled “CI runners”Every synced workflow already sets the correct runner per job — when editing an existing job, keep whatever runs-on it already has; don’t re-derive it. The rule below matters only when authoring a brand-new job:
Xcode-touching work (build/test/lint/archive/sign/release) uses runs-on: [self-hosted, macOS, ARM64, dedicated] — never a GitHub-hosted macOS runner (macos-latest) or a stray self-hosted label like mac-mini. A pure script/REST-call job with no Xcode dependency (merge gates, a TestFlight-feedback fetch, a deploy) uses ubuntu-latest instead — don’t tie up the Mac for work that doesn’t need it.
See the macos-ci-recipes skill for the reasoning and copy-in recipes when the new job is a macOS-only or hybrid iOS+macOS workflow.
Build & test tooling (flowdeck)
Section titled “Build & test tooling (flowdeck)”In an interactive session, reach for flowdeck first — build, run, test, simulator, device, logs, UI automation. It resolves schemes and destinations, keeps DerivedData where you point it, and returns structured output, so it beats hand-assembling an xcodebuild line every time.
flowdeck simulator list # find an available simulator UDID (names are ambiguous across OS versions)flowdeck build -w {{XCODEPROJ}} -s <YourScheme> -S <udid> -d {{COMPONENT_PREFIX}}DerivedDataflowdeck test -w {{XCODEPROJ}} -s <YourScheme> -S <udid> -d {{COMPONENT_PREFIX}}DerivedDataflowdeck project packages update # bump SPM deps within constraints (no .pbxproj edit)Prefer a UDID over a simulator name — names duplicate across OS versions and resolve ambiguously.
Raw xcodebuild/xcrun is correct in non-interactive contexts, and this profile ships it that way. .pre-commit-config.yaml runs xcodebuild test with an explicit -scheme/-destination; ios-ci.yml and ios-release.yml run xcodebuild for build, test and archive, xcodebuild -showBuildSettings in the Baseline job, and xcrun simctl for the whole simulator lifecycle. Those pin their destination and toolchain deliberately, and the steps around them parse their output — so don’t “fix” a hook or a workflow to call flowdeck instead. That changes what CI actually verifies; it isn’t a style cleanup.
Working in worktrees
Section titled “Working in worktrees”- Pass a unique derived-data path per worktree (
-d {{COMPONENT_PREFIX}}DerivedData-<feature>) so parallel worktrees don’t collide on one DerivedData dir (collisions surface as SIGKILL test crashes). - Delete that derived-data dir before running format/lint — otherwise it lints compiled dependency sources and reports phantom
file_length/format violations. (The.swiftformat/.swiftlint.ymlexcludes coverDerivedData*; keep your path matching that glob.) - Ignore SourceKit diagnostics in a fresh worktree (
No such module 'X',Cannot find type) — the worktree has no built index, so they’re false positives. The authoritative signal isflowdeck build/flowdeck test’s printed output — not its exit code (see above).
Editor hooks (.claude/settings.json)
Section titled “Editor hooks (.claude/settings.json)”The synced .claude/settings.json installs hooks that: block edits to .pbxproj/.xcworkspace/.xib/.storyboard/.entitlements (PreToolUse), run SwiftFormat + SwiftLint on every .swift write (PostToolUse), and — on SessionStart — auto-approve the Xcode MCP permission dialog via allow_mcp.js (requires macOS Accessibility permission for your terminal). That auto-approve is a deliberate convenience; remove the SessionStart hook if you’d rather approve the Xcode MCP dialog manually.
Test timeout rule
Section titled “Test timeout rule”Tests must never run longer than 5 minutes (300 seconds). If tests exceed 5 minutes, they’re hung. Kill the process immediately and investigate. When invoking builds/tests via a Bash tool, set a 300000 ms timeout.
Architecture
Section titled “Architecture”View (SwiftUI) → ViewModel (@Observable, @MainActor) → Service → Repository → DataSourceKey patterns:
- All ViewModels:
@Observable+@MainActor. - All service/repository protocols:
Sendable. - Stateless services:
final class; stateful services:actor. - Async operations:
async/awaitandAsyncStream. - Constructor injection for dependencies.
Project structure:
{{COMPONENT_PREFIX}}<YourApp>/├── App/ # App entry point, dependency container├── Features/ # Feature modules (one folder per feature)├── Core/ # Services, Repositories, Models, Networking├── Shared/ # Components, Extensions, Utilities└── Resources/Layer rule: ViewModels must not depend directly on Repository protocols. Inject Service protocols instead.
SwiftData + CloudKit
Section titled “SwiftData + CloudKit”If this project imports SwiftData, lacquer sync suggests the
dpearson2699/swift-ios-skills@swiftdata skill (see internal/skillsuggest)
— it covers CloudKit-compatible schema constraints. Install it with
skills add if it wasn’t suggested.
Testing
Section titled “Testing”Swift Testing is the standard for all new test files. Use @Test, @Suite, and #expect. XCTest is legacy — only modify existing XCTest files when touched for other reasons. Never create new XCTest files.
import Testing@testable import <YourApp>
@Suite("Feature Tests", .serialized)@MainActorstruct FeatureTests { private var mockService = MockService() private var sut: FeatureViewModel { FeatureViewModel(service: mockService) }
@Test func testBehavior() async { // arrange, act, assert }}Targeted tests during development
Section titled “Targeted tests during development”During RED/GREEN, run targeted tests only (-only-testing:<YourApp>Tests/SomeSuite/someTest) — never a full self-run. The full suite runs at pre-commit and again in CI (fresh checkout). Treat SwiftLint warnings as errors — fix the code, never suppress (see core rule 7).
Test support: waitUntil (no Task.sleep in tests)
Section titled “Test support: waitUntil (no Task.sleep in tests)”The no_task_sleep_in_tests lint rule bans arbitrary Task.sleep delays in tests — they cause flaky failures. See the swift-testing-wait-until skill for the polling helper to add to your test target instead.
Local checks vs CI
Section titled “Local checks vs CI”Every CI gate and where it runs before push. See core “Local checks match CI” — a new CI job adds a row here, and a hook never runs weaker than its CI twin, except a job that is itself a build or test run (see the note below the table).
| CI job / step | Local |
|---|---|
Lint → SwiftLint --strict |
pre-commit swiftlint (--strict, staged files) |
Lint → SwiftFormat --lint |
pre-commit swiftformat (writes; a changed file fails the commit) |
missing_docs |
pre-commit swiftlint-docs (--strict, staged files) |
Test |
CI-only — see below |
Baseline |
lacquer audit (exit 4) — CI-only, it reads the pbxproj |
Build (Release) |
CI-only: a full Release archive is not a commit-time cost |
No lacquer drift |
lacquer audit (exit 3) — run it locally any time |
No local xcodebuild test/docbuild hook, deliberately. This fleet’s
self-hosted Mac runner is frequently the very same physical machine you commit
from. On separate hardware, a local build/test hook buys you an earlier signal
before a slower CI run; here it buys nothing but a second, identical
xcodebuild invocation on the one shared box you’re also trying not to tie up.
Test is CI-only for that reason — not an oversight, and not a case of “a
hook never runs weaker than its CI twin,” since there is no weaker local
version, only none. Everything else in the table above is static analysis
(lint/format) with no build cost, so it stays local as usual.
The --strict flags are the load-bearing part. line_length, file_length,
type_body_length and function_body_length are all warning severity in
.swiftlint.yml, so without --strict they print and pass locally and then fail
the PR. A hook carrying || true, or missing --strict, is the single most
common way this fleet produces a “worked on my machine” failure — and it is now a
drift violation, not just a bad idea.
The editor hook counts too. The PostToolUse hooks in
.claude/settings.json run on every Swift write, and they were the worst
offender of all: swiftlint lint --path "$FP" --quiet 2>/dev/null || true.
--path has not been a valid SwiftLint option for some time — the command
errored on every single invocation, and 2>/dev/null || true swallowed the
error, so the hook linted nothing, ever, and looked healthy doing it. It now
passes the path positionally, names the project config explicitly, runs
--strict, and on a violation exits 2 so the diagnostic reaches the agent that
just wrote the file. The formatter hook likewise names --config explicitly, and
neither hook suppresses stderr any more.
Documentation (DocC)
Section titled “Documentation (DocC)”DocC is a requirement — see core documentation rules for the standard and the relaxation mechanism. This is the Swift half.
Every declaration above private carries a /// doc comment, and the DocC
archive builds with zero warnings. Both are checked by the local pre-commit
hook — there is no CI-side publishing step; this is a local,
pre-commit-enforced requirement only.
Use - Parameter / - Returns / - Throws for anything a caller must know, and
double-backtick symbol links rather than plain-text type names — a link is
checked by the build, prose isn’t. Run the checks locally:
swiftlint --strict --config .swiftlint-docs.yml . # every declaration documentedscripts/build-docs.sh docs-site # docs build clean.swiftlint-docs.yml is a separate config from .swiftlint.yml on purpose: the
documentation baseline is the one rule set a project may relax, and mixing it in
would either hand every style rule an escape hatch or leave this one without the
one the standard promises.
Three settings in scripts/build-docs.sh decide whether this checks anything:
DOCC_MINIMUM_ACCESS_LEVEL=internal— DocC extractspublicand above by default, and an app target’s code isinternalby default. Without it an app builds an archive containing essentially nothing, succeeds, and reports as a pass.OTHER_DOCC_FLAGS=--warnings-as-errors—DOCC_FLAGSis also a real build setting name, and it’s silently ignored. The same broken symbol link exits 0 withDOCC_FLAGSand 65 withOTHER_DOCC_FLAGS; picking the obvious-looking name turns the gate off without turning the job red.DOCC_TRANSFORM_FOR_STATIC_HOSTING=YESplusDOCC_HOSTING_BASE_PATH, which is baked into every asset URL and must match where the site is served from.
A .docc catalog adds landing pages, articles, and tutorials beyond the symbol
reference; a <Target>.md root page is the highest-value addition, because it’s
what a reader lands on.
Battery & performance patterns
Section titled “Battery & performance patterns”Apply these whenever touching widgets, animations, networking, or background
work — see the ios-performance-battery-patterns skill for the concrete
patterns (Timeline entry limits, animation cleanup, Low Power Mode guards,
constrained-network config, observer/task cleanup under @Observable).
Swift 6 concurrency & default actor isolation
Section titled “Swift 6 concurrency & default actor isolation”Swift 6 language mode is the baseline, in every build configuration — not just
the app target. SWIFT_VERSION = 6 and SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
are asserted by the lacquer and checked two ways: lacquer audit reads the
pbxproj statically across every configuration, and the CI Baseline job reads
the effective settings via xcodebuild -showBuildSettings. Below Swift 6,
data-race diagnostics are warnings rather than errors, so violations accrue
invisibly until the migration has to happen as one large risky change. A target
left behind (tests, widget, watch app) reports as a coverage ratio like 4/12,
not as a pass. Genuine exceptions go in [baseline.relax] with a reason and an
expiry.
If the app target sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (approachable concurrency), classes without an explicit isolation annotation — including services — are implicitly @MainActor.
await urlSession.data(for:)still does its network I/O off the main thread; the suspension yields. Only the synchronous work around it (e.g. JSON decoding) runs on the main actor — fine at small payload sizes.- If a method does heavy synchronous work (large decode, image processing, crypto), mark that method (or the type)
nonisolated/@concurrentdeliberately so it runs off-main. - Never reach for
@unchecked Sendableornonisolated(unsafe)to silence a diagnostic. Fix the root cause: make the type a value type, isolate it to an actor, or make stored state immutable.
iOS 26 API gotchas
Section titled “iOS 26 API gotchas”- Mini-player / bottom accessory: the shipping API is
.tabViewBottomAccessory { ... }— not.tabViewAccessory. - Tab-bar morphing search: declare the search tab with
Tab(role: .search)and use.searchable(text:prompt:)with automatic placement.SearchFieldPlacement.tabBardoes not exist in the iOS 26 SDK. - Naming: name your tab enum
AppTab(or similar) — a type namedTabshadows SwiftUI’sTabbuilder struct and breaks theTabViewcontent.
URL validation security posture
Section titled “URL validation security posture”Validate every user-provided URL before it reaches AVPlayer, URLSession,
or a WKWebView — see the url-validation-security skill for the
positive-allowlist validator and where to apply it.
Accessibility & design-token contrast (WCAG 1.4.11)
Section titled “Accessibility & design-token contrast (WCAG 1.4.11)”Audit non-text contrast, not just text. Ship two distinct boundary tokens and use them for their intended roles:
controlBorder— ~white @ 30% opacity, ≥ 3:1 against its background — for the boundary of an interactive control (button outline, text-field border, selected chip).- a decorative hairline — ~white @ 8% — for dividers and separators that carry no meaning.
Other rules:
- Use a saturated
controlAccentfor controls that sit against a white system thumb (e.g.Toggle). A near-white accent fails ~3:1 against the white thumb and reads as “off” to low-vision users. - Selection states must be non-color-redundant: show a checkmark / icon, not just a colored ring or tint, so the state survives color-blindness and grayscale.
Premium / subscription gating (if monetized)
Section titled “Premium / subscription gating (if monetized)”If this project imports StoreKit, lacquer sync suggests the
dpearson2699/swift-ios-skills@storekit skill (see internal/skillsuggest)
— it covers paywall/entitlement architecture. Install it with skills add
if it wasn’t suggested.