agentic-team 0.0.0-stage → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +184 -2
- package/bin/build-template.js +208 -0
- package/bin/index.js +1139 -0
- package/package.json +41 -4
- package/template/.claude-plugin/plugin.json +10 -0
- package/template/.orchestra/README.md +401 -0
- package/template/.orchestra/blueprints/README.md +87 -0
- package/template/.orchestra/blueprints/api-only.md +46 -0
- package/template/.orchestra/blueprints/component-crud-resource.md +25 -0
- package/template/.orchestra/blueprints/saas-starter.md +69 -0
- package/template/.orchestra/config.yml +277 -0
- package/template/.orchestra/roles/orchestrator.md +105 -0
- package/template/.orchestra/roles/product-manager.md +438 -0
- package/template/.orchestra/scripts/compact-diff.awk +45 -0
- package/template/.orchestra/stacks/README.md +81 -0
- package/template/.orchestra/stacks/_lib/common.sh +3095 -0
- package/template/.orchestra/stacks/_repo/setup.sh +1109 -0
- package/template/.orchestra/stacks/backend-hono/claude/commands/endpoint.md +13 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/api-endpoint/SKILL.md +111 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/testing/SKILL.md +75 -0
- package/template/.orchestra/stacks/backend-hono/setup.sh +5945 -0
- package/template/.orchestra/stacks/backend-hono/stack.md +195 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/commands/component.md +13 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-android-native/setup.sh +607 -0
- package/template/.orchestra/stacks/mobile-android-native/stack.md +138 -0
- package/template/.orchestra/stacks/mobile-expo/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-expo/claude/skills/design-component/SKILL.md +92 -0
- package/template/.orchestra/stacks/mobile-expo/setup.sh +470 -0
- package/template/.orchestra/stacks/mobile-expo/stack.md +141 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-ios-native/setup.sh +468 -0
- package/template/.orchestra/stacks/mobile-ios-native/stack.md +130 -0
- package/template/.orchestra/stacks/web-nextjs/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-nextjs/setup.sh +128 -0
- package/template/.orchestra/stacks/web-nextjs/stack.md +170 -0
- package/template/.orchestra/stacks/web-vite-react/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-vite-react/setup.sh +185 -0
- package/template/.orchestra/stacks/web-vite-react/stack.md +177 -0
- package/template/.orchestra/trackers/github.sh +467 -0
- package/template/CLAUDE.md +54 -0
- package/template/agents/architect.md +83 -0
- package/template/agents/engineer-high.md +17 -0
- package/template/agents/engineer-low.md +17 -0
- package/template/agents/engineer-max.md +17 -0
- package/template/agents/engineer-medium.md +17 -0
- package/template/agents/engineer-xhigh.md +17 -0
- package/template/agents/lead.md +870 -0
- package/template/agents/milestone-reviewer.md +63 -0
- package/template/agents/orchestrator.md +105 -0
- package/template/agents/product-manager.md +438 -0
- package/template/agents/reviewer.md +301 -0
- package/template/agents/scaffolder.md +180 -0
- package/template/agents/tracker.md +202 -0
- package/template/commands/blueprint.md +23 -0
- package/template/commands/create-role.md +64 -0
- package/template/commands/export.md +45 -0
- package/template/commands/help.md +55 -0
- package/template/commands/hotfix.md +12 -0
- package/template/commands/orchestrator.md +12 -0
- package/template/commands/pm.md +18 -0
- package/template/commands/rewind.md +61 -0
- package/template/commands/setup.md +14 -0
- package/template/commands/start.md +29 -0
- package/template/commands/status.md +11 -0
- package/template/commands/sync.md +73 -0
- package/template/commands/verifier.md +53 -0
- package/template/hooks/audit-context.sh +153 -0
- package/template/hooks/enforce-role.sh +175 -0
- package/template/hooks/protect-system-files.sh +123 -0
- package/template/hooks/review-rounds.sh +172 -0
- package/template/rules/acceptance-check.orchestra.md +11 -0
- package/template/rules/analytics-kpi.orchestra.md +56 -0
- package/template/rules/code-standards.orchestra.md +34 -0
- package/template/rules/comments.orchestra.md +77 -0
- package/template/rules/commit-format.orchestra.md +20 -0
- package/template/rules/dependency-upgrade.orchestra.md +112 -0
- package/template/rules/design-parity.orchestra.md +170 -0
- package/template/rules/observability.orchestra.md +118 -0
- package/template/rules/performance.orchestra.md +186 -0
- package/template/rules/phase-limits.orchestra.md +3 -0
- package/template/rules/pull-request.orchestra.md +213 -0
- package/template/rules/role-boundaries.orchestra.md +117 -0
- package/template/rules/security.orchestra.md +106 -0
- package/template/rules/stuck-detection.orchestra.md +25 -0
- package/template/rules/testing-standards.orchestra.md +214 -0
- package/template/rules/tracker-authority.orchestra.md +190 -0
- package/template/rules/verification-gate.orchestra.md +56 -0
- package/template/skills/accessibility/SKILL.md +31 -0
- package/template/skills/android-testing/SKILL.md +112 -0
- package/template/skills/architecture/SKILL.md +175 -0
- package/template/skills/auth-setup/SKILL.md +105 -0
- package/template/skills/best-practices/SKILL.md +29 -0
- package/template/skills/code-optimizer/SKILL.md +27 -0
- package/template/skills/core-web-vitals/SKILL.md +32 -0
- package/template/skills/crud-api/SKILL.md +32 -0
- package/template/skills/debug/SKILL.md +28 -0
- package/template/skills/deployment/SKILL.md +104 -0
- package/template/skills/error-handling/SKILL.md +154 -0
- package/template/skills/frontend-design/SKILL.md +30 -0
- package/template/skills/fullstack-infrastructure/SKILL.md +108 -0
- package/template/skills/ios-testing/SKILL.md +100 -0
- package/template/skills/mobile-testing/SKILL.md +102 -0
- package/template/skills/observability/SKILL.md +91 -0
- package/template/skills/project-structure/SKILL.md +114 -0
- package/template/skills/push-notifications/SKILL.md +146 -0
- package/template/skills/react-best-practices/SKILL.md +31 -0
- package/template/skills/realtime/SKILL.md +146 -0
- package/template/skills/review/SKILL.md +82 -0
- package/template/skills/testing/SKILL.md +44 -0
- package/template/skills/web-quality-audit/SKILL.md +47 -0
- package/template/skills/web-testing/SKILL.md +120 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Expo / React Native.
|
|
3
|
+
#
|
|
4
|
+
# No styling framework and no UI library. Tokens are a typed module and
|
|
5
|
+
# components are built from React Native primitives, so the kit owes nothing
|
|
6
|
+
# to a third party's release schedule.
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
DIR="${1:?usage: setup.sh <target-dir>}"
|
|
10
|
+
LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../_lib" && pwd)"
|
|
11
|
+
# shellcheck source=../_lib/common.sh
|
|
12
|
+
source "$LIB/common.sh"
|
|
13
|
+
|
|
14
|
+
# --no-agents-md: create-expo-app writes its own CLAUDE.md and
|
|
15
|
+
# .claude/settings.json, which would collide with the stack's pack.
|
|
16
|
+
log "scaffolding expo into $DIR"
|
|
17
|
+
# --yes and </dev/null together: inside an existing git repository the CLI
|
|
18
|
+
# asks whether to skip `git init`, and with a pipe for stdin it waits for an
|
|
19
|
+
# answer that never comes. A create tool must never be able to block a
|
|
20
|
+
# scaffold — closing stdin turns a missed prompt into an error instead of a
|
|
21
|
+
# hang.
|
|
22
|
+
npx --yes create-expo-app@latest "$DIR" \
|
|
23
|
+
--yes --template blank-typescript --no-agents-md --no-install </dev/null
|
|
24
|
+
|
|
25
|
+
assert_scaffolded "$DIR"
|
|
26
|
+
cd "$DIR"
|
|
27
|
+
|
|
28
|
+
setup_yarn
|
|
29
|
+
yarn_install
|
|
30
|
+
|
|
31
|
+
setup_typescript
|
|
32
|
+
setup_biome
|
|
33
|
+
setup_node_pin
|
|
34
|
+
setup_typecheck_script
|
|
35
|
+
|
|
36
|
+
# Native modules — expo install keeps them on SDK-compatible versions.
|
|
37
|
+
expo_install @sentry/react-native posthog-react-native
|
|
38
|
+
# Test deps, in the shape today's packages actually want. Verified against a
|
|
39
|
+
# real scaffold — each of these was a failure before it was a comment:
|
|
40
|
+
# - jest is neither a dependency nor a peer of jest-expo, so without it
|
|
41
|
+
# `npm test` is "jest: command not found"
|
|
42
|
+
# - RNTL 14 peers on `test-renderer`, NOT the deprecated react-test-renderer.
|
|
43
|
+
# react-test-renderer@latest wants a react newer than the SDK pins and npm
|
|
44
|
+
# refuses the whole tree.
|
|
45
|
+
# - @react-native/jest-preset IS required, but only at the version matching
|
|
46
|
+
# the installed react-native. At @latest it drags in a react-dom newer than
|
|
47
|
+
# the SDK's react and the whole tree is rejected. npm's flat hoisting hid
|
|
48
|
+
# this; yarn does not hoist, so the preset must be declared.
|
|
49
|
+
expo_install --dev jest-expo
|
|
50
|
+
# - jest is pinned to the 29 line because jest-expo depends on jest 29
|
|
51
|
+
# packages throughout. jest@latest installs 30, and the resulting
|
|
52
|
+
# jest-runtime 30 / jest-mock 29 split fails every suite with
|
|
53
|
+
# "this._moduleMocker.clearMocksOnScope is not a function".
|
|
54
|
+
add_latest --dev "jest@29" test-renderer @testing-library/react-native
|
|
55
|
+
# @types/jest is on the SDK table (57 wants 29.5.14, not the registry's 30),
|
|
56
|
+
# so it goes through expo install like every other managed package.
|
|
57
|
+
expo_install --dev @types/jest
|
|
58
|
+
|
|
59
|
+
# Match the preset to the installed react-native rather than taking @latest.
|
|
60
|
+
RN_VERSION="$(node -e 'const d=require("./package.json").dependencies;process.stdout.write(String(d["react-native"]||"").replace(/[^0-9.]/g,""))')"
|
|
61
|
+
if [ -n "$RN_VERSION" ]; then
|
|
62
|
+
add_latest --dev "@react-native/jest-preset@${RN_VERSION}"
|
|
63
|
+
else
|
|
64
|
+
warn "could not read react-native version — install @react-native/jest-preset manually"
|
|
65
|
+
fi
|
|
66
|
+
pkg_set scripts.test="jest --passWithNoTests" scripts.test:watch="jest --watch"
|
|
67
|
+
pkg_set jest.preset="jest-expo"
|
|
68
|
+
|
|
69
|
+
log "writing design system skeleton"
|
|
70
|
+
mkdir -p src/design/helpers
|
|
71
|
+
|
|
72
|
+
cat > src/design/tokens.ts <<'TS'
|
|
73
|
+
/**
|
|
74
|
+
* Design tokens. Populated from the Claude Design source before any
|
|
75
|
+
* component is written. Colour is one axis of several — do not stop there.
|
|
76
|
+
*
|
|
77
|
+
* `as const` is deliberate: it makes every value a literal type, so a typo
|
|
78
|
+
* in a component is a compile error rather than a silent undefined.
|
|
79
|
+
*/
|
|
80
|
+
export const tokens = {
|
|
81
|
+
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
|
|
82
|
+
radius: { control: 8, surface: 12, pill: 999 },
|
|
83
|
+
fontSize: { caption: 12, body: 16, title: 20, display: 28 },
|
|
84
|
+
fontWeight: { regular: "400", medium: "500", bold: "700" },
|
|
85
|
+
duration: { fast: 120, normal: 240 },
|
|
86
|
+
// Minimum touch target on both platforms. Not negotiable.
|
|
87
|
+
hitTarget: 44,
|
|
88
|
+
} as const;
|
|
89
|
+
|
|
90
|
+
/** Raw palette. Components never reference these — they use the theme. */
|
|
91
|
+
export const palette = {
|
|
92
|
+
white: "#ffffff",
|
|
93
|
+
black: "#000000",
|
|
94
|
+
neutral50: "#f9fafb",
|
|
95
|
+
neutral900: "#111827",
|
|
96
|
+
brand500: "#2563eb",
|
|
97
|
+
danger500: "#dc2626",
|
|
98
|
+
} as const;
|
|
99
|
+
TS
|
|
100
|
+
|
|
101
|
+
cat > src/design/theme.ts <<'TS'
|
|
102
|
+
import { palette } from "./tokens";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Semantic layer. Components reference these names, never the palette, so
|
|
106
|
+
* a theme change stays a one-file change.
|
|
107
|
+
*/
|
|
108
|
+
export type Theme = {
|
|
109
|
+
surface: string;
|
|
110
|
+
onSurface: string;
|
|
111
|
+
accent: string;
|
|
112
|
+
danger: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const lightTheme: Theme = {
|
|
116
|
+
surface: palette.white,
|
|
117
|
+
onSurface: palette.neutral900,
|
|
118
|
+
accent: palette.brand500,
|
|
119
|
+
danger: palette.danger500,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const darkTheme: Theme = {
|
|
123
|
+
surface: palette.neutral900,
|
|
124
|
+
onSurface: palette.neutral50,
|
|
125
|
+
accent: palette.brand500,
|
|
126
|
+
danger: palette.danger500,
|
|
127
|
+
};
|
|
128
|
+
TS
|
|
129
|
+
|
|
130
|
+
cat > src/design/ThemeProvider.tsx <<'TSX'
|
|
131
|
+
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
132
|
+
import { useColorScheme } from "react-native";
|
|
133
|
+
import { darkTheme, lightTheme, type Theme } from "./theme";
|
|
134
|
+
|
|
135
|
+
export type ThemeName = "light" | "dark" | "system";
|
|
136
|
+
|
|
137
|
+
const ThemeContext = createContext<Theme>(lightTheme);
|
|
138
|
+
|
|
139
|
+
/** Read the active theme. Components call this instead of importing colours. */
|
|
140
|
+
export const useTheme = (): Theme => useContext(ThemeContext);
|
|
141
|
+
|
|
142
|
+
export function ThemeProvider({
|
|
143
|
+
children,
|
|
144
|
+
name = "system",
|
|
145
|
+
}: {
|
|
146
|
+
children: ReactNode;
|
|
147
|
+
name?: ThemeName;
|
|
148
|
+
}) {
|
|
149
|
+
const system = useColorScheme();
|
|
150
|
+
const theme = useMemo(() => {
|
|
151
|
+
const resolved = name === "system" ? (system ?? "light") : name;
|
|
152
|
+
return resolved === "dark" ? darkTheme : lightTheme;
|
|
153
|
+
}, [name, system]);
|
|
154
|
+
|
|
155
|
+
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
|
|
156
|
+
}
|
|
157
|
+
TSX
|
|
158
|
+
|
|
159
|
+
cat > src/design/types.ts <<'TS'
|
|
160
|
+
// Shared design-system types only. Nothing app-specific, no business logic.
|
|
161
|
+
export type Size = "sm" | "md" | "lg";
|
|
162
|
+
export type Variant = "primary" | "secondary" | "danger";
|
|
163
|
+
TS
|
|
164
|
+
|
|
165
|
+
cat > src/design/index.ts <<'TS'
|
|
166
|
+
export * from "./tokens";
|
|
167
|
+
export * from "./theme";
|
|
168
|
+
export * from "./types";
|
|
169
|
+
export { ThemeProvider, useTheme } from "./ThemeProvider";
|
|
170
|
+
TS
|
|
171
|
+
|
|
172
|
+
install_pre_commit_gate
|
|
173
|
+
# Jest here, not vitest — setup_coverage writes a vitest config, which would
|
|
174
|
+
# be dead weight in this app.
|
|
175
|
+
setup_integrations_dir
|
|
176
|
+
|
|
177
|
+
setup_coverage_jest
|
|
178
|
+
setup_mutation
|
|
179
|
+
|
|
180
|
+
# The two commands that see what typecheck and jest cannot: a native module
|
|
181
|
+
# off the SDK's compatibility table. This is the class of defect that passes
|
|
182
|
+
# every JS test and then fails on a device.
|
|
183
|
+
# Installed rather than run through npx: CI should not resolve a different
|
|
184
|
+
# version of the checker on every run. add_latest, not expo_install — the
|
|
185
|
+
# checker is not on the SDK table, and routing it through `expo install`
|
|
186
|
+
# makes the CLI re-parse its own argv and fail on "install" as a directory.
|
|
187
|
+
add_latest --dev expo-doctor
|
|
188
|
+
pkg_set scripts.doctor="expo-doctor && expo install --check"
|
|
189
|
+
# A real build, without EAS or a signing identity. `expo export` runs Metro
|
|
190
|
+
# over the whole graph exactly as a release bundle does, so an import that
|
|
191
|
+
# only resolves in dev, a missing asset, or a native module the SDK does not
|
|
192
|
+
# ship all fail here — none of which tsc, lint or jest can see. CI has no
|
|
193
|
+
# device, so this is the only thing standing between a green suite and an app
|
|
194
|
+
# that cannot start.
|
|
195
|
+
pkg_set scripts.build="expo export --platform all --output-dir .expo-export"
|
|
196
|
+
grep -q '^\.expo-export/' .gitignore 2>/dev/null || printf '\n# expo export (build output)\n.expo-export/\n' >> .gitignore
|
|
197
|
+
|
|
198
|
+
# Our own API, faked at the network layer. Mocking the fetch wrapper instead
|
|
199
|
+
# would leave the wrapper — the thing that breaks when the API changes —
|
|
200
|
+
# untested. msw ships react-native export conditions.
|
|
201
|
+
add_latest --dev msw
|
|
202
|
+
|
|
203
|
+
# Pin the application id before anything reads it.
|
|
204
|
+
#
|
|
205
|
+
# Left unset, `expo prebuild` derives it from the machine's username — the
|
|
206
|
+
# developer gets com.sulhadin.mobile and CI gets com.runner.mobile, for the
|
|
207
|
+
# same commit. Every id-addressed thing downstream then points at an app that
|
|
208
|
+
# does not exist on the device it is running against: maestro flows, push
|
|
209
|
+
# certificates, deep links, store uploads.
|
|
210
|
+
#
|
|
211
|
+
# com.example is deliberately not a plausible id. It has to be replaced with
|
|
212
|
+
# the real organisation identifier before any release, and it should be
|
|
213
|
+
# obvious that it has not been.
|
|
214
|
+
node -e '
|
|
215
|
+
const fs = require("fs");
|
|
216
|
+
const f = ["app.json", "app.config.json"].find((x) => fs.existsSync(x));
|
|
217
|
+
if (!f) process.exit(0);
|
|
218
|
+
const doc = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
219
|
+
const expo = (doc.expo = doc.expo || {});
|
|
220
|
+
const id = `com.example.${(expo.slug || "app").replace(/[^a-z0-9]/gi, "")}`;
|
|
221
|
+
expo.android = { ...(expo.android || {}) };
|
|
222
|
+
expo.ios = { ...(expo.ios || {}) };
|
|
223
|
+
if (!expo.android.package) expo.android.package = id;
|
|
224
|
+
if (!expo.ios.bundleIdentifier) expo.ios.bundleIdentifier = id;
|
|
225
|
+
fs.writeFileSync(f, JSON.stringify(doc, null, 2) + "\n");
|
|
226
|
+
console.log(" [+] application id pinned to " + expo.android.package);
|
|
227
|
+
'
|
|
228
|
+
warn "the application id is a placeholder — set ios.bundleIdentifier and"
|
|
229
|
+
warn "android.package to your real organisation id before any release."
|
|
230
|
+
|
|
231
|
+
# Sentry's Xcode build phase runs sentry-cli to upload source maps, and with
|
|
232
|
+
# no organisation configured it does not warn — it fails the build with
|
|
233
|
+
# exit 65. A freshly scaffolded app therefore cannot build for iOS at all,
|
|
234
|
+
# while Android builds fine, so nothing catches it until someone opens Xcode.
|
|
235
|
+
#
|
|
236
|
+
# sentry-xcode.sh gates on a shell variable in the build phase's own
|
|
237
|
+
# environment: if [ "$SENTRY_DISABLE_AUTO_UPLOAD" != true ]
|
|
238
|
+
# It never sources a dotenv, and ios/.xcode.env is regenerated by every
|
|
239
|
+
# `expo prebuild`, so the variable has to be set where the build is invoked.
|
|
240
|
+
#
|
|
241
|
+
# Not a blanket disable: with real credentials present the upload still runs.
|
|
242
|
+
# Symbols remain required before release — see
|
|
243
|
+
# .claude/rules/observability.orchestra.md. This only stops a build that has
|
|
244
|
+
# no credentials from failing because of it.
|
|
245
|
+
SENTRY_GUARD='SENTRY_DISABLE_AUTO_UPLOAD=$([ -n "$SENTRY_AUTH_TOKEN" ] && echo false || echo true)'
|
|
246
|
+
# LANG for the same reason e2e-ios.sh sets it, and it belongs here too:
|
|
247
|
+
# `expo run:ios` shells out to CocoaPods, which reads the Podfile through
|
|
248
|
+
# ruby's default external encoding. With no UTF-8 locale that is US-ASCII and
|
|
249
|
+
# pod install dies inside String#unicode_normalize — after which xcodebuild
|
|
250
|
+
# reports "the sandbox is not in sync with the Podfile.lock", which names
|
|
251
|
+
# neither the cause nor the fix. The e2e path was guarded and the path a
|
|
252
|
+
# developer actually types was not.
|
|
253
|
+
IOS_LOCALE='LANG=${LANG:-en_US.UTF-8}'
|
|
254
|
+
pkg_set "scripts.ios=${IOS_LOCALE} ${SENTRY_GUARD} expo run:ios" \
|
|
255
|
+
"scripts.android=${SENTRY_GUARD} expo run:android"
|
|
256
|
+
log "sentry: symbol upload runs only when SENTRY_AUTH_TOKEN is present"
|
|
257
|
+
|
|
258
|
+
# The launch flow has to assert something only this app can render. Without a
|
|
259
|
+
# marker it can only assert "some element is visible", which React Native's
|
|
260
|
+
# red bundle-error screen satisfies — the one failure the check exists to
|
|
261
|
+
# catch.
|
|
262
|
+
#
|
|
263
|
+
# The root view owns the marker, so the assertion holds no matter what the
|
|
264
|
+
# screen becomes. setup_maestro refuses to write flows without it.
|
|
265
|
+
cat > App.tsx <<'TSX'
|
|
266
|
+
import { StatusBar } from "expo-status-bar";
|
|
267
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
268
|
+
|
|
269
|
+
export default function App() {
|
|
270
|
+
// testID="app-root" is load-bearing: .maestro/launch.yaml asserts it to
|
|
271
|
+
// prove this app's tree actually mounted. Keep it on whatever the root
|
|
272
|
+
// becomes — a navigator, a provider tree — or the launch check weakens
|
|
273
|
+
// to "something is on screen", which a bundle-error screen satisfies.
|
|
274
|
+
return (
|
|
275
|
+
<View style={styles.container} testID="app-root">
|
|
276
|
+
<Text>Open up App.tsx to start working on your app!</Text>
|
|
277
|
+
<StatusBar style="auto" />
|
|
278
|
+
</View>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const styles = StyleSheet.create({
|
|
283
|
+
container: {
|
|
284
|
+
flex: 1,
|
|
285
|
+
backgroundColor: "#fff",
|
|
286
|
+
alignItems: "center",
|
|
287
|
+
justifyContent: "center",
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
TSX
|
|
291
|
+
log "App.tsx carries testID=app-root for the launch flow"
|
|
292
|
+
|
|
293
|
+
setup_maestro
|
|
294
|
+
|
|
295
|
+
# Expo's tsconfig sets an explicit types list, so jest globals are invisible
|
|
296
|
+
# to tsc until "jest" is added to it.
|
|
297
|
+
node -e "$JSONC_READ"'
|
|
298
|
+
const fs = require("fs");
|
|
299
|
+
const f = "tsconfig.json";
|
|
300
|
+
if (!fs.existsSync(f)) process.exit(0);
|
|
301
|
+
const c = readJsonc(f);
|
|
302
|
+
c.compilerOptions = { ...(c.compilerOptions || {}) };
|
|
303
|
+
c.compilerOptions.types = [...new Set([...(c.compilerOptions.types || []), "jest"])];
|
|
304
|
+
fs.writeFileSync(f, JSON.stringify(c, null, 2) + "\n");
|
|
305
|
+
'
|
|
306
|
+
|
|
307
|
+
# Not refresh_scaffold_deps: in an Expo app the SDK is the authority, not
|
|
308
|
+
# the registry. setup_typescript installed typescript@latest, which the
|
|
309
|
+
# SDK does not accept — this puts every managed package back on the table
|
|
310
|
+
# and fails the scaffold if any of them is still off it.
|
|
311
|
+
assert_expo_sdk_aligned
|
|
312
|
+
|
|
313
|
+
# Format last: every generated file — the design skeleton, tailwind config
|
|
314
|
+
# and the tsconfig edit above — lands after setup_biome ran, and an
|
|
315
|
+
# unformatted scaffold is rejected by the gate this script just installed.
|
|
316
|
+
yarn biome check --write . >/dev/null 2>&1 || true
|
|
317
|
+
|
|
318
|
+
# ── e2e, as a script rather than only as CI steps ─────────────────────────
|
|
319
|
+
# The workflow used to carry the build and the maestro run inline, which made
|
|
320
|
+
# it unrunnable anywhere else: nobody could reproduce a red e2e locally, and
|
|
321
|
+
# the orchestrator's own CI could only test a *copy* of those steps. A copy
|
|
322
|
+
# that drifts proves nothing. One script, called by both.
|
|
323
|
+
cat > e2e-ios.sh <<'E2E'
|
|
324
|
+
#!/usr/bin/env bash
|
|
325
|
+
# Build the app for a simulator and run the maestro flows against it.
|
|
326
|
+
# Called by .github/workflows/mobile-e2e.yml and runnable by hand.
|
|
327
|
+
set -euo pipefail
|
|
328
|
+
cd "$(dirname "$0")"
|
|
329
|
+
|
|
330
|
+
# A debug e2e build has no Sentry credentials and must not fail for want of
|
|
331
|
+
# them. Sentry's Xcode phase gates on this exact variable and kills the build
|
|
332
|
+
# with exit 65 otherwise.
|
|
333
|
+
export SENTRY_DISABLE_AUTO_UPLOAD="${SENTRY_DISABLE_AUTO_UPLOAD:-true}"
|
|
334
|
+
|
|
335
|
+
# CocoaPods reads the Podfile through ruby's default external encoding. With
|
|
336
|
+
# no locale set that is US-ASCII, and `pod install` dies inside
|
|
337
|
+
# String#unicode_normalize with "Unicode Normalization not appropriate for
|
|
338
|
+
# ASCII-8BIT" — then xcodebuild fails with the far less helpful "sandbox is
|
|
339
|
+
# not in sync with the Podfile.lock".
|
|
340
|
+
export LANG="${LANG:-en_US.UTF-8}"
|
|
341
|
+
|
|
342
|
+
# maestro is a JVM application, and nothing else in this script needs a JDK.
|
|
343
|
+
# Without one it dies on "Unable to locate a Java Runtime" after the build has
|
|
344
|
+
# already been paid for.
|
|
345
|
+
if [ -z "${JAVA_HOME:-}" ]; then
|
|
346
|
+
JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null || true)"
|
|
347
|
+
[ -n "$JAVA_HOME" ] && export JAVA_HOME
|
|
348
|
+
fi
|
|
349
|
+
command -v java >/dev/null 2>&1 || [ -x "${JAVA_HOME:-}/bin/java" ] || {
|
|
350
|
+
echo "no JDK on PATH — maestro cannot run without one" >&2
|
|
351
|
+
exit 1
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if ! command -v maestro >/dev/null 2>&1; then
|
|
355
|
+
if [ -x "$HOME/.maestro/bin/maestro" ]; then
|
|
356
|
+
PATH="$HOME/.maestro/bin:$PATH"; export PATH
|
|
357
|
+
else
|
|
358
|
+
echo "── installing maestro"
|
|
359
|
+
curl -fsSL https://get.maestro.mobile.dev | bash
|
|
360
|
+
PATH="$HOME/.maestro/bin:$PATH"; export PATH
|
|
361
|
+
fi
|
|
362
|
+
fi
|
|
363
|
+
|
|
364
|
+
echo "── native dependency check"
|
|
365
|
+
yarn doctor
|
|
366
|
+
|
|
367
|
+
# Two reasons this is not `run:ios --configuration Debug`, both reproduced on
|
|
368
|
+
# a real machine. A debug build loads its JS from Metro, so the command stays
|
|
369
|
+
# attached to the bundler and **never exits** — the job burns its timeout and
|
|
370
|
+
# maestro never runs. And a debug build with no dev server opens on the red
|
|
371
|
+
# error screen, which maestro's launch check happily passes.
|
|
372
|
+
#
|
|
373
|
+
# Release embeds the bundle; --no-bundler skips Metro entirely. Both flags are
|
|
374
|
+
# the CLI's own, read from `expo run:ios --help`. Simulator builds are
|
|
375
|
+
# unsigned, so no certificate is involved.
|
|
376
|
+
echo "── build and install on a simulator"
|
|
377
|
+
yarn expo run:ios --configuration Release --no-bundler
|
|
378
|
+
|
|
379
|
+
echo "── maestro"
|
|
380
|
+
maestro test .maestro
|
|
381
|
+
E2E
|
|
382
|
+
chmod +x e2e-ios.sh
|
|
383
|
+
log "e2e-ios.sh (build + maestro, runnable by hand and from CI)"
|
|
384
|
+
|
|
385
|
+
cat > e2e-android.sh <<'E2E'
|
|
386
|
+
#!/usr/bin/env bash
|
|
387
|
+
# Build the release APK, then drive it with maestro.
|
|
388
|
+
#
|
|
389
|
+
# Two phases, because the emulator is not ours to own: in CI the
|
|
390
|
+
# android-emulator-runner action boots one and runs a script inside it, so the
|
|
391
|
+
# build has to happen before that and the test inside it.
|
|
392
|
+
#
|
|
393
|
+
# ./e2e-android.sh build — doctor, prebuild, assembleRelease
|
|
394
|
+
# ./e2e-android.sh test — install on the connected device and run maestro
|
|
395
|
+
# ./e2e-android.sh — both, against whatever device is attached
|
|
396
|
+
set -euo pipefail
|
|
397
|
+
cd "$(dirname "$0")"
|
|
398
|
+
|
|
399
|
+
export SENTRY_DISABLE_AUTO_UPLOAD="${SENTRY_DISABLE_AUTO_UPLOAD:-true}"
|
|
400
|
+
|
|
401
|
+
APK="android/app/build/outputs/apk/release/app-release.apk"
|
|
402
|
+
|
|
403
|
+
find_jdk() {
|
|
404
|
+
[ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ] && return 0
|
|
405
|
+
for candidate in "$(/usr/libexec/java_home -v 21 2>/dev/null || true)" \
|
|
406
|
+
/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \
|
|
407
|
+
/usr/lib/jvm/temurin-21-jdk-amd64 \
|
|
408
|
+
/usr/lib/jvm/java-21-openjdk-amd64; do
|
|
409
|
+
[ -n "$candidate" ] && [ -x "$candidate/bin/java" ] && { export JAVA_HOME="$candidate"; return 0; }
|
|
410
|
+
done
|
|
411
|
+
echo "no JDK 21 found — gradle and maestro both need one" >&2
|
|
412
|
+
exit 1
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
ensure_maestro() {
|
|
416
|
+
command -v maestro >/dev/null 2>&1 && return 0
|
|
417
|
+
if [ -x "$HOME/.maestro/bin/maestro" ]; then
|
|
418
|
+
PATH="$HOME/.maestro/bin:$PATH"; export PATH; return 0
|
|
419
|
+
fi
|
|
420
|
+
echo "── installing maestro"
|
|
421
|
+
curl -fsSL https://get.maestro.mobile.dev | bash
|
|
422
|
+
PATH="$HOME/.maestro/bin:$PATH"; export PATH
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
do_build() {
|
|
426
|
+
find_jdk
|
|
427
|
+
echo "── native dependency check"
|
|
428
|
+
yarn doctor
|
|
429
|
+
|
|
430
|
+
echo "── generate the native project"
|
|
431
|
+
yarn expo prebuild --platform android --no-install
|
|
432
|
+
|
|
433
|
+
# Release, not debug. A debug APK contains no JS bundle — it fetches one
|
|
434
|
+
# from Metro at launch — so on a machine with no dev server the app opens on
|
|
435
|
+
# the red "could not connect" screen. Maestro's launch check passes on that
|
|
436
|
+
# screen, which is worse than failing: the run goes green having proved
|
|
437
|
+
# nothing. Release embeds the bundle. Expo's template signs release with the
|
|
438
|
+
# debug key, so no keystore is needed.
|
|
439
|
+
echo "── build release APK"
|
|
440
|
+
(cd android && ./gradlew assembleRelease --no-daemon)
|
|
441
|
+
[ -f "$APK" ] || { echo "no APK at $APK after assembleRelease" >&2; exit 1; }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
do_test() {
|
|
445
|
+
find_jdk
|
|
446
|
+
ensure_maestro
|
|
447
|
+
[ -f "$APK" ] || { echo "no APK at $APK — run './e2e-android.sh build' first" >&2; exit 1; }
|
|
448
|
+
|
|
449
|
+
echo "── install"
|
|
450
|
+
adb install -r "$APK"
|
|
451
|
+
|
|
452
|
+
echo "── maestro"
|
|
453
|
+
maestro test .maestro
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
case "${1:-all}" in
|
|
457
|
+
build) do_build ;;
|
|
458
|
+
test) do_test ;;
|
|
459
|
+
all) do_build; do_test ;;
|
|
460
|
+
*) echo "usage: $0 [build|test]" >&2; exit 2 ;;
|
|
461
|
+
esac
|
|
462
|
+
E2E
|
|
463
|
+
chmod +x e2e-android.sh
|
|
464
|
+
log "e2e-android.sh (build + maestro, two phases so CI can own the emulator)"
|
|
465
|
+
|
|
466
|
+
assert_verification_scripts
|
|
467
|
+
assert_scaffold_clean
|
|
468
|
+
log "mobile-expo ready in $DIR"
|
|
469
|
+
warn "e2e flows are in .maestro/ — fill in core-flow.yaml before trusting the suite."
|
|
470
|
+
warn "The maestro CLI installs separately: curl -fsSL https://get.maestro.mobile.dev | bash"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Mobile — Expo / React Native
|
|
2
|
+
|
|
3
|
+
## Who you are
|
|
4
|
+
|
|
5
|
+
You are two engineers at once, and both have to be satisfied:
|
|
6
|
+
|
|
7
|
+
- a **senior frontend engineer** — component API design, state, render cost
|
|
8
|
+
- a **senior mobile engineer** — platform conventions, touch targets, safe
|
|
9
|
+
areas, keyboard behavior, list performance, offline and slow networks
|
|
10
|
+
|
|
11
|
+
Web habits that do not survive on a phone: hover states, tiny tap targets,
|
|
12
|
+
unbounded lists, assuming a fast network, ignoring the notch. Catch these
|
|
13
|
+
in your own work before review does.
|
|
14
|
+
|
|
15
|
+
SOLID, KISS, YAGNI. No overengineering.
|
|
16
|
+
|
|
17
|
+
## Structure
|
|
18
|
+
|
|
19
|
+
Mirrors the web stacks deliberately — one mental model across the repo.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
src/
|
|
23
|
+
├── design/ # the UI kit. no business logic. ever.
|
|
24
|
+
│ ├── tokens.ts # raw scales
|
|
25
|
+
│ ├── theme.ts # semantic layer
|
|
26
|
+
│ ├── ThemeProvider.tsx
|
|
27
|
+
│ ├── types.ts
|
|
28
|
+
│ ├── helpers/
|
|
29
|
+
│ ├── index.ts
|
|
30
|
+
│ └── Button/
|
|
31
|
+
│ ├── Button.tsx
|
|
32
|
+
│ ├── Button.test.tsx
|
|
33
|
+
│ ├── types.ts
|
|
34
|
+
│ └── index.ts
|
|
35
|
+
└── ...
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every component gets its own folder: component, tests, types, `index.ts`.
|
|
39
|
+
|
|
40
|
+
`src/design/` holds nothing that is not design.
|
|
41
|
+
|
|
42
|
+
## Styling — no framework
|
|
43
|
+
|
|
44
|
+
There is no Tailwind, no NativeWind, and no UI library here. Styling is
|
|
45
|
+
`StyleSheet.create` over a typed token module.
|
|
46
|
+
|
|
47
|
+
That is a deliberate trade. A styling framework on React Native buys terse
|
|
48
|
+
class strings and costs a build-time transform, a peer-version treadmill,
|
|
49
|
+
and a layer between you and the platform when something renders wrong. At
|
|
50
|
+
the size of one design system it does not pay for itself.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
src/design/
|
|
54
|
+
├── tokens.ts # spacing, radius, type scale, motion, hit targets
|
|
55
|
+
├── theme.ts # semantic names over the palette
|
|
56
|
+
├── ThemeProvider.tsx
|
|
57
|
+
├── types.ts
|
|
58
|
+
└── index.ts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`tokens.ts` uses `as const`, so every value is a literal type and a typo in
|
|
62
|
+
a component is a compile error rather than a silent `undefined`.
|
|
63
|
+
|
|
64
|
+
Components call `useTheme()`. They never import the palette directly — that
|
|
65
|
+
is what keeps a theme change to one file.
|
|
66
|
+
|
|
67
|
+
## No ready-made components
|
|
68
|
+
|
|
69
|
+
The kit is built from React Native primitives — `Pressable`, `View`,
|
|
70
|
+
`Text`, `FlatList`. No Paper, no NativeBase, no imported button.
|
|
71
|
+
|
|
72
|
+
Platform primitives are the substrate, not a UI kit. Wrapping them in our
|
|
73
|
+
own components is the job; installing someone else's opinion is not.
|
|
74
|
+
|
|
75
|
+
## Order of work
|
|
76
|
+
|
|
77
|
+
Tokens before components. Extract every axis from the design source —
|
|
78
|
+
colour, spacing, typography, radii, elevation, motion — into `tokens.ts`,
|
|
79
|
+
then map meaning onto them in `theme.ts`.
|
|
80
|
+
|
|
81
|
+
A component with a hardcoded `#2563eb` or `padding: 13` is a bug.
|
|
82
|
+
|
|
83
|
+
## Platform rules
|
|
84
|
+
|
|
85
|
+
- Minimum touch target 44×44pt. Smaller is a defect, not a design choice.
|
|
86
|
+
- Respect safe areas. Never assume a rectangular screen.
|
|
87
|
+
- Long lists are virtualized. A `map()` over server data will jank.
|
|
88
|
+
- Handle the keyboard: avoidance, dismissal, and the return key.
|
|
89
|
+
- Every screen has a loading, empty, and error state. All three.
|
|
90
|
+
- Test on both platforms. "Works on iOS" is half an answer.
|
|
91
|
+
|
|
92
|
+
## Memory — the collector cannot end what you still hold
|
|
93
|
+
|
|
94
|
+
React does not free a subscription for you, and the collector cannot free
|
|
95
|
+
what a live closure still references. So:
|
|
96
|
+
|
|
97
|
+
- **Every effect that starts something returns the function that stops
|
|
98
|
+
it.** `AppState`, `Dimensions`, `Keyboard`, notification and deep-link
|
|
99
|
+
listeners all return a subscription — `.remove()` it; `setInterval` →
|
|
100
|
+
`clearInterval`; a fetch → an `AbortController` aborted in the cleanup. The gate checks that the cleanup
|
|
101
|
+
exists; only you can check that it stops the right thing.
|
|
102
|
+
- State that only grows is a leak with a UI. A list that appends on every
|
|
103
|
+
event and never trims, a map of every id ever seen — bound it, or key it
|
|
104
|
+
by what is on screen.
|
|
105
|
+
- A closure created in render captures the render's state and is recreated
|
|
106
|
+
every render. That is by design; it becomes a cost when it is passed to a
|
|
107
|
+
memoized child or a long list. `.claude/rules/performance.orchestra.md`
|
|
108
|
+
says where that matters.
|
|
109
|
+
- A module-level cache is process-lifetime. Give it a maximum and an
|
|
110
|
+
eviction rule, or it is the thing that grows with uptime.
|
|
111
|
+
|
|
112
|
+
On a phone the budget is smaller and the collector is Hermes, not V8. A
|
|
113
|
+
leaked screen is a leaked native view tree, and the OS kills the app when it
|
|
114
|
+
notices — which reads as a crash with no stack trace.
|
|
115
|
+
|
|
116
|
+
## Testing
|
|
117
|
+
|
|
118
|
+
Coverage target is effectively total. The deciding rule:
|
|
119
|
+
|
|
120
|
+
> If one of our functions calls into a third-party library, that function
|
|
121
|
+
> has a test. When Dependabot bumps the library and behavior changes, the
|
|
122
|
+
> test must fail.
|
|
123
|
+
|
|
124
|
+
Tooling differs from web on purpose: `jest-expo` plus React Native Testing
|
|
125
|
+
Library. Vitest browser mode drives real browsers, which React Native does
|
|
126
|
+
not have.
|
|
127
|
+
|
|
128
|
+
- Interaction tests are primary — press, focus, disabled, navigation.
|
|
129
|
+
- Snapshots are secondary. Snapshot-only is untested.
|
|
130
|
+
- E2E via Maestro, installed separately.
|
|
131
|
+
|
|
132
|
+
## Accessibility
|
|
133
|
+
|
|
134
|
+
`accessibilityRole`, `accessibilityLabel`, and `accessibilityState` on
|
|
135
|
+
anything interactive. Test with VoiceOver and TalkBack. Respect the user's
|
|
136
|
+
font scale — a layout that breaks at large text is broken.
|
|
137
|
+
|
|
138
|
+
## Tooling
|
|
139
|
+
|
|
140
|
+
Biome for lint and format. TypeScript strict with
|
|
141
|
+
`noUncheckedIndexedAccess`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Scaffold a new iOS design system component.
|
|
2
|
+
|
|
3
|
+
Usage: `/ios:component {Name}`
|
|
4
|
+
|
|
5
|
+
1. Load the `design-component` skill and follow it.
|
|
6
|
+
2. Confirm every value has a token. Missing one? Add it to `Tokens.swift`
|
|
7
|
+
or the asset catalog first — never hardcode.
|
|
8
|
+
3. Create `Sources/DesignSystem/{Name}/` and the matching test file.
|
|
9
|
+
4. If the project needs a new target or setting, edit `project.yml` and run
|
|
10
|
+
`xcodegen generate`. Never touch the `.pbxproj`.
|
|
11
|
+
5. Run the build and test via `xcodebuild`.
|
|
12
|
+
6. Report the a11y checklist with each item checked or flagged.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: design-component
|
|
3
|
+
description: "Author a SwiftUI component in the iOS design system — folder layout, tokens, snapshot and behavior tests, accessibility. Use for work under Sources/DesignSystem/."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Authoring an iOS design system component
|
|
7
|
+
|
|
8
|
+
## Folder
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
Sources/DesignSystem/{Name}/
|
|
12
|
+
├── {Name}.swift
|
|
13
|
+
└── {Name}Style.swift # if it has variants
|
|
14
|
+
Tests/DesignSystemTests/
|
|
15
|
+
└── {Name}Tests.swift
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Before writing
|
|
19
|
+
|
|
20
|
+
1. Does a token exist for every value you need? If not, add it to
|
|
21
|
+
`Tokens.swift` or the asset catalog first. Never hardcode a color,
|
|
22
|
+
radius, spacing, or duration.
|
|
23
|
+
2. Does SwiftUI already provide this? Do not reimplement `Button` to change
|
|
24
|
+
its background — write a `ButtonStyle`.
|
|
25
|
+
3. Does this view need to know anything about the product? If yes, it does
|
|
26
|
+
not belong in `DesignSystem/`.
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
- Public initializers with sensible parameter names.
|
|
31
|
+
- Variants as an enum, not a set of booleans that can contradict.
|
|
32
|
+
- Accept a `@ViewBuilder` closure for content rather than a fixed `String`,
|
|
33
|
+
where a caller might reasonably want more.
|
|
34
|
+
- Nothing `public` that does not need to be.
|
|
35
|
+
|
|
36
|
+
## Tests
|
|
37
|
+
|
|
38
|
+
Behavior first, snapshot second.
|
|
39
|
+
|
|
40
|
+
- Snapshot in light, dark, and the largest Dynamic Type size.
|
|
41
|
+
- Assert behavior separately — an action fires, a disabled control does not.
|
|
42
|
+
- If the component wraps a dependency, test *our* wrapper so a version bump
|
|
43
|
+
that changes behavior turns the suite red.
|
|
44
|
+
|
|
45
|
+
## Accessibility checklist
|
|
46
|
+
|
|
47
|
+
- [ ] accessibility label present and meaningful
|
|
48
|
+
- [ ] correct traits (`.isButton`, `.isHeader`, …)
|
|
49
|
+
- [ ] value exposed where one applies (sliders, toggles)
|
|
50
|
+
- [ ] VoiceOver order is sensible, not just complete
|
|
51
|
+
- [ ] layout survives the largest Dynamic Type size
|
|
52
|
+
- [ ] touch target at least 44x44pt
|
|
53
|
+
- [ ] contrast holds in light and dark
|
|
54
|
+
|
|
55
|
+
## Done means
|
|
56
|
+
|
|
57
|
+
View, style, tests in all three configurations, and the a11y checklist —
|
|
58
|
+
each item explicitly checked or flagged.
|