From fec3ba41598bb57718d5b10190b1057b35f5126e Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Tue, 8 Sep 2026 17:02:24 -0500 Subject: [PATCH 1/7] Port CDS Button to Jetpack Compose and add RN-to-Compose porting skill. Ship public Button and shared interaction primitives with Robolectric tests, gallery coverage, and docs. Add cds-rn-to-compose skill to guide future mobile-to-Android ports and audits. Co-authored-by: Cursor --- .claude/skills/cds-rn-to-compose/SKILL.md | 392 ++++++++++++++++++ .../skills/cds-rn-to-compose/evals/evals.json | 23 + .../references/audit-checklist.md | 145 +++++++ .../references/compose-docs.md | 73 ++++ .../references/discovery-template.md | 134 ++++++ .../cds-rn-to-compose/references/learnings.md | 69 +++ .../references/rn-to-compose-mapping.md | 104 +++++ AGENTS.md | 1 + android/gradle/libs.versions.toml | 2 + .../coinbase/cds/androidapp/MainActivity.kt | 125 +----- .../gallery/ButtonGallerySection.kt | 160 +++++++ packages/cds-android/AGENTS.md | 14 +- packages/cds-android/CHANGELOG.md | 7 +- packages/cds-android/build.gradle.kts | 11 +- packages/cds-android/docs/README.md | 2 + packages/cds-android/docs/button.md | 115 +++++ packages/cds-android/docs/interaction.md | 75 ++++ .../coinbase/cds/components/button/Button.kt | 130 +++--- .../cds/components/button/ButtonStyle.kt | 53 ++- .../coinbase/cds/interaction/CdsIndication.kt | 171 ++++++++ .../cds/interaction/CdsInteractionDefaults.kt | 35 ++ .../cds/interaction/CdsInteractionState.kt | 50 +++ .../cds/interaction/CdsInteractionTokens.kt | 12 + .../cds/components/button/ButtonStyleTest.kt | 74 ++++ .../cds/components/button/ButtonTest.kt | 173 ++++++++ .../interaction/CdsInteractionStateTest.kt | 60 +++ 26 files changed, 2020 insertions(+), 190 deletions(-) create mode 100644 .claude/skills/cds-rn-to-compose/SKILL.md create mode 100644 .claude/skills/cds-rn-to-compose/evals/evals.json create mode 100644 .claude/skills/cds-rn-to-compose/references/audit-checklist.md create mode 100644 .claude/skills/cds-rn-to-compose/references/compose-docs.md create mode 100644 .claude/skills/cds-rn-to-compose/references/discovery-template.md create mode 100644 .claude/skills/cds-rn-to-compose/references/learnings.md create mode 100644 .claude/skills/cds-rn-to-compose/references/rn-to-compose-mapping.md create mode 100644 apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt create mode 100644 packages/cds-android/docs/button.md create mode 100644 packages/cds-android/docs/interaction.md create mode 100644 packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsIndication.kt create mode 100644 packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionDefaults.kt create mode 100644 packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionState.kt create mode 100644 packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionTokens.kt create mode 100644 packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonStyleTest.kt create mode 100644 packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt create mode 100644 packages/cds-android/src/test/java/com/coinbase/cds/interaction/CdsInteractionStateTest.kt diff --git a/.claude/skills/cds-rn-to-compose/SKILL.md b/.claude/skills/cds-rn-to-compose/SKILL.md new file mode 100644 index 0000000000..27b7b1926b --- /dev/null +++ b/.claude/skills/cds-rn-to-compose/SKILL.md @@ -0,0 +1,392 @@ +--- +name: cds-rn-to-compose +description: | + Guide for porting CDS React Native components to Jetpack Compose in packages/cds-android, and for auditing existing Android ports for completeness and quality. + USE THIS whenever the user asks to port, migrate, or bring a CDS mobile/RN component to Android/Compose/Kotlin, audit an Android CDS component against mobile parity, or review whether a cds-android port is done correctly. + Also trigger for phrases like "RN to Compose", "mobile to Android CDS", "port Button/Chip/Card to cds-android", "public API boundary", or "does our Android Button match mobile". + Stress strict internal-by-default visibility for everything except the customer-facing composable and its required types — guard against Hyrum's Law. + Deliver design tokens via CdsThemeProvider and LocalCdsTheme (CompositionLocal), not component props or cds-common imports. + After completing or auditing a port, update this skill with generalizable learnings so future ports get faster. + Load jetpack-best-practices alongside this skill for Compose API shape; this skill covers CDS-specific porting workflow, parity rules, interactions, tokens, and testing. +--- + +# CDS React Native → Jetpack Compose + +Port **product behavior and visual design** from `packages/mobile` into native Compose in `packages/cds-android`. Do not transliterate React Native mechanisms (`StyleSheet`, `ViewStyle`, `Pressable` props, `useComponentConfig`, `getInteractableStyles`) — map intent to Compose-native patterns. + +**Authority stack (read in order):** + +1. This skill — CDS porting workflow, parity, and audit rules +2. `packages/cds-android/AGENTS.md` — API boundary, theming, Gradle +3. `jetpack-best-practices` skill — AOSP Compose API guidelines +4. [Compose component API guidelines](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-component-api-guidelines.md) — slots, state, styling seams +5. Official Android docs linked in `references/compose-docs.md` + +## Modes + +Determine what the user needs before writing code: + +| Mode | Trigger | Output | +|------|---------|--------| +| **Port** | "Port X to Android", "implement X in cds-android" | Discovery notes → implementation → tests → gallery → docs | +| **Audit** | "Review the Android port", "is X done?", "parity check" | Structured audit report using `references/audit-checklist.md` | + +Default to **Port** when building; switch to **Audit** when reviewing existing Kotlin without a clear build ask. + +At the **start** of every port or audit, read `references/learnings.md` for accumulated gotchas. At the **end**, follow [Phase 8: Improve this skill](#phase-8-improve-this-skill). + +--- + +## Phase 1: Discovery (required before coding) + +Produce a short discovery artifact (markdown in the PR or a comment). Use `references/discovery-template.md` as the outline. + +### Source files to read + +For component `` in `packages/mobile/src/.../.tsx`: + +| Artifact | Path pattern | Why | +|----------|--------------|-----| +| Implementation | `packages/mobile/src/**/.tsx` | Props, behavior, composition | +| Base props / types | `packages/common/src/types/**` | Shared contract, deprecations | +| Tokens | `packages/common/src/tokens/.ts` | Values to hand-port | +| Stories | `packages/mobile/src/**/__stories__/.stories.tsx` | Variant matrix, edge cases | +| Tests | `packages/mobile/src/**/__tests__/.test.tsx` | Behavior worth preserving | +| Interactable | `packages/mobile/src/styles/getInteractableStyles.ts`, `system/Interactable.tsx`, `system/Pressable.tsx` | Press/hover/focus/disabled | +| iOS parity (optional) | `packages/cds-ios/Sources/Components/.swift` | Prior native decisions | +| Existing Android | `packages/cds-android/src/main/java/com/coinbase/cds/components/**` | POC or partial port | + +Search deprecations: grep `@deprecated` on props/types in mobile and common. **Do not port deprecated props** — document what was skipped and why. + +### Discovery questions + +Answer explicitly in the discovery artifact: + +1. **Public API surface** — Which RN props are customer-facing vs internal? What is deprecated? +2. **Variants & sizes** — Enum or sealed types? Default values? +3. **Layout** — What is intrinsic vs caller-controlled? (e.g. RN `block` → caller uses `Modifier.fillMaxWidth()`, not a prop) +4. **Slots** — Icons, labels, custom content: use `@Composable` slot lambdas, not `IconName` strings, until a public Icon component exists +5. **Interactions** — Press, long-press, hover, focus, drag, toggle? See [Interaction analysis](#interaction-analysis) +6. **States** — enabled, loading, selected, error, transparent overlays? +7. **Accessibility** — roles, content descriptions, loading/disabled semantics, live regions +8. **Tokens** — Map each visual value to `CdsTheme.*`; resolve via `LocalCdsTheme` / `CdsThemeProvider`, not props. Note gaps vs `@coinbase/cds-common` +9. **Out of scope** — `useComponentConfig`, haptics, debounce, `wrapperStyles`, RN-only style props +10. **Reuse** — Can `CdsInteractionDefaults`, internal `Text`, or another CDS component be composed? + +--- + +## Phase 2: API design + +### Compose API rules (CDS + AOSP) + +- **Every composable accepts `modifier: Modifier = Modifier`** as the first optional parameter; apply it once on the outermost layout node. See [Modifier](https://developer.android.com/develop/ui/compose/modifiers). +- **UI composables return `Unit`**, named PascalCase nouns (`Button`, not `button` or `renderButton`). +- **Hoist state** — value + callback (`text`, `onClick`) or hoisted `MutableInteractionSource`; avoid hidden internal state customers need to observe. +- **Parameter order** — required → `modifier` → optional scalars → trailing `@Composable` slots. +- **Default values in the signature** so callers override independently. +- **Explicit API mode** — default `internal`; only `public` what customers need. Never widen visibility for `apps/android-app`. See `packages/cds-android/AGENTS.md`. + +### Public API boundary (Hyrum's Law) + +`:cds` is a published library. **Every `public` symbol is a contract** — customers will depend on it even if undocumented, because [Hyrum's Law](https://www.hyrumslaw.com/) says they will. Guard the surface aggressively: + +**What belongs on the public API (typically):** + +- The primary `@Composable` entry point (`Button`, future `Chip`, …) +- Supporting enums and value types callers must name in signatures (`ButtonVariant`, `ButtonSize`) +- Parameters and callbacks on those entry points (`onClick`, `interactionSource`, icon slots) +- Shared primitives meant for customer-built UI (`CdsInteractionDefaults`, theme types) + +**What must stay `internal` (default for everything else):** + +- Style resolvers and resolved value types (`resolveButtonColors`, `ButtonColors`, `ButtonMetrics`, `*Style.kt` helpers) +- Sub-composables used to assemble the public component (`Spinner`, internal `Text`, private `Row` layouts) +- Token plumbing, preview helpers, and gallery-only code +- Anything exported "just in case" or to unblock `apps/android-app` — fix the consumer instead + +**Decision rule:** when Kotlin explicit API mode asks for a visibility modifier, that is the moment to decide whether customers need the symbol — not a reflex to write `public`. If only tests need access, keep it `internal` in the same module (tests compile against `internal`). + +**Audit signal:** grep for `public` in new component files and challenge each occurrence. Style types should match `SlideButtonStyle.kt` (`internal data class`), not leak as public data classes. + +### Styling: opinionated defaults, caller override + +CDS components ship opinionated visual defaults from tokens. Callers override layout and extension points without a parallel RN `style` / `styles` API: + +| RN pattern | Compose pattern | +|------------|-----------------| +| `style` / `styles.foo` | `modifier` for layout; optional parameters only where product requires (e.g. `transparent`, `maxLines`) | +| `block` / `fullWidth` | Document `Modifier.fillMaxWidth()` — no width prop | +| Theme colors | `CdsTheme.colors.*`, `CdsTheme.space.*`, etc. via `LocalCdsTheme` (see [Theming](#theming)) | +| Custom interactable | `CdsInteractionDefaults.indication()` / `indication(shape)` | + +Extract pure resolvers (e.g. `resolveButtonColors()`) into testable functions in a `*Style.kt` file. **Do not use the alpha Compose Styles API** — keep a migration seam (`*Style.kt`, `CdsInteractionDefaults`) so Styles can replace internals later without public API churn. + +### Do not port + +- Deprecated props and variants (grep `@deprecated`) +- `useComponentConfig` / `ComponentConfigProvider` merging +- RN-specific types: `ViewStyle`, `StyleProp`, `StyleSheet`, `wrapperStyles` +- Platform hacks: haptics, debounce, `flush`, legacy color props (`foregroundMuted`, `compact`, etc.) +- Importing `@coinbase/cds-common` — Kotlin cannot consume it; hand-port token values and keep comments pointing to the TS source + +Full mapping table: `references/rn-to-compose-mapping.md`. + +--- + +## Phase 3: Interaction analysis + +For every port, classify interactions before wiring modifiers. + +### Step 1 — Inventory (from RN source) + +| Kind | RN signals | Compose wiring | +|------|------------|----------------| +| Press / tap | `Pressable`, `onPress`, `onClick` | `Modifier.clickable` | +| Long press | `onLongPress` | `combinedClickable` | +| Toggle / select | `selected`, checkbox patterns | `toggleable` / `selectable` | +| Hover | `Interactable`, hover styles | `hoverable` (desktop/emulator) | +| Keyboard focus | focus styles, `accessible` | `focusable` + `CdsInteractionDefaults.indication` | +| Drag | `draggable`, gestures | `draggable` / gesture APIs | +| Scroll | `ScrollView` children | parent scroll; semantics for accessibility | + +### Step 2 — Produce events for customers + +If customers might need to observe interaction state (pressed, focused, hovered), accept an optional hoisted source: + +```kotlin +interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +``` + +Pass the **same instance** to the gesture modifier (`clickable`, `hoverable`, …) and to `CdsInteractionDefaults.indication(shape)`. Customers observe via `interactionSource.collectIsPressedAsState()` or `interactions.collect { }`. See [InteractionSource](https://developer.android.com/reference/kotlin/androidx/compose/foundation/interaction/InteractionSource). + +**Rule:** Only wire modifiers for interactions the component actually **produces**. If `CdsInteractionDefaults` handles hover but the component never calls `hoverable`, hover feedback will never appear — either add the modifier or document the limitation. + +Shared CDS affordances: `com.coinbase.cds.interaction` (`CdsInteractionDefaults`, `CdsInteractionTokens`). Document usage in `packages/cds-android/docs/interaction.md`. + +### Step 3 — Disabled vs indication + +- **Disabled opacity:** `CdsInteractionDefaults.DisabledAlpha` (0.5) on the component — not inside indication +- **Loading:** block clicks, show progress, set [semantics](https://developer.android.com/develop/ui/compose/accessibility) (`progressBarRangeInfo`, keep label for screen readers) + +--- + +## Phase 4: Implementation + +### File layout + +``` +packages/cds-android/src/main/java/com/coinbase/cds/ +├── components// +│ ├── .kt # public @Composable API (+ public enums) +│ └── Style.kt # internal resolvers, metrics, colors +└── interaction/ # shared when interactive (already exists) +``` + +Keep assembly composables `internal` or inline in `.kt`. Do not publish helper composables customers could accidentally depend on. + +### Theming + +CDS Android delivers design tokens through Compose **[CompositionLocal](https://developer.android.com/develop/ui/compose/layering#compositionlocal)** — not through component props or `@coinbase/cds-common` imports. + +**How tokens reach components:** + +1. **`CdsThemeProvider`** wraps the app (or a subtree) and installs a resolved theme into **`LocalCdsTheme`**. +2. **`@Composable` components read tokens** via `CdsTheme.colors`, `CdsTheme.space`, `CdsTheme.typography`, `CdsTheme.borderRadius`. These accessors resolve against the nearest `CdsThemeProvider` above the call site — the Compose equivalent of mobile's `useTheme()`. +3. **Style resolvers stay pure** — `*Style.kt` functions take token values as parameters (e.g. `CdsColors`) so JUnit tests do not need composition. The public `@Composable` reads `CdsTheme.*` once and passes resolved values into the resolver. +4. **Custom `Modifier.Node` draw code** (e.g. `CdsIndication`) reads `currentValueOf(LocalCdsTheme)` when it cannot call `CdsTheme` accessors directly. `LocalCdsTheme` is public read-only for this reason only. + +Do **not** thread colors or spacing through component parameters the way RN passes theme-derived values via props. Do **not** use `CompositionLocal` for per-component configuration — nest `CdsThemeProvider` when a subtree needs a different theme. + +- Author custom themes with the `cdsTheme { }` builder +- Wrap tests, gallery, and previews in `CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light)` +- Do not add public token constructors or `copy()` theme APIs +- When porting from `@coinbase/cds-common/tokens/*`, hand-port values with a comment citing the TS path + +Reference: `packages/cds-android/docs/using-tokens.md`, `packages/cds-android/src/main/java/com/coinbase/cds/theme/README.md`. + +### Accessibility + +Follow [Compose accessibility](https://developer.android.com/develop/ui/compose/accessibility): + +- `Modifier.semantics` — `contentDescription`, `role`, disabled, progress +- Loading: indeterminate progress + retain text label in semantics +- Merge descendants only when it improves screen reader experience + +### Internal composition + +Prefer existing internal CDS pieces (`Text`, etc.) over duplicating typography. Keep internal components `internal` until promoted deliberately. + +--- + +## Phase 5: Testing + +Use **Robolectric + Compose UI Test (JUnit 4)** for component behavior — not only the headless `composeOnce` harness used for theme-only tests. + +```sh +yarn nx run cds-android:test +yarn nx run cds-android:build +``` + +### What to test (high value) + +| Layer | Tool | Examples | +|-------|------|----------| +| Pure resolvers | JUnit | `resolveButtonColors`, `resolveCdsInteractionVisualState` priority | +| Composition & behavior | Robolectric + `createComposeRule()` | click invokes callback, disabled blocks click, semantics | +| Interaction production | Robolectric + `MutableInteractionSource` | press emits `PressInteraction.Press`; add when component hoists source | +| Icon slots | Capture lambda args | tint Color and size Dp passed to slots | + +### What not to over-test + +- Every variant × size matrix (cover in pure resolver tests instead) +- Pixel-perfect screenshots unless the repo already has screenshot infra +- Duplicating framework behavior (e.g. that `clickable` exists) + +Add Robolectric deps in `packages/cds-android/build.gradle.kts` and `android/gradle/libs.versions.toml` if missing (`isIncludeAndroidResources = true`). + +--- + +## Phase 6: Demo app + +Add or extend a gallery section in `apps/android-app`: + +- All variants, sizes, states (disabled, loading, transparent) +- Icon slots, full width via `Modifier.fillMaxWidth()`, truncation +- Interactive demo (click counter) where relevant + +The demo app is a **consumer** — if it needs a non-public API, fix the API design instead of widening visibility. + +--- + +## Phase 7: Documentation & changelog + +- `packages/cds-android/docs/.md` — public API, examples, modifier usage, interaction hoisting +- Update `packages/cds-android/docs/README.md` index +- `packages/cds-android/CHANGELOG.md` under the Gradle version +- Update `packages/cds-android/AGENTS.md` if public surface changes + +--- + +## Phase 8: Improve this skill + +This skill is a **living document**. Each port teaches something the next port should not rediscover. Treat skill maintenance as part of finishing a port — not optional cleanup. + +### When to capture a learning + +Record and potentially promote a learning when any of these happen: + +- You hit a compile error or test failure that reveals a non-obvious Compose/CDS pattern +- The user corrects your approach during review +- An audit finds a gap that existing guidance did not cover +- You make an architectural choice (interaction wiring, test strategy, API shape) that future components should repeat +- You explicitly decide **not** to port something — the rationale may help the next agent + +Skip one-off component trivia that does not generalize (e.g. "Chip uses `radius400`" belongs in that component's code/docs, not the skill). + +### What to update (by scope) + +| Scope | Where | Example | +|-------|-------|---------| +| Single port gotcha, not yet validated | `references/learnings.md` | "Outlined variants need border token even when transparent" | +| Repeatable RN → Compose translation | `references/rn-to-compose-mapping.md` | New row in interaction or styling table | +| Audit criterion | `references/audit-checklist.md` | New checkbox after a recurring gap | +| Workflow step or rule | `SKILL.md` | New phase sub-step, verification item | +| Best reference implementation | [Example section](#example-button-reference-implementation) | Point to the newest high-quality port | +| Shared infrastructure | `packages/cds-android/docs/`, `AGENTS.md` | Interaction docs, public API list | + +**Promotion path:** log in `learnings.md` first → after the pattern appears in **two or more** ports (or one port + one audit), promote it into `SKILL.md` or a reference file → trim the learning entry to point at the promoted location. + +### How to write good updates + +- **Generalize** — write rules for classes of components ("all toggleable controls"), not copy-paste from one file +- **Explain why** — future agents need the reasoning, not just the rule +- **Keep SKILL.md lean** — if a section grows past ~30 lines of component-specific detail, move it to `references/` and link from the skill +- **Do not contradict** `jetpack-best-practices`, `packages/cds-android/AGENTS.md`, or official Android docs; update the skill when repo policy changes +- **Remove stale guidance** when a better pattern replaces it (note the change in `learnings.md`) + +### End-of-port skill checklist + +Before marking the port done, ask: + +- [ ] Did I read `references/learnings.md` at the start? +- [ ] Did I learn anything generalizable? If yes, append to `learnings.md` +- [ ] Should any learning be promoted to `SKILL.md`, mapping, or audit checklist now? +- [ ] Should the reference implementation example change? +- [ ] Did I tell the user what skill updates I made (if any)? + +Only update skill files when the learning is clear. When unsure, log in `learnings.md` with **Skill update: pending** and promote after the next port confirms it. + +--- + +## Audit workflow + +When reviewing an existing port, read the RN discovery sources again and walk `references/audit-checklist.md`. Produce a report: + +```markdown +# Android port audit + +## Summary +[Pass / gaps / recommendations] + +## API parity +| RN prop | Android | Status | Notes | + +## Deprecations correctly omitted +... + +## Interactions +... + +## Tokens & visuals +... + +## Tests +... + +## Docs & demo +... +``` + +Flag: deprecated props ported, missing `modifier`, raw colors, public leakage, missing interaction observation, no behavior tests, Compose Styles API usage. + +--- + +## Verification checklist + +Before marking a port complete: + +- [ ] Discovery artifact written; deprecated RN props excluded +- [ ] `modifier` accepted and applied once on outer node +- [ ] All visuals from `CdsTheme` tokens via `LocalCdsTheme` (no hard-coded design values; no theme props) +- [ ] Interactions classified; gesture modifiers match; `interactionSource` hoisted if observable +- [ ] Accessibility: roles, disabled, loading semantics +- [ ] Pure style resolvers unit-tested +- [ ] Robolectric behavior tests for callbacks and semantics +- [ ] Interaction event tests when component hoists `MutableInteractionSource` +- [ ] Gallery section in `android-app` +- [ ] `docs/.md` + CHANGELOG +- [ ] `yarn nx run cds-android:test` and `cds-android:build` pass +- [ ] No Compose Styles API; no `@coinbase/cds-common` imports +- [ ] Public API reviewed for Hyrum's Law — no leaked style types or assembly composables +- [ ] Skill improved: learnings logged and promoted where appropriate (Phase 8) + +--- + +## Reference files + +| File | When to read | +|------|----------------| +| `references/learnings.md` | **Every port/audit** — accumulated gotchas; append after each port | +| `references/discovery-template.md` | Starting a new port | +| `references/rn-to-compose-mapping.md` | Translating RN concepts | +| `references/audit-checklist.md` | Auditing a port | +| `references/compose-docs.md` | Official Android doc links | + +## Example: Button (reference implementation) + +- RN: `packages/mobile/src/buttons/Button.tsx` +- Android: `packages/cds-android/.../button/Button.kt`, `ButtonStyle.kt` +- Interaction: `CdsInteractionDefaults.indication(shape)` + hoisted `interactionSource` +- Tests: `ButtonTest.kt`, `ButtonStyleTest.kt`, `CdsInteractionStateTest.kt` +- Gallery: `apps/android-app/.../ButtonGallerySection.kt` +- Docs: `packages/cds-android/docs/button.md`, `interaction.md` diff --git a/.claude/skills/cds-rn-to-compose/evals/evals.json b/.claude/skills/cds-rn-to-compose/evals/evals.json new file mode 100644 index 0000000000..e9e0f091e4 --- /dev/null +++ b/.claude/skills/cds-rn-to-compose/evals/evals.json @@ -0,0 +1,23 @@ +{ + "skill_name": "cds-rn-to-compose", + "evals": [ + { + "id": 1, + "prompt": "Port the CDS Chip component from packages/mobile to packages/cds-android as a full Jetpack Compose implementation. Match mobile variants and states, add tests and a gallery section in android-app.", + "expected_output": "Discovery artifact, public Chip composable with modifier-first API, CdsTheme tokens, interaction wiring if pressable, Robolectric tests, gallery, docs, passing cds-android:test and build", + "files": [] + }, + { + "id": 2, + "prompt": "Audit the Android Button port in packages/cds-android against packages/mobile/src/buttons/Button.tsx. Tell me what's missing or wrong and whether we're ready to ship.", + "expected_output": "Structured audit report using audit checklist: API parity table, deprecated props omitted, interaction/semantics/tests/docs gaps, verdict", + "files": [] + }, + { + "id": 3, + "prompt": "I'm adding a new ToggleRow to mobile — should we also add it to cds-android now? Walk me through what the Android port would need without writing all the code yet.", + "expected_output": "Discovery-style plan: source files to read, interaction analysis, API sketch, token mapping, test plan, explicit out-of-scope items, no Compose Styles API", + "files": [] + } + ] +} diff --git a/.claude/skills/cds-rn-to-compose/references/audit-checklist.md b/.claude/skills/cds-rn-to-compose/references/audit-checklist.md new file mode 100644 index 0000000000..b8e1b945c0 --- /dev/null +++ b/.claude/skills/cds-rn-to-compose/references/audit-checklist.md @@ -0,0 +1,145 @@ +# Android port audit checklist + +Use when reviewing an existing `packages/cds-android` component against its `packages/mobile` source. Score each item: **Pass**, **Gap**, or **N/A**. + +## 1. Discovery & scope + +- [ ] RN source, stories, tests, and common tokens were consulted +- [ ] Deprecated RN props are **not** present on the Android API +- [ ] `useComponentConfig`, haptics, debounce, `wrapperStyles` were not ported +- [ ] Out-of-scope items are documented (not silently wrong) + +## 2. API shape (Compose + CDS) + +- [ ] `@Composable` returns `Unit`, PascalCase name +- [ ] `modifier: Modifier = Modifier` is first optional parameter +- [ ] Modifier applied exactly once on outermost layout node +- [ ] Parameter order: required → modifier → optional → composable slots +- [ ] Defaults live in the function signature +- [ ] Only intentional symbols are `public` (explicit API mode) +- [ ] Style resolvers, resolved types (`*Colors`, `*Metrics`), and assembly composables are `internal` +- [ ] No symbols widened to `public` only for tests or the demo app +- [ ] Demo app does not require widening internal APIs + +Reference: `jetpack-best-practices` skill, `packages/cds-android/AGENTS.md` + +## 3. Parity matrix + +Build a table: + +| RN prop / behavior | Android equivalent | Match? | Notes | +|------------------|-------------------|--------|-------| + +Check variants, sizes, defaults, loading, disabled, transparent modes, icon slots, truncation, accessibility. + +**Common gaps:** + +- Missing variant or size enum value +- RN `block` incorrectly modeled as prop instead of modifier docs +- `IconName` instead of composable slots +- Loading still clickable +- Label lost in loading semantics + +## 4. Theming & visuals + +- [ ] Colors from `CdsTheme.colors.*` (resolved via `LocalCdsTheme`, not component props) +- [ ] Spacing from `CdsTheme.space.*` +- [ ] Typography from `CdsTheme.typography.*` +- [ ] Border radius from `CdsTheme.borderRadius.*` +- [ ] Components do not accept raw color/spacing props where RN used `useTheme()` — tokens come from CompositionLocal +- [ ] Tests/gallery wrap content in `CdsThemeProvider` +- [ ] Token values traceable to `@coinbase/cds-common` source (comment or doc) +- [ ] Transparent / inverse / semantic variants match token intent +- [ ] **No** Compose Styles API usage + +## 5. Styling override model + +- [ ] Opinionated defaults implemented in component +- [ ] Callers can override layout via `modifier` (padding, width, test tags) +- [ ] No RN-style `style` / `styles` props unless explicitly required by product +- [ ] Style logic extracted to testable `*Style.kt` resolvers where non-trivial + +## 6. Interactions + +- [ ] Interaction types identified (press, hover, focus, drag, toggle, long-press) +- [ ] Each produced interaction uses the correct Compose modifier +- [ ] `CdsInteractionDefaults.indication()` used for CDS feedback (not ad-hoc alpha hacks) +- [ ] Optional `interactionSource: MutableInteractionSource` hoisted when customers need observation +- [ ] Same `InteractionSource` passed to gesture + indication +- [ ] Disabled: `DisabledAlpha` + gestures blocked +- [ ] Loading: gestures blocked + correct semantics + +If indication supports hover/focus but modifiers are missing, flag as **Gap**. + +Docs: `packages/cds-android/docs/interaction.md` + +## 7. Accessibility + +- [ ] Correct `Role` in semantics +- [ ] `contentDescription` for icon-only or primary label +- [ ] `disabled()` when not interactive +- [ ] Loading: progress semantics + label retained +- [ ] `mergeDescendants` only when appropriate + +Reference: https://developer.android.com/develop/ui/compose/accessibility + +## 8. Tests + +- [ ] `yarn nx run cds-android:test` passes +- [ ] Pure resolver tests for colors/metrics/state priority +- [ ] Robolectric + Compose UI tests for behavior (click, disabled, semantics) +- [ ] Interaction event tests when `MutableInteractionSource` is hoisted +- [ ] Tests focus on regressions, not exhaustive variant grids +- [ ] No tests that only assert framework defaults + +**Anti-patterns:** + +- Only headless theme tests, no component behavior tests +- 20 tests duplicating the same assertion per variant +- No tests for disabled/loading/click paths + +## 9. Demo & documentation + +- [ ] Gallery section covers variants, states, sizes, edge cases +- [ ] `packages/cds-android/docs/.md` exists and matches API +- [ ] `docs/README.md` index updated +- [ ] `CHANGELOG.md` mentions public API changes +- [ ] `AGENTS.md` public surface list accurate +- [ ] `interactionSource` documented if hoisted + +## 10. Build & boundaries + +- [ ] `yarn nx run cds-android:build` passes +- [ ] No `@coinbase/cds-common` imports +- [ ] No Yarn/npm deps added to cds-android +- [ ] Gradle version unchanged unless releasing + +## 11. Skill maintenance + +- [ ] `references/learnings.md` was read at audit start +- [ ] New generalizable learnings appended to `learnings.md` (if any) +- [ ] Repeatable patterns promoted to `SKILL.md` or reference files (if warranted) +- [ ] Reference implementation example still accurate + +## Audit report template + +```markdown +# port audit + +**Verdict:** Ready / Needs work / Blocked + +## Critical gaps +1. + +## API parity +| RN | Android | Status | + +## Interactions +... + +## Tests +... + +## Recommendations (ordered) +1. +``` diff --git a/.claude/skills/cds-rn-to-compose/references/compose-docs.md b/.claude/skills/cds-rn-to-compose/references/compose-docs.md new file mode 100644 index 0000000000..d527b923db --- /dev/null +++ b/.claude/skills/cds-rn-to-compose/references/compose-docs.md @@ -0,0 +1,73 @@ +# Official Jetpack Compose documentation + +Primary sources of truth for Compose patterns used in CDS Android ports. Prefer these over blog posts or outdated samples. + +## Core concepts + +| Topic | URL | +|-------|-----| +| Compose mental model | https://developer.android.com/develop/ui/compose/mental-model | +| Modifiers | https://developer.android.com/develop/ui/compose/modifiers | +| State | https://developer.android.com/develop/ui/compose/state | +| Side-effects | https://developer.android.com/develop/ui/compose/side-effects | + +## API design + +| Topic | URL | +|-------|-----| +| Compose API guidelines (AOSP) | https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines.md | +| Component API guidelines | https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-component-api-guidelines.md | +| List of Compose modifiers | https://developer.android.com/develop/ui/compose/modifiers-list | + +## Interaction & input + +| Topic | URL | +|-------|-----| +| Touch input / clickable | https://developer.android.com/develop/ui/compose/touch-input/pointer-input/tap-and-press | +| Focus | https://developer.android.com/develop/ui/compose/touch-input/focus | +| InteractionSource | https://developer.android.com/reference/kotlin/androidx/compose/foundation/interaction/InteractionSource | +| Indication | https://developer.android.com/reference/kotlin/androidx/compose/foundation/Indication | + +## Layout & theming + +| Topic | URL | +|-------|-----| +| Layout basics | https://developer.android.com/develop/ui/compose/layout/basics | +| Material theming (concepts; CDS uses CdsTheme) | https://developer.android.com/develop/ui/compose/designsystems/material | +| Custom design systems | https://developer.android.com/develop/ui/compose/designsystems | + +## Accessibility + +| Topic | URL | +|-------|-----| +| Accessibility overview | https://developer.android.com/develop/ui/compose/accessibility | +| Semantics | https://developer.android.com/reference/kotlin/androidx/compose/ui/semantics/SemanticsPropertyReceiver | + +## Testing + +| Topic | URL | +|-------|-----| +| Testing overview | https://developer.android.com/develop/ui/compose/testing | +| Compose UI testing cheatsheet | https://developer.android.com/develop/ui/compose/testing-cheatsheet | +| Interoperability (Robolectric) | https://developer.android.com/develop/ui/compose/testing#robolectric | + +## Kotlin library authoring + +| Topic | URL | +|-------|-----| +| Explicit API mode | https://kotlinlang.org/docs/whatsnew14.html#explicit-api-mode-for-library-authors | + +## CDS-specific (repo) + +| Topic | Path | +|-------|------| +| Android package rules | `packages/cds-android/AGENTS.md` | +| Theme design | `packages/cds-android/src/main/java/com/coinbase/cds/theme/README.md` | +| Interaction docs | `packages/cds-android/docs/interaction.md` | +| Jetpack best practices skill | `.claude/skills/jetpack-best-practices/SKILL.md` | + +## Intentionally not used (yet) + +| Topic | URL | Note | +|-------|-----|------| +| Compose Styles API | https://developer.android.com/develop/ui/compose/designsystems/styles | Alpha — CDS uses `*Style.kt` resolvers instead | diff --git a/.claude/skills/cds-rn-to-compose/references/discovery-template.md b/.claude/skills/cds-rn-to-compose/references/discovery-template.md new file mode 100644 index 0000000000..ac16552264 --- /dev/null +++ b/.claude/skills/cds-rn-to-compose/references/discovery-template.md @@ -0,0 +1,134 @@ +# Discovery: `` RN → Compose + +Copy this template into a PR description or working note. Fill every section before writing Kotlin. + +## Source inventory + +| Artifact | Path | Reviewed | +|----------|------|----------| +| RN implementation | `packages/mobile/src/.../.tsx` | [ ] | +| Common types | `packages/common/src/types/...` | [ ] | +| Tokens | `packages/common/src/tokens/...` | [ ] | +| Stories | `packages/mobile/src/**/__stories__/.stories.tsx` | [ ] | +| RN tests | `packages/mobile/src/**/__tests__/.test.tsx` | [ ] | +| iOS (if any) | `packages/cds-ios/Sources/Components/.swift` | [ ] | +| Existing Android | `packages/cds-android/...` | [ ] | + +## Deprecated API (do not port) + +List every `@deprecated` prop, variant, or type found in mobile/common: + +| Symbol | Deprecation reason | Android action | +|--------|-------------------|----------------| +| | | Skip | + +## Public API proposal + +### Composable signature (draft) + +```kotlin +@Composable +fun ComponentName( + // required + modifier: Modifier = Modifier, + // ... +) +``` + +### Props mapping + +| RN prop | Compose equivalent | Port? | Notes | +|---------|-------------------|-------|-------| +| `style` | `modifier` | Partial | Layout only | +| `block` | — | No | Caller `fillMaxWidth()` | +| | | | | + +### Variants / sizes / enums + +| RN | Kotlin type | Default | +|----|-------------|---------| +| | | | + +## Layout & slots + +- **Intrinsic size behavior:** +- **Icon/content slots:** `@Composable (tint: Color, size: Dp) -> Unit` or content lambda? +- **Caller-controlled layout:** document `Modifier` patterns (padding, fillMaxWidth, weight) + +## Interaction analysis + +| Interaction | Needed? | RN mechanism | Compose modifier | Customer observable? | +|-------------|---------|--------------|------------------|---------------------| +| Press | | `Pressable` | `clickable` | `MutableInteractionSource` | +| Hover | | `Interactable` | `hoverable` | | +| Focus | | focus styles | `focusable` | | +| Long press | | | `combinedClickable` | | +| Drag | | | | | +| Toggle | | | | | + +**Indication:** `CdsInteractionDefaults.indication()` or `indication(shape)`? + +**Disabled treatment:** `CdsInteractionDefaults.DisabledAlpha` + block gestures when disabled/loading + +## States + +| State | Visual | Behavioral | Semantics | +|-------|--------|------------|-----------| +| enabled | | | | +| disabled | | block input | `disabled()` | +| loading | | block input | progress + label | + +## Token mapping + +| Visual property | cds-common token | CdsTheme accessor | Delivery | +|-----------------|------------------|-------------------|----------| +| Background | | `CdsTheme.colors.*` | `LocalCdsTheme` via `CdsThemeProvider` | +| Foreground | | | | +| Padding | | `CdsTheme.space.*` | | +| Typography | | `CdsTheme.typography.*` | | +| Radius | | `CdsTheme.borderRadius.*` | | + +**Token gaps:** (values missing from Android theme — file follow-up) + +## Explicitly out of scope + +- [ ] `useComponentConfig` +- [ ] Haptics / debounce / `flush` +- [ ] `wrapperStyles` / granular `styles` object +- [ ] Compose Styles API (alpha) +- [ ] Deprecated props listed above + +## Reuse + +- [ ] `CdsInteractionDefaults` +- [ ] Internal `Text` or other CDS components +- [ ] Shared style resolvers pattern (`*Style.kt`) + +## Test plan + +| Test | Type | Priority | +|------|------|----------| +| Style resolver token mapping | JUnit pure | High | +| onClick / callback | Robolectric | High | +| Disabled blocks interaction | Robolectric | High | +| Loading semantics | Robolectric | High | +| InteractionSource press events | Robolectric | If hoisted | +| Icon slot tint/size | Robolectric | If slots | + +## Demo & docs + +- Gallery section: `apps/android-app/.../GallerySection.kt` +- Doc: `packages/cds-android/docs/.md` +- CHANGELOG entry + +## Open questions + +- + +## Skill feedback (Phase 8) + +After the port, note anything that should be added to `references/learnings.md` or promoted into the skill: + +| Learning | Generalizable? | Target file | +|----------|----------------|-------------| +| | Yes / No / Pending | `learnings.md` / `SKILL.md` / mapping / audit | diff --git a/.claude/skills/cds-rn-to-compose/references/learnings.md b/.claude/skills/cds-rn-to-compose/references/learnings.md new file mode 100644 index 0000000000..a3457b9d61 --- /dev/null +++ b/.claude/skills/cds-rn-to-compose/references/learnings.md @@ -0,0 +1,69 @@ +# Port learnings log + +Append-only record of lessons from CDS RN → Compose ports. Read this at the start of every port and audit — it accumulates gotchas and patterns that are not yet (or should not be) in `SKILL.md`. + +**Format for new entries** (add at the top, newest first): + +```markdown +## YYYY-MM-DD — + +**Context:** [what happened during the port or audit] + +**Learning:** [generalizable rule or pattern] + +**Skill update:** [which file was updated, or "pending" if not yet promoted] + +**Applies to:** [component types, e.g. "all interactive components", "list items"] +``` + +--- + +## 2026-09-08 — Theming via CompositionLocal + +**Context:** Skill review — ensure ports use the established CDS theme delivery mechanism, not RN-style theme props. + +**Learning:** Tokens flow through `CdsThemeProvider` → `LocalCdsTheme` → `CdsTheme.*` accessors. Style resolvers take resolved token values as parameters; `@Composable` entry points read the local once. `Modifier.Node` code uses `currentValueOf(LocalCdsTheme)`. + +**Skill update:** Expanded Theming section in `SKILL.md`; updated mapping, audit checklist, discovery template. + +**Applies to:** All `packages/cds-android` ports. + +--- + +## 2026-09-08 — Button (post-audit fixes) + +**Context:** Audit found interaction, test, docs, and API-boundary gaps; fixes applied in same session. + +**Learnings:** + +- Wire `hoverable` + `focusable` + `clickable` with the same hoisted `interactionSource` when `CdsInteractionDefaults` advertises hover/focus support. +- `ButtonColors` / `ButtonMetrics` must be `internal` — public style types invite Hyrum's Law coupling even when docs say otherwise. +- Test `interactionSource` press production and loading click-blocking explicitly. +- Document `interactionSource` and intentional RN scope deltas in component docs. + +**Skill update:** Promoted Hyrum's Law / public API boundary section in `SKILL.md` and audit checklist. + +**Applies to:** All `packages/cds-android` component ports. + +--- + +## 2026-09-08 — Button (initial skill) + +**Context:** First full CDS Android component port; established interaction, testing, and API patterns. + +**Learnings:** + +- RN `block` / `fullWidth` → caller `Modifier.fillMaxWidth()`, not a component prop. +- Icon props (`IconName`) → `@Composable (tint: Color, size: Dp) -> Unit` slots until a public Icon exists. +- `getInteractableStyles` / `Interactable` → `CdsInteractionDefaults.indication(shape)` + standard gesture modifiers; do not port the RN style pipeline. +- Disabled opacity (0.5) is separate from indication — apply `CdsInteractionDefaults.DisabledAlpha` on the component. +- Hoist `MutableInteractionSource` when customers may observe press/focus/hover; pass the same instance to gesture + indication. +- Only wire modifiers for interactions actually produced (`hoverable` required for hover feedback). +- Extract `resolve*Colors()` / `resolve*Metrics()` to `*Style.kt` for pure JUnit tests; use Robolectric for click, disabled, loading semantics. +- `semantics(mergeDescendants = true)` hides child `testTag`s — test icon slots by capturing slot lambda args instead. +- Do not widen `public` API for `apps/android-app`; fix consumer patterns instead. +- Compose Styles API is alpha — keep `*Style.kt` migration seam. + +**Skill update:** Initial `SKILL.md` and reference files created from this port. + +**Applies to:** All interactive CDS Android ports. diff --git a/.claude/skills/cds-rn-to-compose/references/rn-to-compose-mapping.md b/.claude/skills/cds-rn-to-compose/references/rn-to-compose-mapping.md new file mode 100644 index 0000000000..f15811c1aa --- /dev/null +++ b/.claude/skills/cds-rn-to-compose/references/rn-to-compose-mapping.md @@ -0,0 +1,104 @@ +# React Native → Jetpack Compose mapping + +Use this when translating CDS mobile patterns to `packages/cds-android`. Prefer Compose-native APIs over RN-shaped APIs. + +Official references: see `compose-docs.md`. + +## Component structure + +| React Native (CDS mobile) | Jetpack Compose (CDS Android) | +|---------------------------|-------------------------------| +| `export const Button = memo(...)` | `@Composable fun Button(...)` | +| `function` returning JSX | `@Composable` returning `Unit` | +| `children` | trailing `@Composable () -> Unit` content lambda | +| `React.memo` | Compose skippability (stable params); no manual memo | +| `useMemo` / `useCallback` | `remember` / stable lambdas when needed | +| `forwardRef` | Not applicable; use semantics / test tags | + +## Styling + +| React Native | Jetpack Compose | CDS rule | +|--------------|-----------------|----------| +| `style: ViewStyle` | `modifier: Modifier` | First optional param; outermost node | +| `styles.container` etc. | — | No granular styles object; use modifier + slots | +| `StyleSheet.create` | — | Do not port | +| `useTheme()` | `CdsTheme.colors`, `.space`, `.typography` | Never import cds-common | +| `paddingX: 2` (token index) | `CdsTheme.space.x2` | Hand-port from common tokens | +| `borderRadius: 700` | `CdsTheme.borderRadius.radius700` | | +| `width: '100%'` / `block` prop | `Modifier.fillMaxWidth()` | Caller responsibility, not component prop | +| `flexDirection: 'row'` | `Row` | | +| `alignItems` / `justifyContent` | `Arrangement`, `Alignment` | | +| `opacity` disabled | `Modifier.alpha(CdsInteractionDefaults.DisabledAlpha)` | 0.5 token | +| Dynamic interactable styles | `CdsInteractionDefaults.indication(shape)` | Not `getInteractableStyles` pipeline | + +**Do not use** the alpha [Compose Styles API](https://developer.android.com/develop/ui/compose/designsystems/styles). Keep resolvers in `*Style.kt` for a future migration seam. + +## Interaction + +| React Native | Jetpack Compose | +|--------------|-----------------| +| `Pressable` | `Modifier.clickable` | +| `onPress` | `onClick: () -> Unit` | +| `onLongPress` | `combinedClickable(onLongClick = ...)` | +| `disabled` | `enabled = false` on clickable + semantics | +| `Pressable` state callback (`pressed`) | `MutableInteractionSource` + `collectIsPressedAsState()` | +| `Interactable` / `getInteractableStyles` | `CdsInteractionDefaults` + standard gesture modifiers | +| `accessibilityRole` | `Modifier.semantics { role = Role.Button }` | +| `accessibilityLabel` | `contentDescription` | +| `accessibilityState={{ disabled, busy }}` | `disabled()`, `progressBarRangeInfo` | + +Wire every interaction type you want customers to observe to the **same** `MutableInteractionSource` passed to `clickable` / `hoverable` / `focusable` and to `indication`. + +## Icons & media + +| React Native | Jetpack Compose | +|--------------|-----------------| +| `icon: IconName` | `@Composable (tint: Color, size: Dp) -> Unit` slot | +| `` | Caller provides icon composable until public Icon exists | +| `startIcon` / `endIcon` | `startIcon`, `endIcon` slot parameters | + +## Layout primitives + +| React Native | Jetpack Compose | +|--------------|-----------------| +| `View` | `Box` | +| `HStack` / `VStack` | `Row` / `Column` | +| `Text` (CDS) | Internal `Text` composable in cds-android | +| `ScrollView` | `Column` + parent `verticalScroll` | +| `ActivityIndicator` / `ProgressCircle` | `CircularProgressIndicator` or CDS visualization when ported | + +## State & configuration + +| React Native | Jetpack Compose | +|--------------|-----------------| +| `useComponentConfig('Button', props)` | **Do not port** — defaults in signature | +| `useTheme()` | `CdsTheme.*` accessors (read `LocalCdsTheme` provided by `CdsThemeProvider`) | +| `ThemeProvider` / theme context | `CdsThemeProvider(theme, colorScheme)` → `LocalCdsTheme` | +| Controlled `value` + `onChange` | Hoisted state: param + callback | +| Internal `useState` | `remember` only for UI-local ephemeral state | + +## Types & API boundary + +| React Native | Jetpack Compose | +|--------------|-----------------| +| `export type ButtonSize = 'xs' \| 's'...` | `enum class ButtonSize` or sealed types | +| Props interface | Kotlin data class params or direct composable params | +| Public npm export | `public` Kotlin declaration (explicit API mode) | +| Internal helper | `internal fun` | + +## Testing + +| React Native | Jetpack Compose | +|--------------|-----------------| +| `@testing-library/react-native` | `createComposeRule()` + `onNodeWith...` | +| `fireEvent.press` | `performClick()` | +| Jest matchers | `assertIsDisplayed()`, `assertIsEnabled()` | +| Style logic in TS | Pure Kotlin functions in `*Style.kt` + JUnit | +| — | Robolectric for JVM Android resources | + +## Documentation + +| React Native | Jetpack Compose | +|--------------|-----------------| +| Storybook stories | `android-app` gallery section | +| Component docsite (web) | `packages/cds-android/docs/.md` | diff --git a/AGENTS.md b/AGENTS.md index ad5f3b3c35..a3793169f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ defaults remain Node-oriented, and [`docs/`](docs/README.md) for setup and CI ar - For Swift/SwiftUI implementation rules, API boundaries, token details, and releases, follow [`packages/cds-ios/AGENTS.md`](packages/cds-ios/AGENTS.md). - Load the `jetpack-best-practices` skill when writing Compose. +- Load the `cds-rn-to-compose` skill when porting mobile components to `packages/cds-android` or auditing Android/mobile parity. - Do not copy package-local consumer or release rules into this root file. ## Skills diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index dcf7f6a13a..1df1012692 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -8,6 +8,7 @@ lifecycleRuntimeKtx = "2.6.1" activityCompose = "1.8.0" kotlin = "2.2.10" composeBom = "2026.02.01" +robolectric = "4.16.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -26,6 +27,7 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-animation-core = { group = "androidx.compose.animation", name = "animation-core" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt index 5a9e66f612..bb86510f39 100644 --- a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt @@ -8,16 +8,16 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,12 +28,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import com.coinbase.cds.androidapp.gallery.ButtonGallerySection import com.coinbase.cds.androidapp.gallery.CdsThemeGallery +import com.coinbase.cds.androidapp.theme.AcmeTheme +import com.coinbase.cds.components.button.Button +import com.coinbase.cds.components.button.ButtonVariant import com.coinbase.cds.theme.CdsColorScheme import com.coinbase.cds.theme.CdsDefaultTheme import com.coinbase.cds.theme.CdsTheme import com.coinbase.cds.theme.CdsThemeProvider -import com.coinbase.cds.androidapp.theme.AcmeTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -44,8 +47,6 @@ class MainActivity : ComponentActivity() { var customBrand by remember { mutableStateOf(false) } var showGallery by remember { mutableStateOf(false) } - // Theme and color scheme are independent axes, so they multiply instead of combining: - // two themes times two schemes is two one-line choices here, not a four-branch `when`. val theme: CdsTheme = if (customBrand) AcmeTheme else CdsDefaultTheme val colorScheme = if (darkTheme) CdsColorScheme.Dark else CdsColorScheme.Light @@ -73,12 +74,6 @@ class MainActivity : ComponentActivity() { } } -/** - * A bespoke screen built on CDS theme tokens (color, space, radius, typography). CDS components - * (`Text`, `Button`, `SlideButton`) are temporarily internal for the first AAR release, so this - * screen uses Compose Foundation primitives plus tokens — the way a consumer would until those - * components return to the public API. - */ @Composable fun CdsSampleScreen( darkTheme: Boolean, @@ -88,14 +83,6 @@ fun CdsSampleScreen( onShowGallery: () -> Unit, modifier: Modifier = Modifier, ) { - // var slideConfirmed by remember { mutableStateOf(false) } - // LaunchedEffect(slideConfirmed) { - // if (slideConfirmed) { - // delay(1500) - // slideConfirmed = false - // } - // } - Box( modifier = modifier .fillMaxSize() @@ -103,9 +90,6 @@ fun CdsSampleScreen( .systemBarsPadding() .padding(CdsTheme.space.x3), ) { - // Scrollable rather than a fixed-height assumption: a theme like Acme (bigger space/type - // scale) makes this content taller than the default theme's, and the screen should adapt - // rather than silently clip whatever doesn't fit the current theme's sizing. Column( modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), @@ -138,14 +122,20 @@ fun CdsSampleScreen( style = CdsTheme.typography.body, color = CdsTheme.colors.fgMuted, ) - SampleControl( + Button( text = if (darkTheme) "Switch to light theme" else "Switch to dark theme", onClick = onToggleDarkTheme, + modifier = Modifier.fillMaxWidth(), ) - SampleControl( - text = if (customBrand) "Switch to default CDS theme" else "Switch to Acme brand theme", + Button( + text = if (customBrand) { + "Switch to default CDS theme" + } else { + "Switch to Acme brand theme" + }, onClick = onToggleBrand, - emphasized = false, + modifier = Modifier.fillMaxWidth(), + variant = ButtonVariant.Tertiary, ) SampleText( text = "View theme gallery", @@ -158,9 +148,6 @@ fun CdsSampleScreen( } } - // CDS components (Text / Button / SlideButton) are temporarily internal for the first - // AAR release. Restore this gallery when they return to the public API. - /* Box( modifier = Modifier .fillMaxWidth() @@ -168,46 +155,12 @@ fun CdsSampleScreen( .background(CdsTheme.colors.bgSecondary) .padding(CdsTheme.space.x2), ) { - Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1_5)) { - Text( - text = "CDS Components", - font = CdsFontToken.Headline, - ) - Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x0_5)) { - Text(text = "Display3 heading", font = CdsFontToken.Display3) - Text(text = "Title3 heading", font = CdsFontToken.Title3) - Text( - text = "Label1, in the theme's positive color", - font = CdsFontToken.Label1, - color = CdsTheme.colors.fgPositive, - ) - Text(text = "Caption is auto-uppercased", font = CdsFontToken.Caption) - Text(text = "Legal, muted and fine-print sized", font = CdsFontToken.Legal, color = CdsTheme.colors.fgMuted) - } - FlowRow( - horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), - verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), - ) { - Button(text = "Primary", onClick = {}, variant = ButtonVariant.Primary, size = ButtonSize.S) - Button(text = "Secondary", onClick = {}, variant = ButtonVariant.Secondary, size = ButtonSize.S) - Button(text = "Tertiary", onClick = {}, variant = ButtonVariant.Tertiary, size = ButtonSize.S) - Button(text = "Positive", onClick = {}, variant = ButtonVariant.Positive, size = ButtonSize.S) - Button(text = "Negative", onClick = {}, variant = ButtonVariant.Negative, size = ButtonSize.S) - } - SlideButton( - checked = slideConfirmed, - onCheckedChange = { slideConfirmed = it }, - uncheckedLabel = "Slide to confirm", - checkedLabel = "Confirming...", - ) - } + ButtonGallerySection() } - */ } } } -/** Theme-token text. CDS [com.coinbase.cds.components.text.Text] is temporarily internal. */ @Composable private fun SampleText( text: String, @@ -218,28 +171,6 @@ private fun SampleText( BasicText(text = text, modifier = modifier, style = style.copy(color = color)) } -/** Theme-token control. CDS [com.coinbase.cds.components.button.Button] is temporarily internal. */ -@Composable -private fun SampleControl( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - emphasized: Boolean = true, -) { - val container = if (emphasized) CdsTheme.colors.bgPrimary else CdsTheme.colors.bgTertiary - val content = if (emphasized) CdsTheme.colors.fgInverse else CdsTheme.colors.fg - Box( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(CdsTheme.borderRadius.radius900)) - .background(container) - .clickable(onClick = onClick) - .padding(horizontal = CdsTheme.space.x3, vertical = CdsTheme.space.x1_5), - ) { - SampleText(text = text, style = CdsTheme.typography.headline, color = content) - } -} - @Preview(showBackground = true) @Composable fun CdsSampleScreenLightPreview() { @@ -288,10 +219,7 @@ fun CdsThemeGalleryPreview() { CdsThemeGallery(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) } -// CDS Button is temporarily internal for the first AAR release. Restore this preview when it -// returns to the public API. -/* -@Preview(showBackground = true, heightDp = 620) +@Preview(showBackground = true, heightDp = 1200) @Composable fun ButtonShowcasePreview() { CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { @@ -301,22 +229,7 @@ fun ButtonShowcasePreview() { .background(CdsTheme.colors.bg) .padding(CdsTheme.space.x2), ) { - Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1_5)) { - Button(text = "Primary", onClick = {}, variant = ButtonVariant.Primary) - Button(text = "Secondary", onClick = {}, variant = ButtonVariant.Secondary) - Button(text = "Tertiary", onClick = {}, variant = ButtonVariant.Tertiary) - Button(text = "Positive", onClick = {}, variant = ButtonVariant.Positive) - Button(text = "Negative", onClick = {}, variant = ButtonVariant.Negative) - Button(text = "Transparent", onClick = {}, transparent = true) - Button(text = "Disabled", onClick = {}, enabled = false) - Button(text = "Loading", onClick = {}, loading = true) - Button(text = "Full width", onClick = {}, fullWidth = true) - Row(horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1)) { - Button(text = "Small", onClick = {}, size = ButtonSize.S) - Button(text = "XSmall", onClick = {}, size = ButtonSize.Xs) - } - } + ButtonGallerySection() } } } -*/ diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt new file mode 100644 index 0000000000..5c1243a522 --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt @@ -0,0 +1,160 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import com.coinbase.cds.components.button.Button +import com.coinbase.cds.components.button.ButtonSize +import com.coinbase.cds.components.button.ButtonVariant +import com.coinbase.cds.theme.CdsTheme + +@Composable +fun ButtonGallerySection(modifier: Modifier = Modifier) { + var clickCount by remember { mutableIntStateOf(0) } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), + ) { + GallerySectionTitle(text = "Button") + GalleryText( + text = "Clicks on the interactive primary button: $clickCount", + style = CdsTheme.typography.body, + color = CdsTheme.colors.fgMuted, + ) + + GallerySubsectionTitle("Variants") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Button(text = "Primary", onClick = { clickCount++ }) + Button(text = "Secondary", onClick = {}, variant = ButtonVariant.Secondary) + Button(text = "Tertiary", onClick = {}, variant = ButtonVariant.Tertiary) + Button(text = "Positive", onClick = {}, variant = ButtonVariant.Positive) + Button(text = "Negative", onClick = {}, variant = ButtonVariant.Negative) + Button(text = "Inverse", onClick = {}, variant = ButtonVariant.Inverse) + } + + GallerySubsectionTitle("Transparent") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Button(text = "Primary", onClick = {}, transparent = true) + Button(text = "Positive", onClick = {}, variant = ButtonVariant.Positive, transparent = true) + Button(text = "Inverse", onClick = {}, variant = ButtonVariant.Inverse, transparent = true) + } + + GallerySubsectionTitle("States") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Button(text = "Disabled", onClick = {}, enabled = false) + Button(text = "Loading", onClick = {}, loading = true) + Button( + text = "Transparent loading", + onClick = {}, + loading = true, + transparent = true, + variant = ButtonVariant.Secondary, + ) + } + + GallerySubsectionTitle("Sizes") + Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1)) { + Button(text = "Extra small", onClick = {}, size = ButtonSize.Xs) + Button(text = "Small", onClick = {}, size = ButtonSize.S) + Button(text = "Medium", onClick = {}, size = ButtonSize.M) + Button(text = "Large", onClick = {}, size = ButtonSize.L) + } + + GallerySubsectionTitle("Icons") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Button( + text = "Back", + onClick = {}, + startIcon = { tint, size -> GalleryArrowIcon(tint, size, pointsLeft = true) }, + ) + Button( + text = "Next", + onClick = {}, + endIcon = { tint, size -> GalleryArrowIcon(tint, size, pointsLeft = false) }, + ) + Button( + text = "Both", + onClick = {}, + startIcon = { tint, size -> GalleryArrowIcon(tint, size, pointsLeft = true) }, + endIcon = { tint, size -> GalleryArrowIcon(tint, size, pointsLeft = false) }, + ) + } + + GallerySubsectionTitle("Layout") + Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1)) { + Button( + text = "Full width via Modifier.fillMaxWidth()", + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + Button( + text = "Long label that should truncate when the button cannot grow any wider", + onClick = {}, + modifier = Modifier.fillMaxWidth(), + maxLines = 1, + ) + } + } +} + +@Composable +private fun GallerySubsectionTitle(text: String) { + GalleryText( + text = text, + style = CdsTheme.typography.headline, + color = CdsTheme.colors.fg, + modifier = Modifier.padding(top = CdsTheme.space.x0_5), + ) +} + +@Composable +private fun GalleryArrowIcon(color: Color, iconSize: Dp, pointsLeft: Boolean) { + Canvas(modifier = Modifier.size(iconSize)) { + val strokeWidth = size.minDimension * 0.14f + val path = Path().apply { + if (pointsLeft) { + moveTo(size.width * 0.62f, size.height * 0.12f) + lineTo(size.width * 0.3f, size.height * 0.5f) + lineTo(size.width * 0.62f, size.height * 0.88f) + } else { + moveTo(size.width * 0.38f, size.height * 0.12f) + lineTo(size.width * 0.7f, size.height * 0.5f) + lineTo(size.width * 0.38f, size.height * 0.88f) + } + } + drawPath( + path = path, + color = color, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + } +} diff --git a/packages/cds-android/AGENTS.md b/packages/cds-android/AGENTS.md index 5698a035fa..172d92312a 100644 --- a/packages/cds-android/AGENTS.md +++ b/packages/cds-android/AGENTS.md @@ -13,16 +13,18 @@ so the compiler rejects any declaration whose visibility was inherited rather th - Default to `internal` or `private`. Reach for `public` only when the symbol is meant for customers. +- **Hyrum's Law applies:** consumers will depend on any `public` symbol even if undocumented. + Style resolvers (`*Colors`, `*Metrics`), assembly composables, and helpers stay `internal`. - When the compiler tells you to add a visibility modifier, that is the moment to decide whether the symbol belongs on the customer API - not a formality to satisfy with `public`. - **Never widen visibility to make `apps/android-app` compile.** The demo app is a consumer. If it cannot express something with the public API, either the API is genuinely missing something or the app is doing something it should not. - Changing the signature of an existing `public` declaration is a breaking change. -- The public surface lives in `com.coinbase.cds.theme`. Components under - `com.coinbase.cds.components.*` are temporarily `internal` for the first release — they were - experiments and are not customer API yet. Anything under `components/internal/` stays off-limits - to consumers by construction. +- The public surface lives in `com.coinbase.cds.theme` and `com.coinbase.cds.components.button`, + `com.coinbase.cds.interaction`. Components such as `Text` and `SlideButton` are temporarily + `internal` for the first release — they were experiments and are not customer API yet. Anything + under `components/internal/` stays off-limits to consumers by construction. ## Theming @@ -44,6 +46,10 @@ Follow the `jetpack-best-practices` skill (the official AOSP Compose API guideli that get violated most often here: every element accepts and respects a `Modifier` parameter, `Modifier` is the first optional parameter, and composables that emit UI return `Unit`. +When porting a component from `packages/mobile` or auditing an existing Android port for mobile +parity, load the `cds-rn-to-compose` skill. It covers discovery, RN→Compose mapping, interaction +hoisting, token usage, testing, and the audit checklist. + ## Boundaries with the rest of the monorepo - Do not add Yarn/npm dependencies to this package. Its `package.json` is a stub that exists only diff --git a/packages/cds-android/CHANGELOG.md b/packages/cds-android/CHANGELOG.md index 549e4d4a22..2068919152 100644 --- a/packages/cds-android/CHANGELOG.md +++ b/packages/cds-android/CHANGELOG.md @@ -24,8 +24,11 @@ Its versions are independent of the `@coinbase/cds-*` npm packages. - Initial public theme API: `CdsTheme`, the `cdsTheme` builder, `CdsThemeProvider`, `LocalCdsTheme`, `CdsDefaultTheme`, and the token types under `com.coinbase.cds.theme`. -- Components (`Button`, `Text`, `SlideButton`, and friends) ship in the AAR but are `internal` - and not customer API yet. +- Public `Button` component with variants, sizes, transparent mode, loading/disabled states, and + start/end icon slots under `com.coinbase.cds.components.button`. +- Public `CdsInteractionDefaults` indication primitive under `com.coinbase.cds.interaction` for + shared press, hover, drag, and keyboard-focus affordances. +- Other components (`Text`, `SlideButton`) ship in the AAR but remain `internal`. #### Requirements diff --git a/packages/cds-android/build.gradle.kts b/packages/cds-android/build.gradle.kts index a4e762958d..853493a516 100644 --- a/packages/cds-android/build.gradle.kts +++ b/packages/cds-android/build.gradle.kts @@ -36,6 +36,7 @@ android { // no-opping. Tracing is the only Android surface the token layer reaches, so letting // the stubs return defaults is enough to run it on the JVM -- no Robolectric needed. isReturnDefaultValues = true + isIncludeAndroidResources = true } } publishing { @@ -92,9 +93,11 @@ dependencies { api(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.animation.core) - // JUnit alone. Theme behavior -- inheritance through nested providers, scheme inversion -- - // exists only inside a composition, but nothing about it needs Android or a UI tree, so the - // tests host a composition on `androidx.compose.runtime` directly rather than pulling in - // Robolectric and the UI-test artifacts. See `HeadlessComposition.kt`. + // Theme tests still use the headless runtime harness (`HeadlessComposition.kt`). Component + // behavior uses the standard Compose UI Test + Robolectric JVM stack. testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/packages/cds-android/docs/README.md b/packages/cds-android/docs/README.md index 454db71046..170334351e 100644 --- a/packages/cds-android/docs/README.md +++ b/packages/cds-android/docs/README.md @@ -5,6 +5,8 @@ Guides for teams building Android apps on the Coinbase Design System. | Guide | Read it when | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | [Using theme tokens](using-tokens.md) | You're building UI on CDS: reading tokens in a composable, and carrying them through your own state and logic. **Start here.** | +| [Button](button.md) | You're using the CDS call-to-action control: variants, sizes, states, icon slots, and layout. | +| [Interaction affordances](interaction.md) | You're building a custom interactive composable that should match CDS press/hover/focus behavior. | | [Creating a custom theme](custom-themes.md) | You want CDS components to render in your brand's colors, spacing, type, or shape. | | [Theme token reference](token-reference.md) | You need the full list of token names, or the default values behind them. | | [Publishing a version](releasing.md) | You're cutting a GitHub Release of the AAR: version bump, changelog, build, and `gh release create`. Maintainers only. | diff --git a/packages/cds-android/docs/button.md b/packages/cds-android/docs/button.md new file mode 100644 index 0000000000..6641dcbf43 --- /dev/null +++ b/packages/cds-android/docs/button.md @@ -0,0 +1,115 @@ +# Button + +CDS's primary call-to-action control for Jetpack Compose. + +## Quick start + +```kotlin +Button( + text = "Confirm", + onClick = ::confirm, + modifier = Modifier.fillMaxWidth(), +) +``` + +Wrap your app in [CdsThemeProvider](using-tokens.md) first. [Button] reads variant colors, spacing, +radius, and typography from the ambient theme automatically. + +## Scope + +The Android API is intentionally narrower than React Native in a few places: + +| Mobile (RN) | Android CDS | Notes | +| ----------- | ------------- | ----- | +| `children` (any React node) | `text: String` | Label text only; matches the iOS native API shape | +| `start` / `end` slots | `startIcon` / `endIcon` only | Generic node slots are not supported yet | +| `block` / `fullWidth` | `Modifier.fillMaxWidth()` | Layout is caller-controlled via [modifier] | +| `style` / color overrides | — | Re-theme via [cdsTheme](custom-themes.md) | +| Deprecated props (`compact`, `foregroundMuted`, …) | — | Omitted by design | + +## Variants and sizes + +```kotlin +Button(text = "Primary", onClick = {}) +Button(text = "Secondary", onClick = {}, variant = ButtonVariant.Secondary) +Button(text = "Inverse", onClick = {}, variant = ButtonVariant.Inverse) +Button(text = "Ghost", onClick = {}, transparent = true) +Button(text = "Small", onClick = {}, size = ButtonSize.S) +``` + +| [ButtonVariant] | Filled container | Filled content | Transparent content | +| --------------- | ---------------- | -------------- | ------------------- | +| `Primary` | `bgPrimary` | `fgInverse` | `fgPrimary` | +| `Secondary` | `bgSecondary` | `fg` | `fg` | +| `Tertiary` | `bgTertiary` | `fg` | `fg` | +| `Positive` | `bgPositive` | `fgInverse` | `fgPositive` | +| `Negative` | `bgNegative` | `fgInverse` | `fgNegative` | +| `Inverse` | `bgInverse` | `fgInverse` | `fg` | + +Sizes (`Xs`, `S`, `M`, `L`) map to the same padding, radius, icon, and font tokens as web and mobile. + +## States + +```kotlin +Button(text = "Disabled", onClick = {}, enabled = false) +Button(text = "Loading", onClick = {}, loading = true) +``` + +- **Disabled** — non-interactive, reduced opacity via [CdsInteractionDefaults.DisabledAlpha]. +- **Loading** — replaces label and icons with an indeterminate spinner; blocks clicks; exposes loading + progress semantics while retaining the button label as the content description. + +## Icon slots + +Icon names are not part of the Android CDS API yet. Pass composable slots that receive the +resolved tint and icon size: + +```kotlin +Button( + text = "Back", + onClick = ::goBack, + startIcon = { tint, size -> + Icon(painterResource(R.drawable.ic_back), contentDescription = null, tint = tint, modifier = Modifier.size(size)) + }, +) +``` + +## Layout and customization + +Use standard Compose modifiers instead of React Native-style layout props: + +| Need | Compose approach | +| ------------------ | ------------------------------------------------------------------- | +| Full width | `modifier = Modifier.fillMaxWidth()` | +| Test hook | `modifier = Modifier.testTag("confirm")` | +| Custom semantics | `modifier = Modifier.semantics { … }` merged with Button's defaults | +| Offset / alignment | `Modifier.offset`, parent `Row`/`Column` arrangement | + +Raw color, padding, or style object overrides are intentionally absent. Re-theme via +[cdsTheme](custom-themes.md). + +## Interaction + +Press, hover, and keyboard-focus feedback come from [CdsInteractionDefaults](interaction.md), +wired through a hoisted [MutableInteractionSource]: + +```kotlin +val interactionSource = remember { MutableInteractionSource() } +val isPressed by interactionSource.collectIsPressedAsState() + +Button( + text = "Confirm", + onClick = ::confirm, + interactionSource = interactionSource, +) +``` + +Pass the same [interactionSource] you observe — Button forwards it to `hoverable`, `focusable`, and +`clickable` so press, hover, and focus events are all available to customer logic. + +[Button]: ../src/main/java/com/coinbase/cds/components/button/Button.kt +[ButtonVariant]: ../src/main/java/com/coinbase/cds/components/button/Button.kt +[CdsInteractionDefaults.DisabledAlpha]: ../src/main/java/com/coinbase/cds/interaction/CdsInteractionDefaults.kt +[MutableInteractionSource]: https://developer.android.com/reference/kotlin/androidx/compose/foundation/interaction/MutableInteractionSource +[interactionSource]: ../src/main/java/com/coinbase/cds/components/button/Button.kt +[modifier]: ../src/main/java/com/coinbase/cds/components/button/Button.kt diff --git a/packages/cds-android/docs/interaction.md b/packages/cds-android/docs/interaction.md new file mode 100644 index 0000000000..49def9f2e6 --- /dev/null +++ b/packages/cds-android/docs/interaction.md @@ -0,0 +1,75 @@ +# Interaction affordances + +CDS ships reusable press, hover, drag, and keyboard-focus feedback through Compose's standard +[Indication](https://developer.android.com/reference/kotlin/androidx/compose/foundation/Indication) +API. This is **not** the alpha Compose Styles API — it works on the current stable Compose BOM. + +## Quick start + +Paint your component with normal CDS tokens, then pass `CdsInteractionDefaults.indication()` to +`clickable`, `focusable`, or `Modifier.indication`: + +```kotlin +@Composable +fun CustomAction( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val shape = RoundedCornerShape(CdsTheme.borderRadius.radius700) + + Box( + modifier = modifier + .alpha(if (enabled) 1f else CdsInteractionDefaults.DisabledAlpha) + .clip(shape) + .background(CdsTheme.colors.bgPrimary) + .clickable( + interactionSource = interactionSource, + indication = CdsInteractionDefaults.indication(shape), + enabled = enabled, + role = Role.Button, + onClick = onClick, + ) + .padding(CdsTheme.space.x2), + ) { + BasicText( + text = "Custom action", + style = CdsTheme.typography.headline.copy(color = CdsTheme.colors.fgInverse), + ) + } +} +``` + +## API + +| Symbol | Purpose | +| ------------------------------------------ | ------------------------------------------------------------------------------- | +| `CdsInteractionDefaults.indication()` | Rectangular focus outline; no shape required | +| `CdsInteractionDefaults.indication(shape)` | Press scrim and focus ring follow [shape] | +| `CdsInteractionDefaults.DisabledAlpha` | Shared disabled opacity (`0.5`, matches web/mobile `accessibleOpacityDisabled`) | + +## Behavior + +The indication observes a standard `InteractionSource` and applies CDS tokens: + +| State | Visual treatment | +| --------------------- | -------------------------------------------------------------- | +| **Pressed / dragged** | `0.98` scale, `0.82` content alpha, scheme-aware scrim overlay | +| **Hovered** | `0.88` content alpha | +| **Focused** | `bgPrimary` outline for keyboard/D-pad navigation | +| **Disabled** | Not handled here — apply [DisabledAlpha] on the component | + +Pressed and dragged states take priority over hover; focus is hidden while pressed. + +## Used by CDS components + +[Button](../src/main/java/com/coinbase/cds/components/button/Button.kt) applies +`CdsInteractionDefaults.indication(shape)` internally. Future interactive components will share the +same primitive so customer-built composables and CDS components stay visually aligned. + +## Compose Styles migration seam + +Interaction rendering lives behind `CdsInteractionDefaults` and component style resolution stays in +files like `ButtonStyle.kt`. A future Compose Styles integration can replace the internal +application layer without changing public component parameters or icon slot contracts. diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt index da4b5b8501..d84b51bf1f 100644 --- a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt +++ b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt @@ -1,63 +1,73 @@ package com.coinbase.cds.components.button -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import com.coinbase.cds.components.internal.Spinner -import com.coinbase.cds.theme.CdsColorScheme +import com.coinbase.cds.interaction.CdsInteractionDefaults import com.coinbase.cds.theme.CdsTheme -import com.coinbase.cds.theme.CdsThemeProvider -/** Visual/semantic variant -- the five in the current Figma Button spec. */ -internal enum class ButtonVariant { Primary, Secondary, Tertiary, Positive, Negative } - -/** Size tier. The four sizes (`xs`/`s`/`m`/`l`) in the Figma Button spec. */ -internal enum class ButtonSize { Xs, S, M, L } +/** Visual/semantic variant for [Button]. */ +public enum class ButtonVariant { + Primary, + Secondary, + Tertiary, + Positive, + Negative, + Inverse, +} -private const val PressedScale = 0.98f -private const val DisabledAlpha = 0.4f -private const val PressedBlendFraction = 0.15f +/** Size tier for [Button]. */ +public enum class ButtonSize { + Xs, + S, + M, + L, +} /** * CDS's primary call-to-action control. Covers [variant], [size], [enabled]/[loading] state, - * [transparent], [fullWidth], and leading/trailing icon slots. Raw color/background/border - * overrides are deliberately absent -- re-theme via [CdsThemeProvider] instead, so the override - * applies consistently rather than one call site at a time. - * - * Temporarily `internal` for the first AAR release — this was an experiment and is not customer - * API yet. + * [transparent], icon slots, and accessibility semantics. Raw color/background/border overrides + * are deliberately absent — re-theme via [com.coinbase.cds.theme.CdsThemeProvider] instead. * - * Reads colors and metrics from the ambient [CdsTheme], so wrapping a subtree in a - * [CdsThemeProvider] override -- e.g. a customer brand theme -- is picked up automatically with no - * extra wiring. + * For full-width layout, pass `Modifier.fillMaxWidth()`; for test hooks and custom semantics, + * pass them through [modifier]. * * @param transparent Renders on the plain page background with variant-colored text instead of a - * filled, variant-colored container -- CDS's lower-emphasis "ghost" treatment. - * @param leadingIcon Called with the button's resolved content color so an icon's tint - * automatically matches the label and stays correct across variants and themes. + * filled, variant-colored container — CDS's lower-emphasis "ghost" treatment. + * @param interactionSource Hoisted source for press, hover, and focus interactions. Pass the same + * instance you observe via `collectIsPressedAsState()` or `interactions.collect`. + * @param startIcon Called with the button's resolved content color and icon size so an icon's tint + * and dimensions automatically match the label across variants and themes. + * @param endIcon Same contract as [startIcon], rendered after the label. */ @Composable -internal fun Button( +public fun Button( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -66,54 +76,62 @@ internal fun Button( enabled: Boolean = true, loading: Boolean = false, transparent: Boolean = false, - fullWidth: Boolean = false, - leadingIcon: (@Composable (tint: Color) -> Unit)? = null, - trailingIcon: (@Composable (tint: Color) -> Unit)? = null, + maxLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + startIcon: (@Composable (tint: Color, size: Dp) -> Unit)? = null, + endIcon: (@Composable (tint: Color, size: Dp) -> Unit)? = null, ) { val colors = buttonColors(variant, transparent) val metrics = buttonMetrics(size) - - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val active = pressed && enabled && !loading - - val scale by animateFloatAsState(if (active) PressedScale else 1f, label = "cdsButtonScale") - val containerColor = if (active) { - val scrim = if (CdsTheme.colorScheme == CdsColorScheme.Dark) Color.White else Color.Black - lerp(colors.container, scrim, PressedBlendFraction) - } else { - colors.container - } + val shape = RoundedCornerShape(metrics.radius) + val interactive = enabled && !loading Row( modifier = modifier - .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier) - .scale(scale) - .alpha(if (enabled) 1f else DisabledAlpha) - .clip(RoundedCornerShape(metrics.radius)) - .background(containerColor) + .alpha(if (enabled) 1f else CdsInteractionDefaults.DisabledAlpha) + .semantics(mergeDescendants = true) { + role = Role.Button + contentDescription = text + if (loading) { + stateDescription = "Loading" + progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate + } + if (!enabled) { + disabled() + } + } + .clip(shape) + .background(colors.container) + .hoverable(interactionSource = interactionSource, enabled = interactive) + .focusable(enabled = interactive, interactionSource = interactionSource) .clickable( interactionSource = interactionSource, - indication = null, - enabled = enabled && !loading, + indication = CdsInteractionDefaults.indication(shape), + enabled = interactive, role = Role.Button, onClick = onClick, ) .padding(horizontal = metrics.paddingX, vertical = metrics.paddingY), - horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy( + CdsTheme.space.x1, + Alignment.CenterHorizontally, + ), verticalAlignment = Alignment.CenterVertically, ) { if (loading) { Spinner(color = colors.content, diameter = metrics.iconSize) } else { - leadingIcon?.invoke(colors.content) + startIcon?.invoke(colors.content, metrics.iconSize) BasicText( text = text, - style = metrics.font.copy(color = colors.content), - maxLines = 1, + style = metrics.font.copy( + color = colors.content, + textAlign = TextAlign.Center, + ), + maxLines = maxLines, overflow = TextOverflow.Ellipsis, ) - trailingIcon?.invoke(colors.content) + endIcon?.invoke(colors.content, metrics.iconSize) } } } diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/ButtonStyle.kt b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/ButtonStyle.kt index 424cca8b2c..b56373b9e7 100644 --- a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/ButtonStyle.kt +++ b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/ButtonStyle.kt @@ -8,19 +8,25 @@ import androidx.compose.ui.unit.Dp import com.coinbase.cds.theme.CdsTheme /** - * Resolved container/content colors for a [ButtonVariant] -- a flat lookup by variant, nothing more. + * Resolved container/content colors for a [ButtonVariant] — a flat lookup by variant, nothing more. * - * The transparent variants use a true [Color.Transparent] container rather than the base `bg` token. - * Painting `bg` only reads as "transparent" when the button happens to sit directly on the screen's - * base background; this way it looks right on any surface a caller places it on, such as a - * `bgSecondary` card. + * The transparent variants use a true [Color.Transparent] container rather than the base `bg` + * token. Painting `bg` only reads as "transparent" when the button happens to sit directly on the + * screen's base background; this way it looks right on any surface a caller places it on, such as + * a `bgSecondary` card. */ @Immutable internal data class ButtonColors(val container: Color, val content: Color) @Composable -internal fun buttonColors(variant: ButtonVariant, transparent: Boolean): ButtonColors { - val colors = CdsTheme.colors +internal fun buttonColors(variant: ButtonVariant, transparent: Boolean): ButtonColors = + resolveButtonColors(variant, transparent, CdsTheme.colors) + +internal fun resolveButtonColors( + variant: ButtonVariant, + transparent: Boolean, + colors: com.coinbase.cds.theme.CdsColors, +): ButtonColors { return if (transparent) { when (variant) { ButtonVariant.Primary -> ButtonColors(Color.Transparent, colors.fgPrimary) @@ -28,6 +34,7 @@ internal fun buttonColors(variant: ButtonVariant, transparent: Boolean): ButtonC ButtonVariant.Tertiary -> ButtonColors(Color.Transparent, colors.fg) ButtonVariant.Positive -> ButtonColors(Color.Transparent, colors.fgPositive) ButtonVariant.Negative -> ButtonColors(Color.Transparent, colors.fgNegative) + ButtonVariant.Inverse -> ButtonColors(Color.Transparent, colors.fg) } } else { when (variant) { @@ -36,6 +43,7 @@ internal fun buttonColors(variant: ButtonVariant, transparent: Boolean): ButtonC ButtonVariant.Tertiary -> ButtonColors(colors.bgTertiary, colors.fg) ButtonVariant.Positive -> ButtonColors(colors.bgPositive, colors.fgInverse) ButtonVariant.Negative -> ButtonColors(colors.bgNegative, colors.fgInverse) + ButtonVariant.Inverse -> ButtonColors(colors.bgInverse, colors.fgInverse) } } } @@ -51,15 +59,24 @@ internal data class ButtonMetrics( ) @Composable -internal fun buttonMetrics(size: ButtonSize): ButtonMetrics { - val space = CdsTheme.space - val radius = CdsTheme.borderRadius - val iconSize = CdsTheme.iconSize - val typography = CdsTheme.typography - return when (size) { - ButtonSize.Xs -> ButtonMetrics(space.x2, space.x0_75, radius.radius700, iconSize.s, typography.label1) - ButtonSize.S -> ButtonMetrics(space.x2, space.x1, radius.radius700, iconSize.s, typography.headline) - ButtonSize.M -> ButtonMetrics(space.x3, space.x1_5, radius.radius900, iconSize.m, typography.headline) - ButtonSize.L -> ButtonMetrics(space.x4, space.x2, radius.radius900, iconSize.m, typography.headline) - } +internal fun buttonMetrics(size: ButtonSize): ButtonMetrics = + resolveButtonMetrics( + size = size, + space = CdsTheme.space, + borderRadius = CdsTheme.borderRadius, + iconSize = CdsTheme.iconSize, + typography = CdsTheme.typography, + ) + +internal fun resolveButtonMetrics( + size: ButtonSize, + space: com.coinbase.cds.theme.CdsSpace, + borderRadius: com.coinbase.cds.theme.CdsBorderRadius, + iconSize: com.coinbase.cds.theme.CdsIconSize, + typography: com.coinbase.cds.theme.CdsTypography, +): ButtonMetrics = when (size) { + ButtonSize.Xs -> ButtonMetrics(space.x2, space.x0_75, borderRadius.radius700, iconSize.s, typography.label1) + ButtonSize.S -> ButtonMetrics(space.x2, space.x1, borderRadius.radius700, iconSize.s, typography.headline) + ButtonSize.M -> ButtonMetrics(space.x3, space.x1_5, borderRadius.radius900, iconSize.m, typography.headline) + ButtonSize.L -> ButtonMetrics(space.x4, space.x2, borderRadius.radius900, iconSize.m, typography.headline) } diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsIndication.kt b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsIndication.kt new file mode 100644 index 0000000000..d172006b8c --- /dev/null +++ b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsIndication.kt @@ -0,0 +1,171 @@ +package com.coinbase.cds.interaction + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.HoverInteraction +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.unit.dp +import com.coinbase.cds.theme.CdsColorScheme +import com.coinbase.cds.theme.LocalCdsTheme +import kotlinx.coroutines.launch + +private const val ScaleAnimationDurationMillis = 120 + +internal class CdsIndicationNodeFactory( + private val shape: Shape?, +) : IndicationNodeFactory { + override fun create(interactionSource: InteractionSource): DelegatableNode = + CdsIndicationNode(interactionSource, shape) + + override fun equals(other: Any?): Boolean = + other is CdsIndicationNodeFactory && other.shape == shape + + override fun hashCode(): Int = shape.hashCode() +} + +private class CdsIndicationNode( + private val interactionSource: InteractionSource, + private val shape: Shape?, +) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode { + private val interactions = mutableStateListOf() + private val animatedScale = Animatable(1f) + + override fun onAttach() { + coroutineScope.launch { + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> interactions.add(interaction) + is PressInteraction.Release -> interactions.remove(interaction.press) + is PressInteraction.Cancel -> interactions.remove(interaction.press) + is HoverInteraction.Enter -> interactions.add(interaction) + is HoverInteraction.Exit -> interactions.remove(interaction.enter) + is FocusInteraction.Focus -> interactions.add(interaction) + is FocusInteraction.Unfocus -> interactions.remove(interaction.focus) + is DragInteraction.Start -> interactions.add(interaction) + is DragInteraction.Stop -> interactions.remove(interaction.start) + is DragInteraction.Cancel -> interactions.remove(interaction.start) + else -> Unit + } + val visualState = currentVisualState() + launch { + animatedScale.animateTo( + targetValue = visualState.scale, + animationSpec = tween(durationMillis = ScaleAnimationDurationMillis), + ) + } + invalidateDraw() + } + } + } + + override fun ContentDrawScope.draw() { + val visualState = currentVisualState() + val theme = currentValueOf(LocalCdsTheme) + val focusColor = theme?.colors?.bgPrimary ?: Color.Unspecified + val scrimBase = if (theme?.colorScheme == CdsColorScheme.Dark) Color.White else Color.Black + val layerBounds = Rect(0f, 0f, size.width, size.height) + + scale(animatedScale.value, pivot = Offset(size.width / 2f, size.height / 2f)) { + val alpha = visualState.contentAlpha + if (alpha < 1f) { + val layerPaint = Paint().apply { this.alpha = alpha } + drawContext.canvas.saveLayer(layerBounds, layerPaint) + this@draw.drawContent() + drawContext.canvas.restore() + } else { + this@draw.drawContent() + } + } + + if (visualState.showPressedScrim) { + val scrimColor = scrimBase.copy(alpha = CdsInteractionTokens.PressedScrimBlendFraction) + drawShapeOverlay(color = scrimColor, filled = true) + } + + if (visualState.showFocusRing && focusColor != Color.Unspecified) { + val outlineWidth = CdsInteractionTokens.FocusOutlineWidthDp.dp.toPx() + drawShapeOverlay( + color = focusColor, + filled = false, + strokeWidth = outlineWidth, + ) + } + } + + private fun ContentDrawScope.drawShapeOverlay( + color: Color, + filled: Boolean, + strokeWidth: Float = 0f, + ) { + val drawStyle = if (filled) androidx.compose.ui.graphics.drawscope.Fill else Stroke(width = strokeWidth) + val outline = shape?.createOutline(size, layoutDirection, this) + if (outline != null) { + when (outline) { + is Outline.Rectangle -> drawRect( + color = color, + topLeft = outline.rect.topLeft, + size = outline.rect.size, + style = drawStyle, + ) + is Outline.Rounded -> { + val roundRect = outline.roundRect + drawRoundRect( + color = color, + topLeft = Offset(roundRect.left, roundRect.top), + size = Size(roundRect.width, roundRect.height), + cornerRadius = roundRect.topLeftCornerRadius, + style = drawStyle, + ) + } + is Outline.Generic -> drawPath( + path = outline.path, + color = color, + style = drawStyle, + ) + } + } else if (!filled && strokeWidth > 0f) { + val inset = CdsInteractionTokens.FocusOutlineOffsetDp.dp.toPx() + drawRect( + color = color, + topLeft = Offset(-inset, -inset), + size = size.copy( + width = size.width + inset * 2, + height = size.height + inset * 2, + ), + style = Stroke(width = strokeWidth), + ) + } else if (filled) { + drawRect(color = color) + } + } + + private fun currentVisualState(): CdsInteractionVisualState { + val isPressed = interactions.any { it is PressInteraction.Press } + val isDragged = interactions.any { it is DragInteraction.Start } + val isHovered = interactions.any { it is HoverInteraction.Enter } + val isFocused = interactions.any { it is FocusInteraction.Focus } + return resolveCdsInteractionVisualState(isPressed, isDragged, isHovered, isFocused) + } +} diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionDefaults.kt b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionDefaults.kt new file mode 100644 index 0000000000..11b01180f0 --- /dev/null +++ b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionDefaults.kt @@ -0,0 +1,35 @@ +package com.coinbase.cds.interaction + +import androidx.compose.foundation.Indication +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.ui.graphics.Shape + +/** + * Shared CDS interaction affordances for clickable, focusable, hoverable, and draggable + * composables. + * + * Paint the component's normal colors from [com.coinbase.cds.theme.CdsTheme], then pass + * [indication] to `clickable`, `focusable`, or `indication` so press, hover, drag, and keyboard + * focus render with CDS tokens. Disabled styling is separate: apply [DisabledAlpha] when the + * component is not interactive. + * + * This uses Compose's standard [Indication] / [IndicationNodeFactory] APIs — not the alpha + * Compose Styles API — so it can be adopted today and replaced internally later without changing + * component parameters. + */ +public object CdsInteractionDefaults { + /** Matches `accessibleOpacityDisabled` from `@coinbase/cds-common/tokens/interactable`. */ + public const val DisabledAlpha: Float = CdsInteractionTokens.DisabledAlpha + + /** + * CDS interaction feedback with a rectangular focus outline. Use this when corners are square + * or when an exact outline match is unnecessary. + */ + public fun indication(): IndicationNodeFactory = CdsIndicationNodeFactory(shape = null) + + /** + * CDS interaction feedback clipped to [shape]. Pass the same shape used by `clip` or + * `background` when the focus ring and pressed scrim should follow rounded corners. + */ + public fun indication(shape: Shape): IndicationNodeFactory = CdsIndicationNodeFactory(shape) +} diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionState.kt b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionState.kt new file mode 100644 index 0000000000..40bffd3983 --- /dev/null +++ b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionState.kt @@ -0,0 +1,50 @@ +package com.coinbase.cds.interaction + +import androidx.compose.runtime.Immutable + +/** + * Resolved CDS interaction affordances for a single frame. Pure data so priority rules are + * unit-testable without a composition. + */ +@Immutable +internal data class CdsInteractionVisualState( + val isPressed: Boolean = false, + val isDragged: Boolean = false, + val isHovered: Boolean = false, + val isFocused: Boolean = false, +) { + val isActive: Boolean + get() = isPressed || isDragged + + val contentAlpha: Float + get() = when { + isActive -> CdsInteractionTokens.PressedContentAlpha + isHovered -> CdsInteractionTokens.HoveredContentAlpha + else -> 1f + } + + val scale: Float + get() = if (isActive) CdsInteractionTokens.PressedScale else 1f + + val showPressedScrim: Boolean + get() = isActive + + val showFocusRing: Boolean + get() = isFocused && !isActive +} + +/** + * Collapses raw interaction flags into the CDS visual priority: pressed/dragged beat hover, and + * focus is shown only when not actively pressed. + */ +internal fun resolveCdsInteractionVisualState( + isPressed: Boolean, + isDragged: Boolean, + isHovered: Boolean, + isFocused: Boolean, +): CdsInteractionVisualState = CdsInteractionVisualState( + isPressed = isPressed, + isDragged = isDragged, + isHovered = isHovered, + isFocused = isFocused, +) diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionTokens.kt b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionTokens.kt new file mode 100644 index 0000000000..d7a52510db --- /dev/null +++ b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionTokens.kt @@ -0,0 +1,12 @@ +package com.coinbase.cds.interaction + +/** CDS interactable opacity and scale tokens, hand-ported from `@coinbase/cds-common/tokens/interactable`. */ +internal object CdsInteractionTokens { + const val HoveredContentAlpha = 0.88f + const val PressedContentAlpha = 0.82f + const val DisabledAlpha = 0.5f + const val PressedScale = 0.98f + const val PressedScrimBlendFraction = 0.15f + const val FocusOutlineWidthDp = 2f + const val FocusOutlineOffsetDp = 2f +} diff --git a/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonStyleTest.kt b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonStyleTest.kt new file mode 100644 index 0000000000..c7dbd991fd --- /dev/null +++ b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonStyleTest.kt @@ -0,0 +1,74 @@ +package com.coinbase.cds.components.button + +import androidx.compose.ui.graphics.Color +import com.coinbase.cds.theme.CdsColors +import com.coinbase.cds.theme.CdsDefaultTheme +import org.junit.Assert.assertEquals +import org.junit.Test + +class ButtonStyleTest { + private val light = CdsDefaultTheme.lightColors + private val dark = CdsDefaultTheme.darkColors + + @Test + fun filledPrimaryUsesPrimaryTokens() { + val colors = resolveButtonColors(ButtonVariant.Primary, transparent = false, light) + assertEquals(light.bgPrimary, colors.container) + assertEquals(light.fgInverse, colors.content) + } + + @Test + fun filledInverseUsesInverseTokens() { + val colors = resolveButtonColors(ButtonVariant.Inverse, transparent = false, dark) + assertEquals(dark.bgInverse, colors.container) + assertEquals(dark.fgInverse, colors.content) + } + + @Test + fun transparentPrimaryUsesClearContainerAndPrimaryForeground() { + val colors = resolveButtonColors(ButtonVariant.Primary, transparent = true, light) + assertEquals(Color.Transparent, colors.container) + assertEquals(light.fgPrimary, colors.content) + } + + @Test + fun transparentInverseUsesForegroundToken() { + val colors = resolveButtonColors(ButtonVariant.Inverse, transparent = true, dark) + assertEquals(Color.Transparent, colors.container) + assertEquals(dark.fg, colors.content) + } + + @Test + fun sizeLMetricsMatchMobileTable() { + val theme = CdsDefaultTheme + val metrics = resolveButtonMetrics( + ButtonSize.L, + theme.space, + theme.borderRadius, + theme.iconSize, + theme.typography, + ) + assertEquals(theme.space.x4, metrics.paddingX) + assertEquals(theme.space.x2, metrics.paddingY) + assertEquals(theme.borderRadius.radius900, metrics.radius) + assertEquals(theme.iconSize.m, metrics.iconSize) + assertEquals(theme.typography.headline, metrics.font) + } + + @Test + fun sizeXsMetricsMatchMobileTable() { + val theme = CdsDefaultTheme + val metrics = resolveButtonMetrics( + ButtonSize.Xs, + theme.space, + theme.borderRadius, + theme.iconSize, + theme.typography, + ) + assertEquals(theme.space.x2, metrics.paddingX) + assertEquals(theme.space.x0_75, metrics.paddingY) + assertEquals(theme.borderRadius.radius700, metrics.radius) + assertEquals(theme.iconSize.s, metrics.iconSize) + assertEquals(theme.typography.label1, metrics.font) + } +} diff --git a/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt new file mode 100644 index 0000000000..17c5a913df --- /dev/null +++ b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt @@ -0,0 +1,173 @@ +package com.coinbase.cds.components.button + +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.coinbase.cds.theme.CdsColorScheme +import com.coinbase.cds.theme.CdsDefaultTheme +import com.coinbase.cds.theme.CdsThemeProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(instrumentedPackages = ["androidx.loader.content"]) +class ButtonTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun rendersLabelAndFiresOnClick() { + var clicked = false + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Confirm", onClick = { clicked = true }) + } + } + + composeRule.onNodeWithText("Confirm").assertIsDisplayed().performClick() + assertTrue(clicked) + } + + @Test + fun disabledButtonDoesNotInvokeOnClick() { + var clicked = false + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Disabled", onClick = { clicked = true }, enabled = false) + } + } + + composeRule.onNodeWithText("Disabled").assertIsNotEnabled() + composeRule.onNodeWithText("Disabled").performClick() + assertEquals(false, clicked) + } + + @Test + fun loadingButtonIsNotEnabledAndExposesProgressSemantics() { + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Submit", onClick = {}, loading = true) + } + } + + val node = composeRule.onNodeWithContentDescription("Submit") + node.assertIsNotEnabled() + node.fetchSemanticsNode().config.apply { + assertEquals("Loading", get(SemanticsProperties.StateDescription)) + assertEquals( + ProgressBarRangeInfo.Indeterminate, + get(SemanticsProperties.ProgressBarRangeInfo), + ) + } + } + + @Test + fun loadingButtonDoesNotInvokeOnClick() { + var clicked = false + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Submit", onClick = { clicked = true }, loading = true) + } + } + + composeRule.onNodeWithContentDescription("Submit").performClick() + assertEquals(false, clicked) + } + + @Test + fun hoistedInteractionSourceReceivesPressInteraction() { + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + + composeRule.setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + interactions.add(interaction) + } + } + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button( + text = "Confirm", + onClick = {}, + interactionSource = interactionSource, + ) + } + } + + composeRule.onNodeWithText("Confirm").performClick() + composeRule.waitForIdle() + assertTrue(interactions.any { it is PressInteraction.Press }) + } + + @Test + fun iconSlotsReceiveTintAndSize() { + var capturedTint: Color? = null + var capturedSize: Dp? = null + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button( + text = "With icon", + onClick = {}, + startIcon = { tint, size -> + capturedTint = tint + capturedSize = size + }, + ) + } + } + + composeRule.waitForIdle() + assertEquals(CdsDefaultTheme.lightColors.fgInverse, capturedTint) + assertEquals(CdsDefaultTheme.iconSize.m, capturedSize) + } + + @Test + fun callerModifierFillMaxWidthIsRespected() { + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button( + text = "Full width", + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .testTag("button"), + ) + } + } + + composeRule.onNodeWithTag("button").assertIsDisplayed() + } + + @Test + fun enabledButtonIsClickable() { + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Enabled", onClick = {}) + } + } + + composeRule.onNodeWithText("Enabled").assertIsEnabled() + } +} diff --git a/packages/cds-android/src/test/java/com/coinbase/cds/interaction/CdsInteractionStateTest.kt b/packages/cds-android/src/test/java/com/coinbase/cds/interaction/CdsInteractionStateTest.kt new file mode 100644 index 0000000000..b6dd0f9347 --- /dev/null +++ b/packages/cds-android/src/test/java/com/coinbase/cds/interaction/CdsInteractionStateTest.kt @@ -0,0 +1,60 @@ +package com.coinbase.cds.interaction + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CdsInteractionStateTest { + @Test + fun pressedTakesPriorityOverHoverAndFocus() { + val state = resolveCdsInteractionVisualState( + isPressed = true, + isDragged = false, + isHovered = true, + isFocused = true, + ) + assertEquals(CdsInteractionTokens.PressedContentAlpha, state.contentAlpha) + assertEquals(CdsInteractionTokens.PressedScale, state.scale) + assertTrue(state.showPressedScrim) + assertFalse(state.showFocusRing) + } + + @Test + fun draggedMatchesPressedPriority() { + val state = resolveCdsInteractionVisualState( + isPressed = false, + isDragged = true, + isHovered = true, + isFocused = true, + ) + assertEquals(CdsInteractionTokens.PressedContentAlpha, state.contentAlpha) + assertTrue(state.showPressedScrim) + assertFalse(state.showFocusRing) + } + + @Test + fun hoverAppliesWhenNotActive() { + val state = resolveCdsInteractionVisualState( + isPressed = false, + isDragged = false, + isHovered = true, + isFocused = false, + ) + assertEquals(CdsInteractionTokens.HoveredContentAlpha, state.contentAlpha) + assertEquals(1f, state.scale) + assertFalse(state.showPressedScrim) + } + + @Test + fun focusRingShownOnlyWhenNotActive() { + val state = resolveCdsInteractionVisualState( + isPressed = false, + isDragged = false, + isHovered = false, + isFocused = true, + ) + assertTrue(state.showFocusRing) + assertEquals(1f, state.contentAlpha) + } +} From 39e2b6ac2cb3fd1f87f84912a2e1150d06db69df Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Tue, 8 Sep 2026 17:56:26 -0500 Subject: [PATCH 2/7] Stop leftover docs and skills from starting Node CI. Route unmatched documentation to a Format Docs job so Android-only PRs are not blocked by Node format and bundle-stats. Co-authored-by: Cursor --- .claude/skills/cds-rn-to-compose/SKILL.md | 101 ++++++++++++---------- .github/workflows/node.yml | 4 + AGENTS.md | 3 +- docs/testing.md | 15 +++- packages/cds-android/docs/button.md | 14 +-- tools/ci/checkRootFormat.mjs | 75 ++++++++++++++++ 6 files changed, 155 insertions(+), 57 deletions(-) create mode 100644 tools/ci/checkRootFormat.mjs diff --git a/.claude/skills/cds-rn-to-compose/SKILL.md b/.claude/skills/cds-rn-to-compose/SKILL.md index 27b7b1926b..e3fbcccaf3 100644 --- a/.claude/skills/cds-rn-to-compose/SKILL.md +++ b/.claude/skills/cds-rn-to-compose/SKILL.md @@ -26,9 +26,9 @@ Port **product behavior and visual design** from `packages/mobile` into native C Determine what the user needs before writing code: -| Mode | Trigger | Output | -|------|---------|--------| -| **Port** | "Port X to Android", "implement X in cds-android" | Discovery notes → implementation → tests → gallery → docs | +| Mode | Trigger | Output | +| --------- | ------------------------------------------------------- | ------------------------------------------------------------- | +| **Port** | "Port X to Android", "implement X in cds-android" | Discovery notes → implementation → tests → gallery → docs | | **Audit** | "Review the Android port", "is X done?", "parity check" | Structured audit report using `references/audit-checklist.md` | Default to **Port** when building; switch to **Audit** when reviewing existing Kotlin without a clear build ask. @@ -45,16 +45,16 @@ Produce a short discovery artifact (markdown in the PR or a comment). Use `refer For component `` in `packages/mobile/src/.../.tsx`: -| Artifact | Path pattern | Why | -|----------|--------------|-----| -| Implementation | `packages/mobile/src/**/.tsx` | Props, behavior, composition | -| Base props / types | `packages/common/src/types/**` | Shared contract, deprecations | -| Tokens | `packages/common/src/tokens/.ts` | Values to hand-port | -| Stories | `packages/mobile/src/**/__stories__/.stories.tsx` | Variant matrix, edge cases | -| Tests | `packages/mobile/src/**/__tests__/.test.tsx` | Behavior worth preserving | -| Interactable | `packages/mobile/src/styles/getInteractableStyles.ts`, `system/Interactable.tsx`, `system/Pressable.tsx` | Press/hover/focus/disabled | -| iOS parity (optional) | `packages/cds-ios/Sources/Components/.swift` | Prior native decisions | -| Existing Android | `packages/cds-android/src/main/java/com/coinbase/cds/components/**` | POC or partial port | +| Artifact | Path pattern | Why | +| --------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------- | +| Implementation | `packages/mobile/src/**/.tsx` | Props, behavior, composition | +| Base props / types | `packages/common/src/types/**` | Shared contract, deprecations | +| Tokens | `packages/common/src/tokens/.ts` | Values to hand-port | +| Stories | `packages/mobile/src/**/__stories__/.stories.tsx` | Variant matrix, edge cases | +| Tests | `packages/mobile/src/**/__tests__/.test.tsx` | Behavior worth preserving | +| Interactable | `packages/mobile/src/styles/getInteractableStyles.ts`, `system/Interactable.tsx`, `system/Pressable.tsx` | Press/hover/focus/disabled | +| iOS parity (optional) | `packages/cds-ios/Sources/Components/.swift` | Prior native decisions | +| Existing Android | `packages/cds-android/src/main/java/com/coinbase/cds/components/**` | POC or partial port | Search deprecations: grep `@deprecated` on props/types in mobile and common. **Do not port deprecated props** — document what was skipped and why. @@ -112,12 +112,12 @@ Answer explicitly in the discovery artifact: CDS components ship opinionated visual defaults from tokens. Callers override layout and extension points without a parallel RN `style` / `styles` API: -| RN pattern | Compose pattern | -|------------|-----------------| +| RN pattern | Compose pattern | +| ---------------------- | ------------------------------------------------------------------------------------------------------- | | `style` / `styles.foo` | `modifier` for layout; optional parameters only where product requires (e.g. `transparent`, `maxLines`) | -| `block` / `fullWidth` | Document `Modifier.fillMaxWidth()` — no width prop | -| Theme colors | `CdsTheme.colors.*`, `CdsTheme.space.*`, etc. via `LocalCdsTheme` (see [Theming](#theming)) | -| Custom interactable | `CdsInteractionDefaults.indication()` / `indication(shape)` | +| `block` / `fullWidth` | Document `Modifier.fillMaxWidth()` — no width prop | +| Theme colors | `CdsTheme.colors.*`, `CdsTheme.space.*`, etc. via `LocalCdsTheme` (see [Theming](#theming)) | +| Custom interactable | `CdsInteractionDefaults.indication()` / `indication(shape)` | Extract pure resolvers (e.g. `resolveButtonColors()`) into testable functions in a `*Style.kt` file. **Do not use the alpha Compose Styles API** — keep a migration seam (`*Style.kt`, `CdsInteractionDefaults`) so Styles can replace internals later without public API churn. @@ -139,15 +139,15 @@ For every port, classify interactions before wiring modifiers. ### Step 1 — Inventory (from RN source) -| Kind | RN signals | Compose wiring | -|------|------------|----------------| -| Press / tap | `Pressable`, `onPress`, `onClick` | `Modifier.clickable` | -| Long press | `onLongPress` | `combinedClickable` | -| Toggle / select | `selected`, checkbox patterns | `toggleable` / `selectable` | -| Hover | `Interactable`, hover styles | `hoverable` (desktop/emulator) | -| Keyboard focus | focus styles, `accessible` | `focusable` + `CdsInteractionDefaults.indication` | -| Drag | `draggable`, gestures | `draggable` / gesture APIs | -| Scroll | `ScrollView` children | parent scroll; semantics for accessibility | +| Kind | RN signals | Compose wiring | +| --------------- | --------------------------------- | ------------------------------------------------- | +| Press / tap | `Pressable`, `onPress`, `onClick` | `Modifier.clickable` | +| Long press | `onLongPress` | `combinedClickable` | +| Toggle / select | `selected`, checkbox patterns | `toggleable` / `selectable` | +| Hover | `Interactable`, hover styles | `hoverable` (desktop/emulator) | +| Keyboard focus | focus styles, `accessible` | `focusable` + `CdsInteractionDefaults.indication` | +| Drag | `draggable`, gestures | `draggable` / gesture APIs | +| Scroll | `ScrollView` children | parent scroll; semantics for accessibility | ### Step 2 — Produce events for customers @@ -229,12 +229,12 @@ yarn nx run cds-android:build ### What to test (high value) -| Layer | Tool | Examples | -|-------|------|----------| -| Pure resolvers | JUnit | `resolveButtonColors`, `resolveCdsInteractionVisualState` priority | -| Composition & behavior | Robolectric + `createComposeRule()` | click invokes callback, disabled blocks click, semantics | +| Layer | Tool | Examples | +| ---------------------- | ---------------------------------------- | ---------------------------------------------------------------------- | +| Pure resolvers | JUnit | `resolveButtonColors`, `resolveCdsInteractionVisualState` priority | +| Composition & behavior | Robolectric + `createComposeRule()` | click invokes callback, disabled blocks click, semantics | | Interaction production | Robolectric + `MutableInteractionSource` | press emits `PressInteraction.Press`; add when component hoists source | -| Icon slots | Capture lambda args | tint Color and size Dp passed to slots | +| Icon slots | Capture lambda args | tint Color and size Dp passed to slots | ### What not to over-test @@ -285,14 +285,14 @@ Skip one-off component trivia that does not generalize (e.g. "Chip uses `radius4 ### What to update (by scope) -| Scope | Where | Example | -|-------|-------|---------| -| Single port gotcha, not yet validated | `references/learnings.md` | "Outlined variants need border token even when transparent" | -| Repeatable RN → Compose translation | `references/rn-to-compose-mapping.md` | New row in interaction or styling table | -| Audit criterion | `references/audit-checklist.md` | New checkbox after a recurring gap | -| Workflow step or rule | `SKILL.md` | New phase sub-step, verification item | -| Best reference implementation | [Example section](#example-button-reference-implementation) | Point to the newest high-quality port | -| Shared infrastructure | `packages/cds-android/docs/`, `AGENTS.md` | Interaction docs, public API list | +| Scope | Where | Example | +| ------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- | +| Single port gotcha, not yet validated | `references/learnings.md` | "Outlined variants need border token even when transparent" | +| Repeatable RN → Compose translation | `references/rn-to-compose-mapping.md` | New row in interaction or styling table | +| Audit criterion | `references/audit-checklist.md` | New checkbox after a recurring gap | +| Workflow step or rule | `SKILL.md` | New phase sub-step, verification item | +| Best reference implementation | [Example section](#example-button-reference-implementation) | Point to the newest high-quality port | +| Shared infrastructure | `packages/cds-android/docs/`, `AGENTS.md` | Interaction docs, public API list | **Promotion path:** log in `learnings.md` first → after the pattern appears in **two or more** ports (or one port + one audit), promote it into `SKILL.md` or a reference file → trim the learning entry to point at the promoted location. @@ -326,24 +326,31 @@ When reviewing an existing port, read the RN discovery sources again and walk `r # Android port audit ## Summary + [Pass / gaps / recommendations] ## API parity + | RN prop | Android | Status | Notes | ## Deprecations correctly omitted + ... ## Interactions + ... ## Tokens & visuals + ... ## Tests + ... ## Docs & demo + ... ``` @@ -374,13 +381,13 @@ Before marking a port complete: ## Reference files -| File | When to read | -|------|----------------| -| `references/learnings.md` | **Every port/audit** — accumulated gotchas; append after each port | -| `references/discovery-template.md` | Starting a new port | -| `references/rn-to-compose-mapping.md` | Translating RN concepts | -| `references/audit-checklist.md` | Auditing a port | -| `references/compose-docs.md` | Official Android doc links | +| File | When to read | +| ------------------------------------- | ------------------------------------------------------------------ | +| `references/learnings.md` | **Every port/audit** — accumulated gotchas; append after each port | +| `references/discovery-template.md` | Starting a new port | +| `references/rn-to-compose-mapping.md` | Translating RN concepts | +| `references/audit-checklist.md` | Auditing a port | +| `references/compose-docs.md` | Official Android doc links | ## Example: Button (reference implementation) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 4e3ae84e1b..c642a227dd 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -87,6 +87,10 @@ jobs: fetch-depth: 100 # TODO: This needs to include the merge-base - uses: ./.github/actions/setup-node-ci - name: Format + # Leave this as workspace `nx format:check` on the git diff. It can Prettier + # files outside Node packages when those files happen to be in the same PR + # (docs, native markdown, skills). That extra work is fine; the Format Docs + # job exists so those leftover paths can be checked *without* starting Node CI. run: yarn nx format:check --verbose --base=$NX_BASE --head=$NX_HEAD test: diff --git a/AGENTS.md b/AGENTS.md index a3793169f2..75d782cddb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,8 @@ After writing code, validate the smallest relevant scope: `yarn nx format:write` - Gradle: run the changed project's `test` and `build` targets; Prettier does not format Kotlin - Xcode: run `cds-ios:test` and the changed project's `build` target; Prettier does not format Swift -- Documentation only: run `yarn nx format:write` and verify changed commands and links +- Documentation only: run `yarn nx format:write` and verify changed commands and links. + Markdown and skill files are formatted by a dedicated CI job, not Node. See [`docs/testing.md`](docs/testing.md) for exact commands. diff --git a/docs/testing.md b/docs/testing.md index a5cf63bb82..94fd5b0c5d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -64,5 +64,16 @@ release validation. Prettier does not format Swift; follow Xcode's formatter. ## Documentation-only changes -Run `yarn nx format:write`. If commands, links, or setup steps changed, verify them against the -relevant project configuration or package-local guide. +Run `yarn nx format:write`. CI checks leftover documentation (root guides, `docs/`, skills, and +markdown outside Node packages) in a dedicated Format Docs job, independent of Node, Gradle, and +Xcode. If commands, links, or setup steps changed, verify them against the relevant project +configuration or package-local guide. + +The CI-equivalent root format check is: + +```sh +node tools/ci/checkRootFormat.mjs +``` + +That command needs `NX_BASE` / `NX_HEAD` or `BASE_SHA` / `HEAD_SHA`, matching CI. For a local +handoff, `yarn nx format:write` is enough. diff --git a/packages/cds-android/docs/button.md b/packages/cds-android/docs/button.md index 6641dcbf43..ca5cccb389 100644 --- a/packages/cds-android/docs/button.md +++ b/packages/cds-android/docs/button.md @@ -19,13 +19,13 @@ radius, and typography from the ambient theme automatically. The Android API is intentionally narrower than React Native in a few places: -| Mobile (RN) | Android CDS | Notes | -| ----------- | ------------- | ----- | -| `children` (any React node) | `text: String` | Label text only; matches the iOS native API shape | -| `start` / `end` slots | `startIcon` / `endIcon` only | Generic node slots are not supported yet | -| `block` / `fullWidth` | `Modifier.fillMaxWidth()` | Layout is caller-controlled via [modifier] | -| `style` / color overrides | — | Re-theme via [cdsTheme](custom-themes.md) | -| Deprecated props (`compact`, `foregroundMuted`, …) | — | Omitted by design | +| Mobile (RN) | Android CDS | Notes | +| -------------------------------------------------- | ---------------------------- | ------------------------------------------------- | +| `children` (any React node) | `text: String` | Label text only; matches the iOS native API shape | +| `start` / `end` slots | `startIcon` / `endIcon` only | Generic node slots are not supported yet | +| `block` / `fullWidth` | `Modifier.fillMaxWidth()` | Layout is caller-controlled via [modifier] | +| `style` / color overrides | — | Re-theme via [cdsTheme](custom-themes.md) | +| Deprecated props (`compact`, `foregroundMuted`, …) | — | Omitted by design | ## Variants and sizes diff --git a/tools/ci/checkRootFormat.mjs b/tools/ci/checkRootFormat.mjs new file mode 100644 index 0000000000..cc69b8b814 --- /dev/null +++ b/tools/ci/checkRootFormat.mjs @@ -0,0 +1,75 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +import { selectRootFormatFiles } from './toolchains.mjs'; +import { readWorkspaceProjects, workspaceRoot } from './workspaceProjects.mjs'; + +// Root/leftover docs only (skills, docs/, AGENTS.md, native-package markdown). Node package +// format stays on `nx format:check` in the Node workflow. Uses the workspace Prettier config. + +const PRETTIER_BATCH_SIZE = 200; +const prettierCli = path.join(workspaceRoot, 'node_modules/prettier/bin/prettier.cjs'); + +function getChangedFiles() { + const { + BASE_SHA: baseSha, + GITHUB_EVENT_NAME: eventName, + NX_BASE: nxBase, + NX_HEAD: nxHead, + HEAD_SHA: headSha, + } = process.env; + const base = nxBase || baseSha; + const head = nxHead || headSha || 'HEAD'; + + if (!base) { + console.error('NX_BASE or BASE_SHA is required to determine changed files.'); + process.exit(1); + } + + const separator = eventName === 'pull_request' ? '...' : '..'; + const result = spawnSync( + 'git', + ['diff', '--name-only', '--diff-filter=ACMR', `${base}${separator}${head}`], + { encoding: 'utf8' }, + ); + + if (result.status !== 0) { + console.error(result.stderr || 'Unable to list changed files for format check.'); + process.exit(result.status ?? 1); + } + + return result.stdout.split('\n').filter(Boolean); +} + +function runPrettierCheck(files) { + for (let index = 0; index < files.length; index += PRETTIER_BATCH_SIZE) { + const batch = files.slice(index, index + PRETTIER_BATCH_SIZE); + const result = spawnSync( + process.execPath, + [prettierCli, '--check', '--ignore-unknown', ...batch], + { + cwd: workspaceRoot, + encoding: 'utf8', + stdio: 'inherit', + }, + ); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } +} + +const changedFiles = getChangedFiles(); +const projects = await readWorkspaceProjects(); +const files = selectRootFormatFiles(changedFiles, projects); + +if (files.length === 0) { + console.log('No root-level documentation files to format-check.'); + process.exit(0); +} + +console.log( + `Root format check: ${files.length} file(s)\n${files.map((file) => `- ${file}`).join('\n')}`, +); +runPrettierCheck(files); From b577363d94cb0092440be7eaeb9d844044c0538d Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Wed, 9 Sep 2026 09:19:49 -0500 Subject: [PATCH 3/7] Move format to a workspace-level Prettier job. Run tools:format from root CI on JS/TS/JSON/Markdown so docs and skills are checked without starting Node CI, and drop format from the Node workflow. Co-authored-by: Cursor --- .github/workflows/ci.yml | 13 +++++++ .github/workflows/node.yml | 20 ---------- .prettierignore | 7 ++-- AGENTS.md | 7 ++-- docs/ci.md | 22 +++++++---- docs/nx.md | 2 +- docs/testing.md | 16 +++----- prettier.config.js | 2 + tools/ci/checkRootFormat.mjs | 75 ------------------------------------ tools/ci/toolchains.mjs | 5 ++- tools/project.json | 23 +++++++++++ 11 files changed, 68 insertions(+), 124 deletions(-) delete mode 100644 tools/ci/checkRootFormat.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbece97fdd..e2bfd9ac8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,19 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: node tools/ci/classifyToolchains.mjs + format: + name: Format + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: ./.github/actions/setup-node-ci + - name: Format + run: yarn nx run tools:format:check + # Always call so required checks report; jobs skip when unaffected. node: name: Node diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index c642a227dd..00727e2fca 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -73,26 +73,6 @@ jobs: # paid down. run: yarn nx affected --exclude='*,!tag:toolchain:node' --target=lint --base=$NX_BASE --head=$NX_HEAD --max-warnings=300 - format: - name: Format - if: ${{ inputs.affected == true }} - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: audit - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 100 # TODO: This needs to include the merge-base - - uses: ./.github/actions/setup-node-ci - - name: Format - # Leave this as workspace `nx format:check` on the git diff. It can Prettier - # files outside Node packages when those files happen to be in the same PR - # (docs, native markdown, skills). That extra work is fine; the Format Docs - # job exists so those leftover paths can be checked *without* starting Node CI. - run: yarn nx format:check --verbose --base=$NX_BASE --head=$NX_HEAD - test: name: Test if: ${{ inputs.affected == true }} diff --git a/.prettierignore b/.prettierignore index fe93e19037..9e05637d04 100644 --- a/.prettierignore +++ b/.prettierignore @@ -29,10 +29,9 @@ CLAUDE.md .claude/skills/*/references/ .agents/skills/*/references/ -# Native Android (Kotlin/Gradle). Prettier has no parser for these, and Kotlin formatting is -# owned by Android Studio's formatter until a Compose-aware linter is picked (see -# packages/cds-android/README.md). Markdown under android/ is deliberately not excluded, so the -# Gradle root's docs stay formatted like every other README. +# Native Android (Kotlin/Gradle). Workspace format only passes JS/TS/JSON/Markdown to Prettier +# (`tools:format`); these ignores remain so a manual whole-tree Prettier run cannot rewrite +# Kotlin. Markdown under android/ is still formatted. *.kt *.kts **/*.gradle* diff --git a/AGENTS.md b/AGENTS.md index 75d782cddb..9735644599 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,11 +40,10 @@ work. After writing code, validate the smallest relevant scope: - Node: run tests for the modified files, typecheck and lint the modified projects, then run - `yarn nx format:write` + `yarn nx run tools:format` - Gradle: run the changed project's `test` and `build` targets; Prettier does not format Kotlin - Xcode: run `cds-ios:test` and the changed project's `build` target; Prettier does not format Swift -- Documentation only: run `yarn nx format:write` and verify changed commands and links. - Markdown and skill files are formatted by a dedicated CI job, not Node. +- Documentation only: run `yarn nx run tools:format` and verify changed commands and links See [`docs/testing.md`](docs/testing.md) for exact commands. @@ -57,7 +56,7 @@ See [`docs/testing.md`](docs/testing.md) for exact commands. - `yarn nx run :build` - Build any project through its assigned toolchain - `yarn nx run :test` - Run tests for a specific project - `yarn nx run :test --testNamePattern=` - Run tests matching pattern -- `yarn nx format:write` - Formats all files in the workspace with Prettier +- `yarn nx run tools:format` - Formats JS, JSX, TypeScript, JSON, and Markdown with Prettier - `yarn nx run :lint` - Lint a specific project - `yarn nx run :typecheck` - Check for type errors in a specific project - `yarn nx run-many --target=,` - Run targets for all projects diff --git a/docs/ci.md b/docs/ci.md index 65c2b8c218..ce16393d4f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -2,7 +2,8 @@ [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) is the pull-request orchestrator. It classifies changed paths by toolchain, then always starts the Node, Android, and iOS lanes so -required checks are reported. Jobs inside each lane skip when that toolchain is unaffected. +required checks are reported. Jobs inside each lane skip when that toolchain is unaffected. Format +is a separate workspace-level job that always runs. ## Toolchain tags @@ -21,8 +22,8 @@ The orchestrator determines whether Node, Gradle, or Xcode paths changed: - The [Node workflow](../.github/workflows/node.yml) is always called so required `Node / *` checks are reported. When Node paths changed, its Linux jobs use `nx affected` plus - `toolchain:node`. Format (`yarn nx format:check`) runs in that workflow when Node is selected. - When Node is unaffected, each Node job is skipped; GitHub treats skipped jobs as passing. + `toolchain:node`. When Node is unaffected, each Node job is skipped; GitHub treats skipped jobs + as passing. - The [Android workflow](../.github/workflows/android.yml) is always called so required `Android / *` checks are reported. When Gradle paths changed, it builds and tests the Android library and demo app with JDK 21 and the Android SDK. When they did not, its jobs skip. @@ -30,16 +31,21 @@ The orchestrator determines whether Node, Gradle, or Xcode paths changed: are reported. When Xcode paths changed, it builds and tests the Swift library and builds the gallery on macOS. When they did not, its jobs skip. +**Format** is not a language toolchain. It always runs from `ci.yml` via +`yarn nx run tools:format:check`. That Nx target is the monorepo format entry point: Prettier on +JS, JSX, TypeScript, JSON, and Markdown today. Additional language formatters should be chained on +the same target rather than added to Node, Gradle, or Xcode workflows. + This keeps toolchains isolated while preserving dependency-aware validation: - A web-only change does not run React Native, Android, or iOS work (those jobs skip). - A change to a shared Node dependency may affect multiple dependent Node projects, such as both web and React Native packages. -- An Android-only change runs the Gradle lane; Node and iOS jobs skip. -- An iOS-only change runs the Xcode lane; Node and Android jobs skip. +- An Android-only change runs the Gradle lane; Node and iOS jobs skip. Format still runs. +- An iOS-only change runs the Xcode lane; Node and Android jobs skip. Format still runs. - Changes under `docs/`, `.claude/`, `.agents/`, `skills/`, and root-level markdown that do not - belong to a Node project do not start Node, Gradle, or Xcode on their own. + belong to a Node project do not start Node, Gradle, or Xcode on their own. Format still runs. - Changes to centralized CI or Nx classification files safely enable all toolchains. -Manually dispatching `ci.yml` enables every toolchain. The reusable Android and iOS workflows can -also be dispatched independently for toolchain-specific reruns. +Manually dispatching `ci.yml` enables every toolchain and Format. The reusable Android and iOS +workflows can also be dispatched independently for toolchain-specific reruns. diff --git a/docs/nx.md b/docs/nx.md index 11a41b1143..e310136525 100644 --- a/docs/nx.md +++ b/docs/nx.md @@ -16,7 +16,7 @@ Nx 20 cannot use the tags to select `targetDefaults`, but CI already depends on 1. The change classifier maps a changed project root to its `toolchain:*` tag and decides which of the Node, Gradle, or Xcode workflows should execute work. Every lane is still invoked so required checks report; unaffected lanes skip their jobs. Documentation and skill files do - not start a language toolchain. See [`docs/ci.md`](ci.md). + not start a language toolchain; workspace Format still runs. See [`docs/ci.md`](ci.md). 2. The Node workflow positively filters `nx affected` to `toolchain:node`, preventing native `build` and `test` targets from running on Node-only Linux jobs. 3. The validator rejects missing or conflicting tags so a new project cannot silently enter the diff --git a/docs/testing.md b/docs/testing.md index 94fd5b0c5d..a8824deec7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -22,7 +22,7 @@ When iterating, narrow Jest tests with `--testNamePattern=`. Before han format the workspace: ```sh -yarn nx format:write +yarn nx run tools:format ``` To validate all affected Node projects locally, the CI-equivalent pattern is: @@ -64,16 +64,12 @@ release validation. Prettier does not format Swift; follow Xcode's formatter. ## Documentation-only changes -Run `yarn nx format:write`. CI checks leftover documentation (root guides, `docs/`, skills, and -markdown outside Node packages) in a dedicated Format Docs job, independent of Node, Gradle, and -Xcode. If commands, links, or setup steps changed, verify them against the relevant project -configuration or package-local guide. +Run `yarn nx run tools:format`. CI runs the same workspace format check on every PR, independent of +Node, Gradle, and Xcode. If commands, links, or setup steps changed, verify them against the +relevant project configuration or package-local guide. -The CI-equivalent root format check is: +The CI-equivalent format check is: ```sh -node tools/ci/checkRootFormat.mjs +yarn nx run tools:format:check ``` - -That command needs `NX_BASE` / `NX_HEAD` or `BASE_SHA` / `HEAD_SHA`, matching CI. For a local -handoff, `yarn nx format:write` is enough. diff --git a/prettier.config.js b/prettier.config.js index 9c8b8f963c..2744089988 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -1,4 +1,6 @@ module.exports = { + // Workspace format only passes JS, JSX, TypeScript, JSON, and Markdown to Prettier + // (`tools:format`). Chain other formatters on that Nx target when they are added. arrowParens: 'always', bracketSameLine: false, jsxSingleQuote: false, diff --git a/tools/ci/checkRootFormat.mjs b/tools/ci/checkRootFormat.mjs deleted file mode 100644 index cc69b8b814..0000000000 --- a/tools/ci/checkRootFormat.mjs +++ /dev/null @@ -1,75 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -import { selectRootFormatFiles } from './toolchains.mjs'; -import { readWorkspaceProjects, workspaceRoot } from './workspaceProjects.mjs'; - -// Root/leftover docs only (skills, docs/, AGENTS.md, native-package markdown). Node package -// format stays on `nx format:check` in the Node workflow. Uses the workspace Prettier config. - -const PRETTIER_BATCH_SIZE = 200; -const prettierCli = path.join(workspaceRoot, 'node_modules/prettier/bin/prettier.cjs'); - -function getChangedFiles() { - const { - BASE_SHA: baseSha, - GITHUB_EVENT_NAME: eventName, - NX_BASE: nxBase, - NX_HEAD: nxHead, - HEAD_SHA: headSha, - } = process.env; - const base = nxBase || baseSha; - const head = nxHead || headSha || 'HEAD'; - - if (!base) { - console.error('NX_BASE or BASE_SHA is required to determine changed files.'); - process.exit(1); - } - - const separator = eventName === 'pull_request' ? '...' : '..'; - const result = spawnSync( - 'git', - ['diff', '--name-only', '--diff-filter=ACMR', `${base}${separator}${head}`], - { encoding: 'utf8' }, - ); - - if (result.status !== 0) { - console.error(result.stderr || 'Unable to list changed files for format check.'); - process.exit(result.status ?? 1); - } - - return result.stdout.split('\n').filter(Boolean); -} - -function runPrettierCheck(files) { - for (let index = 0; index < files.length; index += PRETTIER_BATCH_SIZE) { - const batch = files.slice(index, index + PRETTIER_BATCH_SIZE); - const result = spawnSync( - process.execPath, - [prettierCli, '--check', '--ignore-unknown', ...batch], - { - cwd: workspaceRoot, - encoding: 'utf8', - stdio: 'inherit', - }, - ); - - if (result.status !== 0) { - process.exit(result.status ?? 1); - } - } -} - -const changedFiles = getChangedFiles(); -const projects = await readWorkspaceProjects(); -const files = selectRootFormatFiles(changedFiles, projects); - -if (files.length === 0) { - console.log('No root-level documentation files to format-check.'); - process.exit(0); -} - -console.log( - `Root format check: ${files.length} file(s)\n${files.map((file) => `- ${file}`).join('\n')}`, -); -runPrettierCheck(files); diff --git a/tools/ci/toolchains.mjs b/tools/ci/toolchains.mjs index 4f0a4abbcb..45cc5738a1 100644 --- a/tools/ci/toolchains.mjs +++ b/tools/ci/toolchains.mjs @@ -17,7 +17,8 @@ const gradlePathPrefixes = [ const xcodePathPrefixes = ['.github/workflows/ios.yml', 'ios/']; -// Language-agnostic trees. These paths must not start Node, Gradle, or Xcode on their own. +// Language-agnostic trees. Format still runs at the workspace root; these paths +// must not start Node, Gradle, or Xcode on their own. const docsOnlyPathPrefixes = ['docs/', '.claude/', '.agents/', 'skills/']; const docsMarkdownExtensions = ['.md', '.mdx']; @@ -80,7 +81,7 @@ export function classifyToolchains(changedFiles, projects = []) { } else if (projectToolchain) { result[projectToolchain] = true; } else if (isDocsOnlyPath(file) || isUnmatchedMarkdown(file)) { - // Not a language toolchain. + // Workspace format covers these; they are not a language toolchain. } else { result.node = true; } diff --git a/tools/project.json b/tools/project.json index ffdea16cfa..54686fa17f 100644 --- a/tools/project.json +++ b/tools/project.json @@ -7,6 +7,29 @@ "toolchain:node" ], "targets": { + "format": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "yarn prettier --write --cache \"**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json,md,mdx}\"" + ], + "parallel": false, + "cwd": "." + }, + "configurations": { + "check": { + "commands": [ + "yarn prettier --check \"**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json,md,mdx}\"" + ] + } + }, + "inputs": [ + "{workspaceRoot}/**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json,md,mdx}", + "{workspaceRoot}/prettier.config.js", + "{workspaceRoot}/.prettierignore" + ], + "cache": true + }, "test": { "executor": "@nx/jest:jest", "options": { From e5a312e01582e075a5ad69ff8de428ef23c440a5 Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Wed, 9 Sep 2026 15:22:23 -0400 Subject: [PATCH 4/7] Split gallery apps into routed screens and document Maestro test IDs. Move component demos off monolithic home scrolls to prepare for visreg and Maestro flows, and extend the RN-to-Compose skill with testTag guidance. Co-authored-by: Cursor --- .claude/skills/cds-rn-to-compose/SKILL.md | 28 ++- .../references/audit-checklist.md | 11 + .../references/discovery-template.md | 11 + .../cds-rn-to-compose/references/learnings.md | 18 ++ .../references/ui-testing.md | 101 +++++++++ .../coinbase/cds/androidapp/MainActivity.kt | 195 +++++++----------- .../androidapp/gallery/ButtonGalleryScreen.kt | 26 +++ .../gallery/ButtonGallerySection.kt | 32 +-- .../gallery/ComponentGalleryScreen.kt | 20 ++ .../androidapp/gallery/GalleryComponents.kt | 29 ++- .../androidapp/gallery/GalleryDestination.kt | 22 ++ .../androidapp/gallery/GalleryNavigation.kt | 195 ++++++++++++++++++ .../cds/androidapp/gallery/GalleryRoute.kt | 9 + .../androidapp/gallery/HomeGalleryScreen.kt | 107 ++++++++++ .../Sources/ButtonGalleryView.swift | 35 ++++ .../Sources/ComponentGalleryView.swift | 22 ++ .../Sources/GalleryDestination.swift | 49 +++++ .../Sources/GalleryNavigation.swift | 152 ++++++++++++++ .../ios-gallery/Sources/HomeGalleryView.swift | 69 +++++++ ...swift => OtherComponentsGalleryView.swift} | 59 +++--- .../ios-gallery/Sources/RootGalleryView.swift | 70 +++---- .../Sources/ThemeTokensGalleryView.swift | 26 +++ packages/cds-android/docs/button.md | 33 ++- .../coinbase/cds/components/button/Button.kt | 7 +- .../cds/components/button/ButtonTest.kt | 16 ++ 25 files changed, 1123 insertions(+), 219 deletions(-) create mode 100644 .claude/skills/cds-rn-to-compose/references/ui-testing.md create mode 100644 apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGalleryScreen.kt create mode 100644 apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ComponentGalleryScreen.kt create mode 100644 apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryDestination.kt create mode 100644 apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryNavigation.kt create mode 100644 apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryRoute.kt create mode 100644 apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/HomeGalleryScreen.kt create mode 100644 apps/ios-gallery/Sources/ButtonGalleryView.swift create mode 100644 apps/ios-gallery/Sources/ComponentGalleryView.swift create mode 100644 apps/ios-gallery/Sources/GalleryDestination.swift create mode 100644 apps/ios-gallery/Sources/GalleryNavigation.swift create mode 100644 apps/ios-gallery/Sources/HomeGalleryView.swift rename apps/ios-gallery/Sources/{ComponentsGallery.swift => OtherComponentsGalleryView.swift} (51%) create mode 100644 apps/ios-gallery/Sources/ThemeTokensGalleryView.swift diff --git a/.claude/skills/cds-rn-to-compose/SKILL.md b/.claude/skills/cds-rn-to-compose/SKILL.md index e3fbcccaf3..88ef25f3d1 100644 --- a/.claude/skills/cds-rn-to-compose/SKILL.md +++ b/.claude/skills/cds-rn-to-compose/SKILL.md @@ -69,9 +69,10 @@ Answer explicitly in the discovery artifact: 5. **Interactions** — Press, long-press, hover, focus, drag, toggle? See [Interaction analysis](#interaction-analysis) 6. **States** — enabled, loading, selected, error, transparent overlays? 7. **Accessibility** — roles, content descriptions, loading/disabled semantics, live regions -8. **Tokens** — Map each visual value to `CdsTheme.*`; resolve via `LocalCdsTheme` / `CdsThemeProvider`, not props. Note gaps vs `@coinbase/cds-common` -9. **Out of scope** — `useComponentConfig`, haptics, debounce, `wrapperStyles`, RN-only style props -10. **Reuse** — Can `CdsInteractionDefaults`, internal `Text`, or another CDS component be composed? +8. **Test IDs** — RN `testID` on root → caller `Modifier.testTag`; Maestro needs `testTagsAsResourceId` at app root. See `references/ui-testing.md` +9. **Tokens** — Map each visual value to `CdsTheme.*`; resolve via `LocalCdsTheme` / `CdsThemeProvider`, not props. Note gaps vs `@coinbase/cds-common` +10. **Out of scope** — `useComponentConfig`, haptics, debounce, `wrapperStyles`, RN-only style props +11. **Reuse** — Can `CdsInteractionDefaults`, internal `Text`, or another CDS component be composed? --- @@ -212,6 +213,21 @@ Follow [Compose accessibility](https://developer.android.com/develop/ui/compose/ - Loading: indeterminate progress + retain text label in semantics - Merge descendants only when it improves screen reader experience +### Test IDs (Compose UI Test + Maestro) + +RN `testID` maps to **`modifier = Modifier.testTag("…")`** on the component root — do not add a separate `testID` parameter. Full rules: `references/ui-testing.md`. + +| Tool | How callers tag | How tests select | +| ---- | --------------- | ---------------- | +| Robolectric / `createComposeRule` | `modifier.testTag("confirm")` | `onNodeWithTag("confirm")` | +| Maestro (black-box) | same `testTag` on composables | `tapOn: { id: "confirm" }` **after** app enables `Modifier.semantics { testTagsAsResourceId = true }` once near the root | + +**Maestro selector priority** ([Jetpack Compose guide](https://docs.maestro.dev/get-started/supported-platform/android/jetpack)): prefer visible **text**, then **accessibility description** (`contentDescription`), then **`id`** (`testTag`) for duplicates, lists, or visreg anchors. + +- Apply caller `modifier` on the **outermost interactive node** so tags share semantics with `role` and gestures. +- `semantics(mergeDescendants = true)` hides nested `testTag`s — do not rely on child tags inside merged subtrees. +- Tag gallery navigation and one instance per representative state (`gallery-button-loading`, etc.) for future visreg/Maestro flows; keep library components free of app-specific ids. + ### Internal composition Prefer existing internal CDS pieces (`Text`, etc.) over duplicating typography. Keep internal components `internal` until promoted deliberately. @@ -233,6 +249,7 @@ yarn nx run cds-android:build | ---------------------- | ---------------------------------------- | ---------------------------------------------------------------------- | | Pure resolvers | JUnit | `resolveButtonColors`, `resolveCdsInteractionVisualState` priority | | Composition & behavior | Robolectric + `createComposeRule()` | click invokes callback, disabled blocks click, semantics | +| Test tags | Robolectric + `onNodeWithTag` | caller `Modifier.testTag` on root is queryable | | Interaction production | Robolectric + `MutableInteractionSource` | press emits `PressInteraction.Press`; add when component hoists source | | Icon slots | Capture lambda args | tint Color and size Dp passed to slots | @@ -253,6 +270,9 @@ Add or extend a gallery section in `apps/android-app`: - All variants, sizes, states (disabled, loading, transparent) - Icon slots, full width via `Modifier.fillMaxWidth()`, truncation - Interactive demo (click counter) where relevant +- Stable `Modifier.testTag` on navigation chrome and representative demo instances (`gallery-*`) for visreg and Maestro — see `references/ui-testing.md` + +Ensure `apps/android-app` enables `testTagsAsResourceId` at the activity root so Maestro can use `id:` selectors. The demo app is a **consumer** — if it needs a non-public API, fix the API design instead of widening visibility. @@ -367,6 +387,7 @@ Before marking a port complete: - [ ] All visuals from `CdsTheme` tokens via `LocalCdsTheme` (no hard-coded design values; no theme props) - [ ] Interactions classified; gesture modifiers match; `interactionSource` hoisted if observable - [ ] Accessibility: roles, disabled, loading semantics +- [ ] Test IDs: RN `testID` documented as `Modifier.testTag`; Robolectric `onNodeWithTag` test; gallery tags for Maestro/visreg - [ ] Pure style resolvers unit-tested - [ ] Robolectric behavior tests for callbacks and semantics - [ ] Interaction event tests when component hoists `MutableInteractionSource` @@ -388,6 +409,7 @@ Before marking a port complete: | `references/rn-to-compose-mapping.md` | Translating RN concepts | | `references/audit-checklist.md` | Auditing a port | | `references/compose-docs.md` | Official Android doc links | +| `references/ui-testing.md` | Maestro + Compose UI Test tags, selector priority, gallery naming | ## Example: Button (reference implementation) diff --git a/.claude/skills/cds-rn-to-compose/references/audit-checklist.md b/.claude/skills/cds-rn-to-compose/references/audit-checklist.md index b8e1b945c0..951921f04f 100644 --- a/.claude/skills/cds-rn-to-compose/references/audit-checklist.md +++ b/.claude/skills/cds-rn-to-compose/references/audit-checklist.md @@ -88,10 +88,21 @@ Reference: https://developer.android.com/develop/ui/compose/accessibility - [ ] `yarn nx run cds-android:test` passes - [ ] Pure resolver tests for colors/metrics/state priority - [ ] Robolectric + Compose UI tests for behavior (click, disabled, semantics) +- [ ] Caller `Modifier.testTag` queryable via `onNodeWithTag` (RN `testID` parity) - [ ] Interaction event tests when `MutableInteractionSource` is hoisted - [ ] Tests focus on regressions, not exhaustive variant grids - [ ] No tests that only assert framework defaults +Reference: `references/ui-testing.md`, [Maestro Jetpack Compose](https://docs.maestro.dev/get-started/supported-platform/android/jetpack) + +## 8b. UI testing hooks + +- [ ] No dedicated `testID` prop — tags via `modifier.testTag` on root +- [ ] Maestro selector priority documented: text → description → id +- [ ] `apps/android-app` enables `testTagsAsResourceId` at activity root +- [ ] Gallery uses stable `gallery-*` tags on navigation and representative states +- [ ] `mergeDescendants` does not block intended tag placement + **Anti-patterns:** - Only headless theme tests, no component behavior tests diff --git a/.claude/skills/cds-rn-to-compose/references/discovery-template.md b/.claude/skills/cds-rn-to-compose/references/discovery-template.md index ac16552264..fc14b991b8 100644 --- a/.claude/skills/cds-rn-to-compose/references/discovery-template.md +++ b/.claude/skills/cds-rn-to-compose/references/discovery-template.md @@ -78,6 +78,17 @@ fun ComponentName( | disabled | | block input | `disabled()` | | loading | | block input | progress + label | +## Test IDs + +| RN | Android CDS | Notes | +|----|-------------|-------| +| `testID` on root | `modifier = Modifier.testTag("…")` | No dedicated prop | +| Maestro `id:` | same tag + app `testTagsAsResourceId` | See `references/ui-testing.md` | + +**Maestro selector plan:** text match / `description` / `id` for each critical flow? + +**Gallery tags:** stable `gallery-*` ids for visreg anchors? + ## Token mapping | Visual property | cds-common token | CdsTheme accessor | Delivery | diff --git a/.claude/skills/cds-rn-to-compose/references/learnings.md b/.claude/skills/cds-rn-to-compose/references/learnings.md index a3457b9d61..8293f93269 100644 --- a/.claude/skills/cds-rn-to-compose/references/learnings.md +++ b/.claude/skills/cds-rn-to-compose/references/learnings.md @@ -18,6 +18,24 @@ Append-only record of lessons from CDS RN → Compose ports. Read this at the st --- +## 2026-09-09 — UI testing (Maestro + testTag) + +**Context:** Gallery navigation refactor + request to document test-id practices for Maestro and unit tests. + +**Learnings:** + +- RN `testID` → caller `Modifier.testTag("…")` on the component root; no dedicated CDS prop (matches internal `Text` pattern). +- Maestro is black-box: prefers visible text and `contentDescription`, then `id:` from `testTag` — see [Maestro Jetpack Compose](https://docs.maestro.dev/get-started/supported-platform/android/jetpack). +- Maestro requires `Modifier.semantics { testTagsAsResourceId = true }` once near the app root for `id:` selectors; enable in `apps/android-app` `MainActivity`. +- `semantics(mergeDescendants = true)` prevents querying child `testTag`s — tag the outer interactive node only. +- Gallery/demo screens own stable `gallery-*` tags; library components stay tag-free by default. + +**Skill update:** Added `references/ui-testing.md`; expanded `SKILL.md` Test IDs section, Phase 5/6, verification checklist, audit checklist. + +**Applies to:** All `packages/cds-android` ports, `apps/android-app` gallery. + +--- + ## 2026-09-08 — Theming via CompositionLocal **Context:** Skill review — ensure ports use the established CDS theme delivery mechanism, not RN-style theme props. diff --git a/.claude/skills/cds-rn-to-compose/references/ui-testing.md b/.claude/skills/cds-rn-to-compose/references/ui-testing.md new file mode 100644 index 0000000000..06493724a8 --- /dev/null +++ b/.claude/skills/cds-rn-to-compose/references/ui-testing.md @@ -0,0 +1,101 @@ +# UI testing: Compose UI Test and Maestro + +CDS Android components should be testable from **in-process Compose UI tests** (Robolectric / instrumentation) and from **black-box Maestro flows** without widening the public API with RN-style `testID` props. + +Official references: + +- [Maestro — Jetpack Compose](https://docs.maestro.dev/get-started/supported-platform/android/jetpack) — black-box selectors, semantics-first philosophy +- [Maestro — Core selectors](https://docs.maestro.dev/reference/selectors/core-selectors) — `id` requires `testTagsAsResourceId` for Compose +- [Compose testing interoperability](https://developer.android.com/develop/ui/compose/testing/interoperability) — `testTagsAsResourceId` for UiAutomator / external tools +- [Compose semantics](https://developer.android.com/develop/ui/compose/accessibility) — `contentDescription`, roles, progress + +## Selector priority (Maestro) + +Maestro recommends matching **user-visible** properties first for refactoring resilience: + +| Priority | Maestro selector | Compose source | When to use | +| -------- | ---------------- | -------------- | ----------- | +| 1 | `tapOn: "Confirm"` | Visible label text (`BasicText`, `Button` label) | Unique, stable copy; preferred for buttons with text | +| 2 | `tapOn: { description: "…" }` | `contentDescription` / semantics label | Icon-only controls, loading state, custom a11y labels | +| 3 | `tapOn: { id: "confirm" }` | `Modifier.testTag("confirm")` + `testTagsAsResourceId` | Duplicate labels, lists, scroll targets, visreg anchors | + +Do **not** add a dedicated `testID` parameter on CDS composables. RN `testID` maps to caller-controlled `modifier = Modifier.testTag("…")` on the component root — same pattern as internal `Text`. + +## In-process tests (Robolectric / `createComposeRule`) + +```kotlin +Button( + text = "Confirm", + onClick = {}, + modifier = Modifier.testTag("confirm"), +) + +composeRule.onNodeWithTag("confirm").performClick() +``` + +Also assert via visible text and semantics when that is what customers and Maestro will use: + +```kotlin +composeRule.onNodeWithText("Confirm").performClick() +composeRule.onNodeWithContentDescription("Submit").assertIsNotEnabled() // loading +``` + +## Maestro (black-box) + +Maestro does **not** see `Modifier.testTag` unless the app enables resource-id mapping once near the root: + +```kotlin +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId + +CdsThemeProvider(theme = theme, colorScheme = colorScheme) { + Box( + modifier = Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, + ) { + // All nested Modifier.testTag values are selectable as id: in Maestro + } +} +``` + +`apps/android-app` enables this in `MainActivity` so gallery and component demos are Maestro-ready. + +Maestro flow example: + +```yaml +- tapOn: "Primary" # text match when unique +- tapOn: + id: gallery-button-primary-interactive +- tapOn: + description: "Submit" # contentDescription from Button label when loading +``` + +**Note:** IDs may not appear in Maestro Studio's inspector even when flows work — use `maestro hierarchy` to verify tags if Studio omits them. + +## Component author rules + +1. **Apply `modifier` on the outermost interactive node** so caller `testTag` lands on the same semantics node as `role`, `contentDescription`, and gestures. +2. **`semantics(mergeDescendants = true)`** merges child semantics into the parent — child `testTag`s are **not** independently queryable. Test icon slots by capturing slot lambda args, not nested tags. +3. **Preserve label text in semantics when loading** so text and `description` selectors keep working (`contentDescription = text` on Button). +4. **Do not bake app-specific tags into library components** — callers and gallery screens supply tags. +5. **Gallery / visreg anchors** — tag navigation chrome and one representative instance per state matrix row (interactive, disabled, loading) with stable `gallery-*` ids. + +### Naming conventions + +| Context | Pattern | Example | +| ------- | ------- | ------- | +| Gallery navigation | `gallery-` | `gallery-home`, `gallery-nav-back` | +| Gallery component screen | `gallery-component-` | `gallery-component-button` | +| Gallery demo instance | `gallery--` | `gallery-button-loading` | +| Customer screen | caller-defined, stable | `checkout-confirm` | + +Use lowercase kebab-case for multi-word tags. Avoid dynamic values (user ids, timestamps, prices). + +## Port checklist (testing) + +- [ ] RN `testID` mapped to documented `Modifier.testTag` usage (no new prop) +- [ ] Robolectric test uses `onNodeWithTag` when documenting tag contract +- [ ] Maestro-viable: unique text or `contentDescription` for primary flows; `testTag` for ambiguous cases +- [ ] Demo app root sets `testTagsAsResourceId = true` +- [ ] Gallery tags stable ids for visreg / Maestro entry points diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt index bb86510f39..ce5546be96 100644 --- a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt @@ -6,33 +6,26 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import com.coinbase.cds.androidapp.gallery.ButtonGallerySection +import com.coinbase.cds.androidapp.gallery.ButtonGalleryScreen import com.coinbase.cds.androidapp.gallery.CdsThemeGallery +import com.coinbase.cds.androidapp.gallery.ComponentGalleryScreen +import com.coinbase.cds.androidapp.gallery.GalleryRoute +import com.coinbase.cds.androidapp.gallery.HomeGalleryScreen import com.coinbase.cds.androidapp.theme.AcmeTheme -import com.coinbase.cds.components.button.Button -import com.coinbase.cds.components.button.ButtonVariant import com.coinbase.cds.theme.CdsColorScheme import com.coinbase.cds.theme.CdsDefaultTheme import com.coinbase.cds.theme.CdsTheme @@ -45,28 +38,30 @@ class MainActivity : ComponentActivity() { setContent { var darkTheme by remember { mutableStateOf(false) } var customBrand by remember { mutableStateOf(false) } - var showGallery by remember { mutableStateOf(false) } + var route by remember { mutableStateOf(GalleryRoute.Home) } - val theme: CdsTheme = if (customBrand) AcmeTheme else CdsDefaultTheme + val theme = if (customBrand) AcmeTheme else CdsDefaultTheme val colorScheme = if (darkTheme) CdsColorScheme.Dark else CdsColorScheme.Light - BackHandler(enabled = showGallery) { showGallery = false } + BackHandler(enabled = route != GalleryRoute.Home) { + route = GalleryRoute.Home + } CdsThemeProvider(theme = theme, colorScheme = colorScheme) { - if (showGallery) { - CdsThemeGallery( - theme = theme, - colorScheme = colorScheme, - modifier = Modifier.fillMaxSize(), - onBack = { showGallery = false }, - ) - } else { - CdsSampleScreen( + Box( + modifier = Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, + ) { + GalleryApp( + route = route, + onRouteChange = { route = it }, darkTheme = darkTheme, onToggleDarkTheme = { darkTheme = !darkTheme }, customBrand = customBrand, onToggleBrand = { customBrand = !customBrand }, - onShowGallery = { showGallery = true }, + theme = theme, + colorScheme = colorScheme, ) } } @@ -75,140 +70,87 @@ class MainActivity : ComponentActivity() { } @Composable -fun CdsSampleScreen( +private fun GalleryApp( + route: GalleryRoute, + onRouteChange: (GalleryRoute) -> Unit, darkTheme: Boolean, onToggleDarkTheme: () -> Unit, customBrand: Boolean, onToggleBrand: () -> Unit, - onShowGallery: () -> Unit, + theme: com.coinbase.cds.theme.CdsTheme, + colorScheme: CdsColorScheme, modifier: Modifier = Modifier, ) { - Box( - modifier = modifier - .fillMaxSize() - .background(CdsTheme.colors.bg) - .systemBarsPadding() - .padding(CdsTheme.space.x3), - ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), - ) { - SampleText( - text = "Coinbase Design System", - style = CdsTheme.typography.title1, - ) - SampleText( - text = "Jetpack Compose port of the default theme.", - style = CdsTheme.typography.body, - color = CdsTheme.colors.fgMuted, - ) + when (route) { + GalleryRoute.Home -> HomeGalleryScreen( + darkTheme = darkTheme, + onToggleDarkTheme = onToggleDarkTheme, + customBrand = customBrand, + onToggleBrand = onToggleBrand, + onOpenThemeTokens = { onRouteChange(GalleryRoute.ThemeTokens) }, + onOpenComponent = { onRouteChange(GalleryRoute.Component(it)) }, + modifier = modifier, + ) - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(CdsTheme.borderRadius.radius400)) - .background(CdsTheme.colors.bgSecondary) - .padding(CdsTheme.space.x2), - ) { - Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1_5)) { - SampleText( - text = "Primary action surface", - style = CdsTheme.typography.headline, - ) - SampleText( - text = "This card's colors, spacing, corner radius, and type all come " + - "from CdsTheme.", - style = CdsTheme.typography.body, - color = CdsTheme.colors.fgMuted, - ) - Button( - text = if (darkTheme) "Switch to light theme" else "Switch to dark theme", - onClick = onToggleDarkTheme, - modifier = Modifier.fillMaxWidth(), - ) - Button( - text = if (customBrand) { - "Switch to default CDS theme" - } else { - "Switch to Acme brand theme" - }, - onClick = onToggleBrand, - modifier = Modifier.fillMaxWidth(), - variant = ButtonVariant.Tertiary, - ) - SampleText( - text = "View theme gallery", - style = CdsTheme.typography.headline, - color = CdsTheme.colors.fgPrimary, - modifier = Modifier - .clickable(onClick = onShowGallery) - .padding(vertical = CdsTheme.space.x0_5), - ) - } - } + GalleryRoute.ThemeTokens -> CdsThemeGallery( + theme = theme, + colorScheme = colorScheme, + modifier = modifier + .fillMaxSize() + .testTag("gallery-destination-theme-tokens"), + onBack = { onRouteChange(GalleryRoute.Home) }, + ) - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(CdsTheme.borderRadius.radius400)) - .background(CdsTheme.colors.bgSecondary) - .padding(CdsTheme.space.x2), - ) { - ButtonGallerySection() - } - } + is GalleryRoute.Component -> ComponentGalleryScreen( + destination = route.destination, + onBack = { onRouteChange(GalleryRoute.Home) }, + onNavigateToComponent = { onRouteChange(GalleryRoute.Component(it)) }, + modifier = modifier, + ) } } -@Composable -private fun SampleText( - text: String, - style: TextStyle, - modifier: Modifier = Modifier, - color: Color = CdsTheme.colors.fg, -) { - BasicText(text = text, modifier = modifier, style = style.copy(color = color)) -} - @Preview(showBackground = true) @Composable -fun CdsSampleScreenLightPreview() { +fun HomeGalleryScreenLightPreview() { CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { - CdsSampleScreen( + HomeGalleryScreen( darkTheme = false, onToggleDarkTheme = {}, customBrand = false, onToggleBrand = {}, - onShowGallery = {}, + onOpenThemeTokens = {}, + onOpenComponent = {}, ) } } @Preview(showBackground = true) @Composable -fun CdsSampleScreenDarkPreview() { +fun HomeGalleryScreenDarkPreview() { CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Dark) { - CdsSampleScreen( + HomeGalleryScreen( darkTheme = true, onToggleDarkTheme = {}, customBrand = false, onToggleBrand = {}, - onShowGallery = {}, + onOpenThemeTokens = {}, + onOpenComponent = {}, ) } } @Preview(showBackground = true) @Composable -fun CdsSampleScreenAcmeBrandPreview() { +fun HomeGalleryScreenAcmeBrandPreview() { CdsThemeProvider(theme = AcmeTheme, colorScheme = CdsColorScheme.Light) { - CdsSampleScreen( + HomeGalleryScreen( darkTheme = false, onToggleDarkTheme = {}, customBrand = true, onToggleBrand = {}, - onShowGallery = {}, + onOpenThemeTokens = {}, + onOpenComponent = {}, ) } } @@ -219,6 +161,17 @@ fun CdsThemeGalleryPreview() { CdsThemeGallery(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) } +@Preview(showBackground = true, heightDp = 1200) +@Composable +fun ButtonGalleryScreenPreview() { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + ButtonGalleryScreen( + onBack = {}, + onNavigateToComponent = {}, + ) + } +} + @Preview(showBackground = true, heightDp = 1200) @Composable fun ButtonShowcasePreview() { diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGalleryScreen.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGalleryScreen.kt new file mode 100644 index 0000000000..2a9e5d9d8d --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGalleryScreen.kt @@ -0,0 +1,26 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal fun ButtonGalleryScreen( + onBack: () -> Unit, + onNavigateToComponent: (GalleryDestination) -> Unit, + modifier: Modifier = Modifier, +) { + val destination = GalleryDestination.Button + GalleryScreenScaffold( + title = destination.title, + subtitle = destination.subtitle, + onBack = onBack, + modifier = modifier.systemBarsPadding(), + testTag = destination.testTag, + previousDestination = destination.previous(), + nextDestination = destination.next(), + onNavigateToComponent = onNavigateToComponent, + ) { + ButtonGallerySection() + } +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt index 5c1243a522..581234006e 100644 --- a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap @@ -32,7 +33,6 @@ fun ButtonGallerySection(modifier: Modifier = Modifier) { modifier = modifier, verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), ) { - GallerySectionTitle(text = "Button") GalleryText( text = "Clicks on the interactive primary button: $clickCount", style = CdsTheme.typography.body, @@ -44,7 +44,11 @@ fun ButtonGallerySection(modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), ) { - Button(text = "Primary", onClick = { clickCount++ }) + Button( + text = "Primary", + onClick = { clickCount++ }, + modifier = Modifier.testTag("gallery-button-primary-interactive"), + ) Button(text = "Secondary", onClick = {}, variant = ButtonVariant.Secondary) Button(text = "Tertiary", onClick = {}, variant = ButtonVariant.Tertiary) Button(text = "Positive", onClick = {}, variant = ButtonVariant.Positive) @@ -67,8 +71,18 @@ fun ButtonGallerySection(modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), ) { - Button(text = "Disabled", onClick = {}, enabled = false) - Button(text = "Loading", onClick = {}, loading = true) + Button( + text = "Disabled", + onClick = {}, + enabled = false, + modifier = Modifier.testTag("gallery-button-disabled"), + ) + Button( + text = "Loading", + onClick = {}, + loading = true, + modifier = Modifier.testTag("gallery-button-loading"), + ) Button( text = "Transparent loading", onClick = {}, @@ -126,16 +140,6 @@ fun ButtonGallerySection(modifier: Modifier = Modifier) { } } -@Composable -private fun GallerySubsectionTitle(text: String) { - GalleryText( - text = text, - style = CdsTheme.typography.headline, - color = CdsTheme.colors.fg, - modifier = Modifier.padding(top = CdsTheme.space.x0_5), - ) -} - @Composable private fun GalleryArrowIcon(color: Color, iconSize: Dp, pointsLeft: Boolean) { Canvas(modifier = Modifier.size(iconSize)) { diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ComponentGalleryScreen.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ComponentGalleryScreen.kt new file mode 100644 index 0000000000..75b625b38a --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ComponentGalleryScreen.kt @@ -0,0 +1,20 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal fun ComponentGalleryScreen( + destination: GalleryDestination, + onBack: () -> Unit, + onNavigateToComponent: (GalleryDestination) -> Unit, + modifier: Modifier = Modifier, +) { + when (destination) { + GalleryDestination.Button -> ButtonGalleryScreen( + onBack = onBack, + onNavigateToComponent = onNavigateToComponent, + modifier = modifier, + ) + } +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryComponents.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryComponents.kt index 41d282e89b..8266252e35 100644 --- a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryComponents.kt +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryComponents.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -88,7 +89,33 @@ internal fun GalleryChevronLeftIcon(color: Color, iconSize: Dp, modifier: Modifi } } -/** An unlabeled square swatch, used for dense grids like the spectrum ramp. */ +/** A minimal right-pointing chevron for destination rows. */ +@Composable +internal fun GalleryChevronRightIcon(color: Color, iconSize: Dp, modifier: Modifier = Modifier) { + Canvas(modifier = modifier.size(iconSize)) { + val strokeWidth = size.minDimension * 0.14f + val path = Path().apply { + moveTo(size.width * 0.38f, size.height * 0.12f) + lineTo(size.width * 0.7f, size.height * 0.5f) + lineTo(size.width * 0.38f, size.height * 0.88f) + } + drawPath( + path = path, + color = color, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + } +} + +@Composable +internal fun GallerySubsectionTitle(text: String) { + GalleryText( + text = text, + style = CdsTheme.typography.headline, + color = CdsTheme.colors.fg, + modifier = Modifier.padding(top = CdsTheme.space.x0_5), + ) +} @Composable internal fun GalleryColorChip(color: Color, size: Dp) { Box( diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryDestination.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryDestination.kt new file mode 100644 index 0000000000..494c85bda8 --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryDestination.kt @@ -0,0 +1,22 @@ +package com.coinbase.cds.androidapp.gallery + +/** + * Registered component galleries in display order. Visreg and manual QA navigate directly to a + * single destination instead of scrolling a monolithic home screen. + */ +internal enum class GalleryDestination( + val title: String, + val subtitle: String, + val testTag: String, +) { + Button( + title = "Button", + subtitle = "Variants, states, sizes, icons, and layout", + testTag = "gallery-component-button", + ), + ; + + fun previous(): GalleryDestination? = entries.getOrNull(ordinal - 1) + + fun next(): GalleryDestination? = entries.getOrNull(ordinal + 1) +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryNavigation.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryNavigation.kt new file mode 100644 index 0000000000..0bb5b6024c --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryNavigation.kt @@ -0,0 +1,195 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.coinbase.cds.theme.CdsTheme + +/** + * Shared chrome for full-screen gallery destinations: back affordance, title, optional prev/next + * pager between component galleries, and a single scrollable content region. + */ +@Composable +internal fun GalleryScreenScaffold( + title: String, + subtitle: String?, + onBack: () -> Unit, + modifier: Modifier = Modifier, + testTag: String? = null, + previousDestination: GalleryDestination? = null, + nextDestination: GalleryDestination? = null, + onNavigateToComponent: ((GalleryDestination) -> Unit)? = null, + content: @Composable () -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(CdsTheme.colors.bg) + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier), + ) { + GalleryScreenHeader( + title = title, + subtitle = subtitle, + onBack = onBack, + ) + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = CdsTheme.space.x3) + .padding(bottom = CdsTheme.space.x2), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), + ) { + content() + } + if (onNavigateToComponent != null && (previousDestination != null || nextDestination != null)) { + GalleryComponentPager( + previousDestination = previousDestination, + nextDestination = nextDestination, + onNavigateToComponent = onNavigateToComponent, + ) + } + } +} + +@Composable +private fun GalleryScreenHeader( + title: String, + subtitle: String?, + onBack: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CdsTheme.space.x3) + .padding(top = CdsTheme.space.x3, bottom = CdsTheme.space.x2), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x0_5), + modifier = Modifier + .clickable(onClick = onBack) + .padding(vertical = CdsTheme.space.x0_5) + .testTag("gallery-nav-back"), + ) { + GalleryChevronLeftIcon(color = CdsTheme.colors.fgPrimary, iconSize = 16.dp) + GalleryText( + text = "Back", + style = CdsTheme.typography.label1, + color = CdsTheme.colors.fgPrimary, + ) + } + GalleryText(text = title, style = CdsTheme.typography.title1, color = CdsTheme.colors.fg) + if (subtitle != null) { + GalleryText( + text = subtitle, + style = CdsTheme.typography.body, + color = CdsTheme.colors.fgMuted, + ) + } + } +} + +@Composable +internal fun GalleryDestinationRow( + title: String, + subtitle: String, + onClick: () -> Unit, + testTag: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CdsTheme.borderRadius.radius300)) + .background(CdsTheme.colors.bgSecondary) + .clickable(onClick = onClick) + .padding(CdsTheme.space.x2) + .testTag(testTag), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x0_5), + ) { + GalleryText(text = title, style = CdsTheme.typography.headline, color = CdsTheme.colors.fg) + GalleryText(text = subtitle, style = CdsTheme.typography.body, color = CdsTheme.colors.fgMuted) + } + GalleryChevronRightIcon(color = CdsTheme.colors.fgMuted, iconSize = 16.dp) + } +} + +@Composable +private fun GalleryComponentPager( + previousDestination: GalleryDestination?, + nextDestination: GalleryDestination?, + onNavigateToComponent: (GalleryDestination) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(CdsTheme.colors.bgSecondary) + .padding(horizontal = CdsTheme.space.x3, vertical = CdsTheme.space.x1_5), + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + GalleryPagerButton( + label = previousDestination?.title?.let { "← $it" } ?: "← Previous", + enabled = previousDestination != null, + onClick = { previousDestination?.let(onNavigateToComponent) }, + modifier = Modifier + .weight(1f) + .testTag("gallery-nav-previous"), + ) + GalleryPagerButton( + label = nextDestination?.title?.let { "$it →" } ?: "Next →", + enabled = nextDestination != null, + onClick = { nextDestination?.let(onNavigateToComponent) }, + modifier = Modifier + .weight(1f) + .testTag("gallery-nav-next"), + ) + } +} + +@Composable +private fun GalleryPagerButton( + label: String, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val background = if (enabled) CdsTheme.colors.bgTertiary else CdsTheme.colors.bgSecondary + val foreground = if (enabled) CdsTheme.colors.fg else CdsTheme.colors.fgMuted + GalleryText( + text = label, + style = CdsTheme.typography.label1, + color = foreground, + modifier = modifier + .clip(RoundedCornerShape(CdsTheme.borderRadius.radius300)) + .background(background) + .then( + if (enabled) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + }, + ) + .padding(vertical = CdsTheme.space.x1_5, horizontal = CdsTheme.space.x1), + ) +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryRoute.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryRoute.kt new file mode 100644 index 0000000000..0358427241 --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryRoute.kt @@ -0,0 +1,9 @@ +package com.coinbase.cds.androidapp.gallery + +internal sealed interface GalleryRoute { + data object Home : GalleryRoute + + data object ThemeTokens : GalleryRoute + + data class Component(val destination: GalleryDestination) : GalleryRoute +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/HomeGalleryScreen.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/HomeGalleryScreen.kt new file mode 100644 index 0000000000..576b4d476f --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/HomeGalleryScreen.kt @@ -0,0 +1,107 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import com.coinbase.cds.components.button.Button +import com.coinbase.cds.components.button.ButtonVariant +import com.coinbase.cds.theme.CdsTheme + +@Composable +internal fun HomeGalleryScreen( + darkTheme: Boolean, + onToggleDarkTheme: () -> Unit, + customBrand: Boolean, + onToggleBrand: () -> Unit, + onOpenThemeTokens: () -> Unit, + onOpenComponent: (GalleryDestination) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(CdsTheme.colors.bg) + .systemBarsPadding() + .padding(CdsTheme.space.x3) + .testTag("gallery-home"), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), + ) { + GalleryText( + text = "Coinbase Design System", + style = CdsTheme.typography.title1, + color = CdsTheme.colors.fg, + ) + GalleryText( + text = "Android gallery for theme tokens and CDS components.", + style = CdsTheme.typography.body, + color = CdsTheme.colors.fgMuted, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CdsTheme.borderRadius.radius400)) + .background(CdsTheme.colors.bgSecondary) + .padding(CdsTheme.space.x2), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1_5), + ) { + GalleryText( + text = "Theme", + style = CdsTheme.typography.headline, + color = CdsTheme.colors.fg, + ) + Button( + text = if (darkTheme) "Switch to light theme" else "Switch to dark theme", + onClick = onToggleDarkTheme, + modifier = Modifier.fillMaxWidth(), + ) + Button( + text = if (customBrand) { + "Switch to default CDS theme" + } else { + "Switch to Acme brand theme" + }, + onClick = onToggleBrand, + modifier = Modifier.fillMaxWidth(), + variant = ButtonVariant.Tertiary, + ) + } + + GalleryText( + text = "Galleries", + style = CdsTheme.typography.headline, + color = CdsTheme.colors.fg, + ) + + GalleryDestinationRow( + title = "Theme tokens", + subtitle = "Colors, spacing, typography, and the full token scale", + onClick = onOpenThemeTokens, + testTag = "gallery-destination-theme-tokens", + ) + + GalleryText( + text = "Components", + style = CdsTheme.typography.headline, + color = CdsTheme.colors.fg, + ) + + GalleryDestination.entries.forEach { destination -> + GalleryDestinationRow( + title = destination.title, + subtitle = destination.subtitle, + onClick = { onOpenComponent(destination) }, + testTag = destination.testTag, + ) + } + } +} diff --git a/apps/ios-gallery/Sources/ButtonGalleryView.swift b/apps/ios-gallery/Sources/ButtonGalleryView.swift new file mode 100644 index 0000000000..2e3aaf0153 --- /dev/null +++ b/apps/ios-gallery/Sources/ButtonGalleryView.swift @@ -0,0 +1,35 @@ +@testable import CDSDesignSystem +import SwiftUI + +struct ButtonGalleryView: View { + @Environment(\.cdsTheme) private var cds + let onBack: () -> Void + let onNavigateToComponent: (GalleryDestination) -> Void + + private let destination = GalleryDestination.button + + var body: some View { + GalleryScreenScaffold( + title: destination.title, + subtitle: destination.subtitle, + onBack: onBack, + testTag: destination.testTag, + previousDestination: destination.previous(), + nextDestination: destination.next(), + onNavigateToComponent: onNavigateToComponent + ) { + VStack(alignment: .leading, spacing: cds.spacing.x1) { + CDSDesignSystem.Text("Button", style: .label1, color: cds.colors.fgMuted) + CDSDesignSystem.Button(text: "Primary", action: {}) + CDSDesignSystem.Button(text: "Secondary", action: {}, variant: .secondary) + CDSDesignSystem.Button(text: "Tertiary", action: {}, variant: .tertiary) + CDSDesignSystem.Button(text: "Positive", action: {}, variant: .positive) + CDSDesignSystem.Button(text: "Negative", action: {}, variant: .negative) + CDSDesignSystem.Button(text: "Ghost", action: {}, transparent: true) + CDSDesignSystem.Button(text: "Disabled", action: {}, isEnabled: false) + CDSDesignSystem.Button(text: "Loading", action: {}, loading: true) + CDSDesignSystem.Button(text: "Full width", action: {}, fullWidth: true) + } + } + } +} diff --git a/apps/ios-gallery/Sources/ComponentGalleryView.swift b/apps/ios-gallery/Sources/ComponentGalleryView.swift new file mode 100644 index 0000000000..ad50c5ba7f --- /dev/null +++ b/apps/ios-gallery/Sources/ComponentGalleryView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct ComponentGalleryView: View { + let destination: GalleryDestination + let onBack: () -> Void + let onNavigateToComponent: (GalleryDestination) -> Void + + var body: some View { + switch destination { + case .button: + ButtonGalleryView( + onBack: onBack, + onNavigateToComponent: onNavigateToComponent + ) + case .otherComponents: + OtherComponentsGalleryView( + onBack: onBack, + onNavigateToComponent: onNavigateToComponent + ) + } + } +} diff --git a/apps/ios-gallery/Sources/GalleryDestination.swift b/apps/ios-gallery/Sources/GalleryDestination.swift new file mode 100644 index 0000000000..57ac17d8b5 --- /dev/null +++ b/apps/ios-gallery/Sources/GalleryDestination.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Registered component galleries in display order. Visreg and manual QA navigate directly to a +/// single destination instead of scrolling a monolithic home screen. +enum GalleryDestination: String, CaseIterable, Identifiable { + case button + case otherComponents + + var id: String { rawValue } + + var title: String { + switch self { + case .button: return "Button" + case .otherComponents: return "Other components" + } + } + + var subtitle: String { + switch self { + case .button: return "Variants, states, sizes, and layout" + case .otherComponents: return "Text, SlideButton, ProgressCircle, and inverted theme" + } + } + + var testTag: String { + switch self { + case .button: return "gallery-component-button" + case .otherComponents: return "gallery-component-other" + } + } + + func previous() -> GalleryDestination? { + let all = Self.allCases + guard let index = all.firstIndex(of: self), index > 0 else { return nil } + return all[index - 1] + } + + func next() -> GalleryDestination? { + let all = Self.allCases + guard let index = all.firstIndex(of: self), index < all.count - 1 else { return nil } + return all[index + 1] + } +} + +enum GalleryRoute: Equatable { + case home + case themeTokens + case component(GalleryDestination) +} diff --git a/apps/ios-gallery/Sources/GalleryNavigation.swift b/apps/ios-gallery/Sources/GalleryNavigation.swift new file mode 100644 index 0000000000..0b385b9f77 --- /dev/null +++ b/apps/ios-gallery/Sources/GalleryNavigation.swift @@ -0,0 +1,152 @@ +@testable import CDSDesignSystem +import SwiftUI + +/// Shared chrome for full-screen gallery destinations: back affordance, title, optional prev/next +/// pager between component galleries, and a single scrollable content region. +struct GalleryScreenScaffold: View { + @Environment(\.cdsTheme) private var cds + let title: String + let subtitle: String? + let onBack: () -> Void + let testTag: String? + let previousDestination: GalleryDestination? + let nextDestination: GalleryDestination? + let onNavigateToComponent: ((GalleryDestination) -> Void)? + @ViewBuilder let content: Content + + init( + title: String, + subtitle: String? = nil, + onBack: @escaping () -> Void, + testTag: String? = nil, + previousDestination: GalleryDestination? = nil, + nextDestination: GalleryDestination? = nil, + onNavigateToComponent: ((GalleryDestination) -> Void)? = nil, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.subtitle = subtitle + self.onBack = onBack + self.testTag = testTag + self.previousDestination = previousDestination + self.nextDestination = nextDestination + self.onNavigateToComponent = onNavigateToComponent + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + ScrollView { + VStack(alignment: .leading, spacing: cds.spacing.x2) { + content + } + .padding(.horizontal, cds.spacing.x3) + .padding(.bottom, cds.spacing.x2) + } + if onNavigateToComponent != nil, + previousDestination != nil || nextDestination != nil { + componentPager + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(cds.colors.bg) + .accessibilityIdentifier(testTag ?? "") + } + + private var header: some View { + VStack(alignment: .leading, spacing: cds.spacing.x1) { + Button(action: onBack) { + HStack(spacing: cds.spacing.x0_5) { + Image(systemName: "chevron.left") + .font(.caption.weight(.semibold)) + CDSDesignSystem.Text("Back", style: .label1, color: cds.colors.fgPrimary) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("gallery-nav-back") + + CDSDesignSystem.Text(title, style: .title1) + if let subtitle { + CDSDesignSystem.Text(subtitle, style: .body, color: cds.colors.fgMuted) + } + } + .padding(.horizontal, cds.spacing.x3) + .padding(.top, cds.spacing.x3) + .padding(.bottom, cds.spacing.x2) + } + + private var componentPager: some View { + HStack(spacing: cds.spacing.x1) { + GalleryPagerButton( + label: previousDestination.map { "← \($0.title)" } ?? "← Previous", + enabled: previousDestination != nil, + action: { previousDestination.map { onNavigateToComponent?($0) } } + ) + .accessibilityIdentifier("gallery-nav-previous") + + GalleryPagerButton( + label: nextDestination.map { "\($0.title) →" } ?? "Next →", + enabled: nextDestination != nil, + action: { nextDestination.map { onNavigateToComponent?($0) } } + ) + .accessibilityIdentifier("gallery-nav-next") + } + .padding(.horizontal, cds.spacing.x3) + .padding(.vertical, cds.spacing.x1_5) + .background(cds.colors.bgSecondary) + } +} + +struct GalleryDestinationRow: View { + @Environment(\.cdsTheme) private var cds + let title: String + let subtitle: String + let testTag: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .center, spacing: cds.spacing.x1) { + VStack(alignment: .leading, spacing: cds.spacing.x0_5) { + CDSDesignSystem.Text(title, style: .headline) + CDSDesignSystem.Text(subtitle, style: .body, color: cds.colors.fgMuted) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(cds.colors.fgMuted) + } + .padding(cds.spacing.x2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(cds.colors.bgSecondary) + .clipShape(RoundedRectangle(cornerRadius: cds.radius.r300)) + } + .buttonStyle(.plain) + .accessibilityIdentifier(testTag) + } +} + +private struct GalleryPagerButton: View { + @Environment(\.cdsTheme) private var cds + let label: String + let enabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + CDSDesignSystem.Text( + label, + style: .label1, + color: enabled ? cds.colors.fg : cds.colors.fgMuted + ) + .frame(maxWidth: .infinity) + .padding(.vertical, cds.spacing.x1_5) + .padding(.horizontal, cds.spacing.x1) + .background(enabled ? cds.colors.bgTertiary : cds.colors.bgSecondary) + .clipShape(RoundedRectangle(cornerRadius: cds.radius.r300)) + } + .buttonStyle(.plain) + .disabled(!enabled) + } +} diff --git a/apps/ios-gallery/Sources/HomeGalleryView.swift b/apps/ios-gallery/Sources/HomeGalleryView.swift new file mode 100644 index 0000000000..3244a91fd1 --- /dev/null +++ b/apps/ios-gallery/Sources/HomeGalleryView.swift @@ -0,0 +1,69 @@ +@testable import CDSDesignSystem +import SwiftUI + +struct HomeGalleryView: View { + @Environment(\.cdsTheme) private var cds + @Binding var scheme: SchemeChoice + @Binding var theme: ThemeChoice + let onOpenThemeTokens: () -> Void + let onOpenComponent: (GalleryDestination) -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: cds.spacing.x2) { + CDSDesignSystem.Text("Coinbase Design System", style: .title1) + CDSDesignSystem.Text( + "iOS gallery for theme tokens and CDS components.", + style: .body, + color: cds.colors.fgMuted + ) + + themeControls + + CDSDesignSystem.Text("Galleries", style: .headline) + + GalleryDestinationRow( + title: "Theme tokens", + subtitle: "Colors, spacing, typography, and the full token scale", + testTag: "gallery-destination-theme-tokens", + action: onOpenThemeTokens + ) + + CDSDesignSystem.Text("Components", style: .headline) + + ForEach(GalleryDestination.allCases) { destination in + GalleryDestinationRow( + title: destination.title, + subtitle: destination.subtitle, + testTag: destination.testTag, + action: { onOpenComponent(destination) } + ) + } + } + .padding(cds.spacing.x3) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(cds.colors.bg) + .accessibilityIdentifier("gallery-home") + } + + private var themeControls: some View { + VStack(alignment: .leading, spacing: cds.spacing.x1_5) { + CDSDesignSystem.Text("Theme", style: .headline) + + Picker("Theme", selection: $theme) { + ForEach(ThemeChoice.allCases) { SwiftUI.Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + + Picker("Color scheme", selection: $scheme) { + ForEach(SchemeChoice.allCases) { SwiftUI.Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + } + .padding(cds.spacing.x2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(cds.colors.bgSecondary) + .clipShape(RoundedRectangle(cornerRadius: cds.radius.r400)) + } +} diff --git a/apps/ios-gallery/Sources/ComponentsGallery.swift b/apps/ios-gallery/Sources/OtherComponentsGalleryView.swift similarity index 51% rename from apps/ios-gallery/Sources/ComponentsGallery.swift rename to apps/ios-gallery/Sources/OtherComponentsGalleryView.swift index 7b63525c52..7e55637ff4 100644 --- a/apps/ios-gallery/Sources/ComponentsGallery.swift +++ b/apps/ios-gallery/Sources/OtherComponentsGalleryView.swift @@ -1,26 +1,33 @@ @testable import CDSDesignSystem import SwiftUI -/// The component surface. `Text`, `Button`, and `SlideButton` are `internal`, so the gallery -/// reaches them via `@testable import` (Debug enables testability) to demo the real components. -struct ComponentsGallery: View { +struct OtherComponentsGalleryView: View { @Environment(\.cdsTheme) private var cds @State private var slideChecked = false + let onBack: () -> Void + let onNavigateToComponent: (GalleryDestination) -> Void + + private let destination = GalleryDestination.otherComponents var body: some View { - SectionCard("Components", subtitle: "Text · Button · SlideButton · ProgressCircle · inverted theme") { - VStack(alignment: .leading, spacing: cds.space.x3) { - text - buttons - slideButton - progressCircle - invertedDemo - } + GalleryScreenScaffold( + title: destination.title, + subtitle: destination.subtitle, + onBack: onBack, + testTag: destination.testTag, + previousDestination: destination.previous(), + nextDestination: destination.next(), + onNavigateToComponent: onNavigateToComponent + ) { + text + slideButton + progressCircle + invertedDemo } } private var text: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { + VStack(alignment: .leading, spacing: cds.spacing.x1) { CDSDesignSystem.Text("Text", style: .label1, color: cds.colors.fgMuted) CDSDesignSystem.Text("Default foreground", style: .body) CDSDesignSystem.Text("Muted foreground", style: .body, color: cds.colors.fgMuted) @@ -30,23 +37,8 @@ struct ComponentsGallery: View { } } - private var buttons: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { - CDSDesignSystem.Text("Button", style: .label1, color: cds.colors.fgMuted) - CDSDesignSystem.Button(text: "Primary", action: {}) - CDSDesignSystem.Button(text: "Secondary", action: {}, variant: .secondary) - CDSDesignSystem.Button(text: "Tertiary", action: {}, variant: .tertiary) - CDSDesignSystem.Button(text: "Positive", action: {}, variant: .positive) - CDSDesignSystem.Button(text: "Negative", action: {}, variant: .negative) - CDSDesignSystem.Button(text: "Ghost", action: {}, transparent: true) - CDSDesignSystem.Button(text: "Disabled", action: {}, isEnabled: false) - CDSDesignSystem.Button(text: "Loading", action: {}, loading: true) - CDSDesignSystem.Button(text: "Full width", action: {}, fullWidth: true) - } - } - private var slideButton: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { + VStack(alignment: .leading, spacing: cds.spacing.x1) { CDSDesignSystem.Text("SlideButton", style: .label1, color: cds.colors.fgMuted) SlideButton( checked: $slideChecked, @@ -58,9 +50,9 @@ struct ComponentsGallery: View { } private var progressCircle: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { + VStack(alignment: .leading, spacing: cds.spacing.x1) { CDSDesignSystem.Text("ProgressCircle", style: .label1, color: cds.colors.fgMuted) - HStack(spacing: cds.space.x3) { + HStack(spacing: cds.spacing.x3) { ProgressCircle(size: .s) ProgressCircle(size: .m) ProgressCircle(size: .l) @@ -68,9 +60,8 @@ struct ComponentsGallery: View { } } - /// Same content rendered under `InvertedThemeProvider`, which flips the scheme for its subtree. private var invertedDemo: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { + VStack(alignment: .leading, spacing: cds.spacing.x1) { CDSDesignSystem.Text("InvertedThemeProvider", style: .label1, color: cds.colors.fgMuted) InvertedThemeProvider { InvertedCard() @@ -86,9 +77,9 @@ private struct InvertedCard: View { var body: some View { CDSDesignSystem.Text("Content on the opposite scheme", style: .body) - .padding(cds.space.x2) + .padding(cds.spacing.x2) .frame(maxWidth: .infinity, alignment: .leading) .background(cds.colors.bg) - .cdsBorderedCard(radius: cds.borderRadius.radius300) + .cdsBorderedCard(radius: cds.radius.r300) } } diff --git a/apps/ios-gallery/Sources/RootGalleryView.swift b/apps/ios-gallery/Sources/RootGalleryView.swift index c888e7b232..298dd9fbdc 100644 --- a/apps/ios-gallery/Sources/RootGalleryView.swift +++ b/apps/ios-gallery/Sources/RootGalleryView.swift @@ -38,61 +38,45 @@ enum ThemeChoice: String, CaseIterable, Identifiable { struct RootGalleryView: View { @State private var scheme: SchemeChoice = .system @State private var theme: ThemeChoice = .cds + @State private var route: GalleryRoute = .home var body: some View { CDSThemeProvider(theme: theme.set, colorScheme: scheme.colorScheme) { - GalleryScreen(scheme: $scheme, theme: $theme) + GalleryApp( + route: route, + onRouteChange: { route = $0 }, + scheme: $scheme, + theme: $theme + ) } } } -/// The scrolling gallery itself, plus the theme/scheme controls. Lives under the provider so -/// `@Environment(\.cdsTheme)` resolves to the current selection. -struct GalleryScreen: View { +private struct GalleryApp: View { + let route: GalleryRoute + let onRouteChange: (GalleryRoute) -> Void @Binding var scheme: SchemeChoice @Binding var theme: ThemeChoice - @Environment(\.cdsTheme) private var cds var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: cds.space.x3) { - controls - - ColorGallery() - IllustrationGallery() - SpectrumGallery() - TypographyGallery() - SpacingGallery() - RadiusGallery() - BorderWidthGallery() - SizesGallery() - ShadowGallery() - ComponentsGallery() - } - .padding(cds.space.x2) - .frame(maxWidth: .infinity, alignment: .leading) - } - .background(cds.colors.bg) - } - - private var controls: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { - CDSDesignSystem.Text("CDS iOS — Theme Gallery", style: .title2) - CDSDesignSystem.Text("Live view of every token scale in the active theme.", style: .label2, color: cds.colors.fgMuted) - - Picker("Theme", selection: $theme) { - ForEach(ThemeChoice.allCases) { SwiftUI.Text($0.label).tag($0) } - } - .pickerStyle(.segmented) - - Picker("Color scheme", selection: $scheme) { - ForEach(SchemeChoice.allCases) { SwiftUI.Text($0.label).tag($0) } + Group { + switch route { + case .home: + HomeGalleryView( + scheme: $scheme, + theme: $theme, + onOpenThemeTokens: { onRouteChange(.themeTokens) }, + onOpenComponent: { onRouteChange(.component($0)) } + ) + case .themeTokens: + ThemeTokensGalleryView(onBack: { onRouteChange(.home) }) + case .component(let destination): + ComponentGalleryView( + destination: destination, + onBack: { onRouteChange(.home) }, + onNavigateToComponent: { onRouteChange(.component($0)) } + ) } - .pickerStyle(.segmented) } - .padding(cds.space.x2) - .frame(maxWidth: .infinity, alignment: .leading) - .background(cds.colors.bgSecondary) - .clipShape(RoundedRectangle(cornerRadius: cds.borderRadius.radius300)) } } diff --git a/apps/ios-gallery/Sources/ThemeTokensGalleryView.swift b/apps/ios-gallery/Sources/ThemeTokensGalleryView.swift new file mode 100644 index 0000000000..a712ea4e9f --- /dev/null +++ b/apps/ios-gallery/Sources/ThemeTokensGalleryView.swift @@ -0,0 +1,26 @@ +@testable import CDSDesignSystem +import SwiftUI + +struct ThemeTokensGalleryView: View { + @Environment(\.cdsTheme) private var cds + let onBack: () -> Void + + var body: some View { + GalleryScreenScaffold( + title: "Theme tokens", + subtitle: "Live view of every token scale in the active theme.", + onBack: onBack, + testTag: "gallery-destination-theme-tokens" + ) { + ColorGallery() + IllustrationGallery() + SpectrumGallery() + TypographyGallery() + SpacingGallery() + RadiusGallery() + BorderWidthGallery() + SizesGallery() + ShadowGallery() + } + } +} diff --git a/packages/cds-android/docs/button.md b/packages/cds-android/docs/button.md index ca5cccb389..8290c0491e 100644 --- a/packages/cds-android/docs/button.md +++ b/packages/cds-android/docs/button.md @@ -81,10 +81,41 @@ Use standard Compose modifiers instead of React Native-style layout props: | Need | Compose approach | | ------------------ | ------------------------------------------------------------------- | | Full width | `modifier = Modifier.fillMaxWidth()` | -| Test hook | `modifier = Modifier.testTag("confirm")` | +| Test hook (RN `testID`) | `modifier = Modifier.testTag("confirm")` on the button root | | Custom semantics | `modifier = Modifier.semantics { … }` merged with Button's defaults | | Offset / alignment | `Modifier.offset`, parent `Row`/`Column` arrangement | +### UI testing (Compose UI Test and Maestro) + +Button does not take a `testID` parameter. Pass a tag through [modifier] on the root composable — the same node that owns `Role.Button`, `contentDescription`, and click handling. + +**In-process tests** (Robolectric / `createComposeRule`): + +```kotlin +Button( + text = "Confirm", + onClick = ::confirm, + modifier = Modifier.testTag("checkout-confirm"), +) + +composeRule.onNodeWithTag("checkout-confirm").performClick() +``` + +**Maestro** (black-box) follows [Jetpack Compose guidance](https://docs.maestro.dev/get-started/supported-platform/android/jetpack): prefer visible text when unique (`tapOn: "Confirm"`), then accessibility description, then `id` for duplicates or stable anchors. For `id`, the host app must enable resource-id mapping once near the root: + +```kotlin +Modifier.semantics { testTagsAsResourceId = true } +``` + +`apps/android-app` does this in `MainActivity`. Then Maestro can target: + +```yaml +- tapOn: + id: checkout-confirm +``` + +Loading buttons keep [text] in `contentDescription`, so `tapOn: { description: "Submit" }` continues to work while the label is hidden. + Raw color, padding, or style object overrides are intentionally absent. Re-theme via [cdsTheme](custom-themes.md). diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt index d84b51bf1f..90b342cb8b 100644 --- a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt +++ b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt @@ -55,8 +55,11 @@ public enum class ButtonSize { * [transparent], icon slots, and accessibility semantics. Raw color/background/border overrides * are deliberately absent — re-theme via [com.coinbase.cds.theme.CdsThemeProvider] instead. * - * For full-width layout, pass `Modifier.fillMaxWidth()`; for test hooks and custom semantics, - * pass them through [modifier]. + * For full-width layout, pass `Modifier.fillMaxWidth()`. For test hooks (RN `testID` equivalent), + * pass `modifier = Modifier.testTag("confirm")` — the tag is applied on this root `Row` alongside + * button semantics and gestures. Maestro can select it with `id:` when the host app enables + * `testTagsAsResourceId` at the activity root; prefer matching visible label text when unique. + * See `packages/cds-android/docs/button.md` and the cds-rn-to-compose `ui-testing` reference. * * @param transparent Renders on the plain page background with variant-colored text instead of a * filled, variant-colored container — CDS's lower-emphasis "ghost" treatment. diff --git a/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt index 17c5a913df..38463625e8 100644 --- a/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt +++ b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt @@ -143,6 +143,22 @@ class ButtonTest { assertEquals(CdsDefaultTheme.iconSize.m, capturedSize) } + @Test + fun callerTestTagIsQueryable() { + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button( + text = "Confirm", + onClick = {}, + modifier = Modifier.testTag("confirm-button"), + ) + } + } + + composeRule.onNodeWithTag("confirm-button").assertIsDisplayed() + composeRule.onNodeWithText("Confirm").assertIsDisplayed() + } + @Test fun callerModifierFillMaxWidthIsRespected() { composeRule.setContent { From 61a69c0fb0e116647f39ba69d09c161dcee67d87 Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Thu, 10 Sep 2026 12:20:53 -0400 Subject: [PATCH 5/7] Revert tools:format and document components in KDoc only. Restore nx format:check in the Node workflow while keeping docs/skills off the Node CI classifier, and remove per-component cds-android docs. Co-authored-by: Cursor --- .claude/skills/cds-rn-to-compose/SKILL.md | 8 +- .../references/audit-checklist.md | 3 +- .../references/discovery-template.md | 2 +- .../references/rn-to-compose-mapping.md | 2 +- .github/workflows/ci.yml | 13 -- .github/workflows/node.yml | 15 ++ .prettierignore | 7 +- AGENTS.md | 6 +- docs/ci.md | 26 ++-- docs/nx.md | 5 +- docs/testing.md | 11 +- packages/cds-android/docs/README.md | 4 +- packages/cds-android/docs/button.md | 146 ------------------ .../coinbase/cds/components/button/Button.kt | 3 +- prettier.config.js | 2 - tools/ci/toolchains.mjs | 5 +- tools/project.json | 23 --- 17 files changed, 52 insertions(+), 229 deletions(-) delete mode 100644 packages/cds-android/docs/button.md diff --git a/.claude/skills/cds-rn-to-compose/SKILL.md b/.claude/skills/cds-rn-to-compose/SKILL.md index 88ef25f3d1..c46de9fc1d 100644 --- a/.claude/skills/cds-rn-to-compose/SKILL.md +++ b/.claude/skills/cds-rn-to-compose/SKILL.md @@ -280,8 +280,8 @@ The demo app is a **consumer** — if it needs a non-public API, fix the API des ## Phase 7: Documentation & changelog -- `packages/cds-android/docs/.md` — public API, examples, modifier usage, interaction hoisting -- Update `packages/cds-android/docs/README.md` index +- **KDoc** on the public `@Composable` and its types — API, modifier usage, interaction hoisting, test tags +- Do **not** add per-component files under `packages/cds-android/docs/` — that folder is for cross-cutting guides (tokens, themes, interaction, releasing) only - `packages/cds-android/CHANGELOG.md` under the Gradle version - Update `packages/cds-android/AGENTS.md` if public surface changes @@ -392,7 +392,7 @@ Before marking a port complete: - [ ] Robolectric behavior tests for callbacks and semantics - [ ] Interaction event tests when component hoists `MutableInteractionSource` - [ ] Gallery section in `android-app` -- [ ] `docs/.md` + CHANGELOG +- [ ] KDoc on public API + CHANGELOG - [ ] `yarn nx run cds-android:test` and `cds-android:build` pass - [ ] No Compose Styles API; no `@coinbase/cds-common` imports - [ ] Public API reviewed for Hyrum's Law — no leaked style types or assembly composables @@ -418,4 +418,4 @@ Before marking a port complete: - Interaction: `CdsInteractionDefaults.indication(shape)` + hoisted `interactionSource` - Tests: `ButtonTest.kt`, `ButtonStyleTest.kt`, `CdsInteractionStateTest.kt` - Gallery: `apps/android-app/.../ButtonGallerySection.kt` -- Docs: `packages/cds-android/docs/button.md`, `interaction.md` +- KDoc: `Button.kt`; package docs: `interaction.md` diff --git a/.claude/skills/cds-rn-to-compose/references/audit-checklist.md b/.claude/skills/cds-rn-to-compose/references/audit-checklist.md index 951921f04f..69471b2a7d 100644 --- a/.claude/skills/cds-rn-to-compose/references/audit-checklist.md +++ b/.claude/skills/cds-rn-to-compose/references/audit-checklist.md @@ -112,8 +112,7 @@ Reference: `references/ui-testing.md`, [Maestro Jetpack Compose](https://docs.ma ## 9. Demo & documentation - [ ] Gallery section covers variants, states, sizes, edge cases -- [ ] `packages/cds-android/docs/.md` exists and matches API -- [ ] `docs/README.md` index updated +- [ ] KDoc on public composable and types matches API (no per-component `docs/*.md`) - [ ] `CHANGELOG.md` mentions public API changes - [ ] `AGENTS.md` public surface list accurate - [ ] `interactionSource` documented if hoisted diff --git a/.claude/skills/cds-rn-to-compose/references/discovery-template.md b/.claude/skills/cds-rn-to-compose/references/discovery-template.md index fc14b991b8..603f45ba4c 100644 --- a/.claude/skills/cds-rn-to-compose/references/discovery-template.md +++ b/.claude/skills/cds-rn-to-compose/references/discovery-template.md @@ -129,7 +129,7 @@ fun ComponentName( ## Demo & docs - Gallery section: `apps/android-app/.../GallerySection.kt` -- Doc: `packages/cds-android/docs/.md` +- KDoc on public composable and types (no per-component `docs/*.md`) - CHANGELOG entry ## Open questions diff --git a/.claude/skills/cds-rn-to-compose/references/rn-to-compose-mapping.md b/.claude/skills/cds-rn-to-compose/references/rn-to-compose-mapping.md index f15811c1aa..740e4dd23e 100644 --- a/.claude/skills/cds-rn-to-compose/references/rn-to-compose-mapping.md +++ b/.claude/skills/cds-rn-to-compose/references/rn-to-compose-mapping.md @@ -101,4 +101,4 @@ Wire every interaction type you want customers to observe to the **same** `Mutab | React Native | Jetpack Compose | |--------------|-----------------| | Storybook stories | `android-app` gallery section | -| Component docsite (web) | `packages/cds-android/docs/.md` | +| Component docsite (web) | KDoc on public composable (no per-component `docs/*.md` in cds-android) | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2bfd9ac8e..bbece97fdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,19 +48,6 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: node tools/ci/classifyToolchains.mjs - format: - name: Format - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: audit - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - uses: ./.github/actions/setup-node-ci - - name: Format - run: yarn nx run tools:format:check - # Always call so required checks report; jobs skip when unaffected. node: name: Node diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 00727e2fca..a9913f19a5 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -73,6 +73,21 @@ jobs: # paid down. run: yarn nx affected --exclude='*,!tag:toolchain:node' --target=lint --base=$NX_BASE --head=$NX_HEAD --max-warnings=300 + format: + name: Format + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 100 # TODO: This needs to include the merge-base + - uses: ./.github/actions/setup-node-ci + - name: Format + run: yarn nx format:check --verbose --base=$NX_BASE --head=$NX_HEAD + test: name: Test if: ${{ inputs.affected == true }} diff --git a/.prettierignore b/.prettierignore index 9e05637d04..fe93e19037 100644 --- a/.prettierignore +++ b/.prettierignore @@ -29,9 +29,10 @@ CLAUDE.md .claude/skills/*/references/ .agents/skills/*/references/ -# Native Android (Kotlin/Gradle). Workspace format only passes JS/TS/JSON/Markdown to Prettier -# (`tools:format`); these ignores remain so a manual whole-tree Prettier run cannot rewrite -# Kotlin. Markdown under android/ is still formatted. +# Native Android (Kotlin/Gradle). Prettier has no parser for these, and Kotlin formatting is +# owned by Android Studio's formatter until a Compose-aware linter is picked (see +# packages/cds-android/README.md). Markdown under android/ is deliberately not excluded, so the +# Gradle root's docs stay formatted like every other README. *.kt *.kts **/*.gradle* diff --git a/AGENTS.md b/AGENTS.md index 9735644599..a3793169f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,10 +40,10 @@ work. After writing code, validate the smallest relevant scope: - Node: run tests for the modified files, typecheck and lint the modified projects, then run - `yarn nx run tools:format` + `yarn nx format:write` - Gradle: run the changed project's `test` and `build` targets; Prettier does not format Kotlin - Xcode: run `cds-ios:test` and the changed project's `build` target; Prettier does not format Swift -- Documentation only: run `yarn nx run tools:format` and verify changed commands and links +- Documentation only: run `yarn nx format:write` and verify changed commands and links See [`docs/testing.md`](docs/testing.md) for exact commands. @@ -56,7 +56,7 @@ See [`docs/testing.md`](docs/testing.md) for exact commands. - `yarn nx run :build` - Build any project through its assigned toolchain - `yarn nx run :test` - Run tests for a specific project - `yarn nx run :test --testNamePattern=` - Run tests matching pattern -- `yarn nx run tools:format` - Formats JS, JSX, TypeScript, JSON, and Markdown with Prettier +- `yarn nx format:write` - Formats all files in the workspace with Prettier - `yarn nx run :lint` - Lint a specific project - `yarn nx run :typecheck` - Check for type errors in a specific project - `yarn nx run-many --target=,` - Run targets for all projects diff --git a/docs/ci.md b/docs/ci.md index ce16393d4f..feec246f09 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -2,8 +2,8 @@ [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) is the pull-request orchestrator. It classifies changed paths by toolchain, then always starts the Node, Android, and iOS lanes so -required checks are reported. Jobs inside each lane skip when that toolchain is unaffected. Format -is a separate workspace-level job that always runs. +required checks are reported. Jobs inside each lane skip when that toolchain is unaffected, except +Node's format check, which always runs. ## Toolchain tags @@ -22,8 +22,8 @@ The orchestrator determines whether Node, Gradle, or Xcode paths changed: - The [Node workflow](../.github/workflows/node.yml) is always called so required `Node / *` checks are reported. When Node paths changed, its Linux jobs use `nx affected` plus - `toolchain:node`. When Node is unaffected, each Node job is skipped; GitHub treats skipped jobs - as passing. + `toolchain:node`. When Node is unaffected, each Node job is skipped except format, which always + runs `yarn nx format:check` so docs, skills, and other non-Node files still get checked. - The [Android workflow](../.github/workflows/android.yml) is always called so required `Android / *` checks are reported. When Gradle paths changed, it builds and tests the Android library and demo app with JDK 21 and the Android SDK. When they did not, its jobs skip. @@ -31,21 +31,19 @@ The orchestrator determines whether Node, Gradle, or Xcode paths changed: are reported. When Xcode paths changed, it builds and tests the Swift library and builds the gallery on macOS. When they did not, its jobs skip. -**Format** is not a language toolchain. It always runs from `ci.yml` via -`yarn nx run tools:format:check`. That Nx target is the monorepo format entry point: Prettier on -JS, JSX, TypeScript, JSON, and Markdown today. Additional language formatters should be chained on -the same target rather than added to Node, Gradle, or Xcode workflows. - This keeps toolchains isolated while preserving dependency-aware validation: - A web-only change does not run React Native, Android, or iOS work (those jobs skip). - A change to a shared Node dependency may affect multiple dependent Node projects, such as both web and React Native packages. -- An Android-only change runs the Gradle lane; Node and iOS jobs skip. Format still runs. -- An iOS-only change runs the Xcode lane; Node and Android jobs skip. Format still runs. +- An Android-only change runs the Gradle lane; Node and iOS jobs skip except Node's format check, + which still runs. +- An iOS-only change runs the Xcode lane; Node and Android jobs skip except Node's format check, + which still runs. - Changes under `docs/`, `.claude/`, `.agents/`, `skills/`, and root-level markdown that do not - belong to a Node project do not start Node, Gradle, or Xcode on their own. Format still runs. + belong to a Node project do not start Node, Gradle, or Xcode on their own; Node's format check + still runs. - Changes to centralized CI or Nx classification files safely enable all toolchains. -Manually dispatching `ci.yml` enables every toolchain and Format. The reusable Android and iOS -workflows can also be dispatched independently for toolchain-specific reruns. +Manually dispatching `ci.yml` enables every toolchain. The reusable Android and iOS workflows can +also be dispatched independently for toolchain-specific reruns. diff --git a/docs/nx.md b/docs/nx.md index e310136525..78b002858c 100644 --- a/docs/nx.md +++ b/docs/nx.md @@ -15,8 +15,9 @@ Nx 20 cannot use the tags to select `targetDefaults`, but CI already depends on 1. The change classifier maps a changed project root to its `toolchain:*` tag and decides which of the Node, Gradle, or Xcode workflows should execute work. Every lane is still invoked so - required checks report; unaffected lanes skip their jobs. Documentation and skill files do - not start a language toolchain; workspace Format still runs. See [`docs/ci.md`](ci.md). + required checks report; unaffected lanes skip their jobs, except Node's format check, which + always runs. Documentation and skill files do not start a language toolchain. See + [`docs/ci.md`](ci.md). 2. The Node workflow positively filters `nx affected` to `toolchain:node`, preventing native `build` and `test` targets from running on Node-only Linux jobs. 3. The validator rejects missing or conflicting tags so a new project cannot silently enter the diff --git a/docs/testing.md b/docs/testing.md index a8824deec7..a5cf63bb82 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -22,7 +22,7 @@ When iterating, narrow Jest tests with `--testNamePattern=`. Before han format the workspace: ```sh -yarn nx run tools:format +yarn nx format:write ``` To validate all affected Node projects locally, the CI-equivalent pattern is: @@ -64,12 +64,5 @@ release validation. Prettier does not format Swift; follow Xcode's formatter. ## Documentation-only changes -Run `yarn nx run tools:format`. CI runs the same workspace format check on every PR, independent of -Node, Gradle, and Xcode. If commands, links, or setup steps changed, verify them against the +Run `yarn nx format:write`. If commands, links, or setup steps changed, verify them against the relevant project configuration or package-local guide. - -The CI-equivalent format check is: - -```sh -yarn nx run tools:format:check -``` diff --git a/packages/cds-android/docs/README.md b/packages/cds-android/docs/README.md index 170334351e..333c59ef71 100644 --- a/packages/cds-android/docs/README.md +++ b/packages/cds-android/docs/README.md @@ -5,13 +5,13 @@ Guides for teams building Android apps on the Coinbase Design System. | Guide | Read it when | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | [Using theme tokens](using-tokens.md) | You're building UI on CDS: reading tokens in a composable, and carrying them through your own state and logic. **Start here.** | -| [Button](button.md) | You're using the CDS call-to-action control: variants, sizes, states, icon slots, and layout. | | [Interaction affordances](interaction.md) | You're building a custom interactive composable that should match CDS press/hover/focus behavior. | | [Creating a custom theme](custom-themes.md) | You want CDS components to render in your brand's colors, spacing, type, or shape. | | [Theme token reference](token-reference.md) | You need the full list of token names, or the default values behind them. | | [Publishing a version](releasing.md) | You're cutting a GitHub Release of the AAR: version bump, changelog, build, and `gh release create`. Maintainers only. | -For API-level detail on a specific type, see its KDoc. For the internal rationale behind the token +Component APIs (`Button`, future composables) are documented in **KDoc** on the public types — this +folder covers cross-cutting package guides only. For the internal rationale behind the token layer's design (why the builder DSL, why `equals` is hand-written, how ABI compatibility is enforced), see [`../src/main/java/com/coinbase/cds/theme/README.md`](../src/main/java/com/coinbase/cds/theme/README.md). diff --git a/packages/cds-android/docs/button.md b/packages/cds-android/docs/button.md deleted file mode 100644 index 8290c0491e..0000000000 --- a/packages/cds-android/docs/button.md +++ /dev/null @@ -1,146 +0,0 @@ -# Button - -CDS's primary call-to-action control for Jetpack Compose. - -## Quick start - -```kotlin -Button( - text = "Confirm", - onClick = ::confirm, - modifier = Modifier.fillMaxWidth(), -) -``` - -Wrap your app in [CdsThemeProvider](using-tokens.md) first. [Button] reads variant colors, spacing, -radius, and typography from the ambient theme automatically. - -## Scope - -The Android API is intentionally narrower than React Native in a few places: - -| Mobile (RN) | Android CDS | Notes | -| -------------------------------------------------- | ---------------------------- | ------------------------------------------------- | -| `children` (any React node) | `text: String` | Label text only; matches the iOS native API shape | -| `start` / `end` slots | `startIcon` / `endIcon` only | Generic node slots are not supported yet | -| `block` / `fullWidth` | `Modifier.fillMaxWidth()` | Layout is caller-controlled via [modifier] | -| `style` / color overrides | — | Re-theme via [cdsTheme](custom-themes.md) | -| Deprecated props (`compact`, `foregroundMuted`, …) | — | Omitted by design | - -## Variants and sizes - -```kotlin -Button(text = "Primary", onClick = {}) -Button(text = "Secondary", onClick = {}, variant = ButtonVariant.Secondary) -Button(text = "Inverse", onClick = {}, variant = ButtonVariant.Inverse) -Button(text = "Ghost", onClick = {}, transparent = true) -Button(text = "Small", onClick = {}, size = ButtonSize.S) -``` - -| [ButtonVariant] | Filled container | Filled content | Transparent content | -| --------------- | ---------------- | -------------- | ------------------- | -| `Primary` | `bgPrimary` | `fgInverse` | `fgPrimary` | -| `Secondary` | `bgSecondary` | `fg` | `fg` | -| `Tertiary` | `bgTertiary` | `fg` | `fg` | -| `Positive` | `bgPositive` | `fgInverse` | `fgPositive` | -| `Negative` | `bgNegative` | `fgInverse` | `fgNegative` | -| `Inverse` | `bgInverse` | `fgInverse` | `fg` | - -Sizes (`Xs`, `S`, `M`, `L`) map to the same padding, radius, icon, and font tokens as web and mobile. - -## States - -```kotlin -Button(text = "Disabled", onClick = {}, enabled = false) -Button(text = "Loading", onClick = {}, loading = true) -``` - -- **Disabled** — non-interactive, reduced opacity via [CdsInteractionDefaults.DisabledAlpha]. -- **Loading** — replaces label and icons with an indeterminate spinner; blocks clicks; exposes loading - progress semantics while retaining the button label as the content description. - -## Icon slots - -Icon names are not part of the Android CDS API yet. Pass composable slots that receive the -resolved tint and icon size: - -```kotlin -Button( - text = "Back", - onClick = ::goBack, - startIcon = { tint, size -> - Icon(painterResource(R.drawable.ic_back), contentDescription = null, tint = tint, modifier = Modifier.size(size)) - }, -) -``` - -## Layout and customization - -Use standard Compose modifiers instead of React Native-style layout props: - -| Need | Compose approach | -| ------------------ | ------------------------------------------------------------------- | -| Full width | `modifier = Modifier.fillMaxWidth()` | -| Test hook (RN `testID`) | `modifier = Modifier.testTag("confirm")` on the button root | -| Custom semantics | `modifier = Modifier.semantics { … }` merged with Button's defaults | -| Offset / alignment | `Modifier.offset`, parent `Row`/`Column` arrangement | - -### UI testing (Compose UI Test and Maestro) - -Button does not take a `testID` parameter. Pass a tag through [modifier] on the root composable — the same node that owns `Role.Button`, `contentDescription`, and click handling. - -**In-process tests** (Robolectric / `createComposeRule`): - -```kotlin -Button( - text = "Confirm", - onClick = ::confirm, - modifier = Modifier.testTag("checkout-confirm"), -) - -composeRule.onNodeWithTag("checkout-confirm").performClick() -``` - -**Maestro** (black-box) follows [Jetpack Compose guidance](https://docs.maestro.dev/get-started/supported-platform/android/jetpack): prefer visible text when unique (`tapOn: "Confirm"`), then accessibility description, then `id` for duplicates or stable anchors. For `id`, the host app must enable resource-id mapping once near the root: - -```kotlin -Modifier.semantics { testTagsAsResourceId = true } -``` - -`apps/android-app` does this in `MainActivity`. Then Maestro can target: - -```yaml -- tapOn: - id: checkout-confirm -``` - -Loading buttons keep [text] in `contentDescription`, so `tapOn: { description: "Submit" }` continues to work while the label is hidden. - -Raw color, padding, or style object overrides are intentionally absent. Re-theme via -[cdsTheme](custom-themes.md). - -## Interaction - -Press, hover, and keyboard-focus feedback come from [CdsInteractionDefaults](interaction.md), -wired through a hoisted [MutableInteractionSource]: - -```kotlin -val interactionSource = remember { MutableInteractionSource() } -val isPressed by interactionSource.collectIsPressedAsState() - -Button( - text = "Confirm", - onClick = ::confirm, - interactionSource = interactionSource, -) -``` - -Pass the same [interactionSource] you observe — Button forwards it to `hoverable`, `focusable`, and -`clickable` so press, hover, and focus events are all available to customer logic. - -[Button]: ../src/main/java/com/coinbase/cds/components/button/Button.kt -[ButtonVariant]: ../src/main/java/com/coinbase/cds/components/button/Button.kt -[CdsInteractionDefaults.DisabledAlpha]: ../src/main/java/com/coinbase/cds/interaction/CdsInteractionDefaults.kt -[MutableInteractionSource]: https://developer.android.com/reference/kotlin/androidx/compose/foundation/interaction/MutableInteractionSource -[interactionSource]: ../src/main/java/com/coinbase/cds/components/button/Button.kt -[modifier]: ../src/main/java/com/coinbase/cds/components/button/Button.kt diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt index 90b342cb8b..bdb57f7002 100644 --- a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt +++ b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt @@ -59,7 +59,8 @@ public enum class ButtonSize { * pass `modifier = Modifier.testTag("confirm")` — the tag is applied on this root `Row` alongside * button semantics and gestures. Maestro can select it with `id:` when the host app enables * `testTagsAsResourceId` at the activity root; prefer matching visible label text when unique. - * See `packages/cds-android/docs/button.md` and the cds-rn-to-compose `ui-testing` reference. + * For shared interaction patterns see `packages/cds-android/docs/interaction.md`. For Maestro and + * `testTag` conventions see the cds-rn-to-compose `ui-testing` reference. * * @param transparent Renders on the plain page background with variant-colored text instead of a * filled, variant-colored container — CDS's lower-emphasis "ghost" treatment. diff --git a/prettier.config.js b/prettier.config.js index 2744089988..9c8b8f963c 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -1,6 +1,4 @@ module.exports = { - // Workspace format only passes JS, JSX, TypeScript, JSON, and Markdown to Prettier - // (`tools:format`). Chain other formatters on that Nx target when they are added. arrowParens: 'always', bracketSameLine: false, jsxSingleQuote: false, diff --git a/tools/ci/toolchains.mjs b/tools/ci/toolchains.mjs index 45cc5738a1..4f0a4abbcb 100644 --- a/tools/ci/toolchains.mjs +++ b/tools/ci/toolchains.mjs @@ -17,8 +17,7 @@ const gradlePathPrefixes = [ const xcodePathPrefixes = ['.github/workflows/ios.yml', 'ios/']; -// Language-agnostic trees. Format still runs at the workspace root; these paths -// must not start Node, Gradle, or Xcode on their own. +// Language-agnostic trees. These paths must not start Node, Gradle, or Xcode on their own. const docsOnlyPathPrefixes = ['docs/', '.claude/', '.agents/', 'skills/']; const docsMarkdownExtensions = ['.md', '.mdx']; @@ -81,7 +80,7 @@ export function classifyToolchains(changedFiles, projects = []) { } else if (projectToolchain) { result[projectToolchain] = true; } else if (isDocsOnlyPath(file) || isUnmatchedMarkdown(file)) { - // Workspace format covers these; they are not a language toolchain. + // Not a language toolchain. } else { result.node = true; } diff --git a/tools/project.json b/tools/project.json index 54686fa17f..ffdea16cfa 100644 --- a/tools/project.json +++ b/tools/project.json @@ -7,29 +7,6 @@ "toolchain:node" ], "targets": { - "format": { - "executor": "nx:run-commands", - "options": { - "commands": [ - "yarn prettier --write --cache \"**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json,md,mdx}\"" - ], - "parallel": false, - "cwd": "." - }, - "configurations": { - "check": { - "commands": [ - "yarn prettier --check \"**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json,md,mdx}\"" - ] - } - }, - "inputs": [ - "{workspaceRoot}/**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json,md,mdx}", - "{workspaceRoot}/prettier.config.js", - "{workspaceRoot}/.prettierignore" - ], - "cache": true - }, "test": { "executor": "@nx/jest:jest", "options": { From d653f64c8e131e849cc14dc1bd7939ba4f9f6db6 Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Thu, 10 Sep 2026 11:25:38 -0500 Subject: [PATCH 6/7] fix format --- .claude/skills/cds-rn-to-compose/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/skills/cds-rn-to-compose/SKILL.md b/.claude/skills/cds-rn-to-compose/SKILL.md index c46de9fc1d..7d3e6cb7cf 100644 --- a/.claude/skills/cds-rn-to-compose/SKILL.md +++ b/.claude/skills/cds-rn-to-compose/SKILL.md @@ -217,10 +217,10 @@ Follow [Compose accessibility](https://developer.android.com/develop/ui/compose/ RN `testID` maps to **`modifier = Modifier.testTag("…")`** on the component root — do not add a separate `testID` parameter. Full rules: `references/ui-testing.md`. -| Tool | How callers tag | How tests select | -| ---- | --------------- | ---------------- | -| Robolectric / `createComposeRule` | `modifier.testTag("confirm")` | `onNodeWithTag("confirm")` | -| Maestro (black-box) | same `testTag` on composables | `tapOn: { id: "confirm" }` **after** app enables `Modifier.semantics { testTagsAsResourceId = true }` once near the root | +| Tool | How callers tag | How tests select | +| --------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Robolectric / `createComposeRule` | `modifier.testTag("confirm")` | `onNodeWithTag("confirm")` | +| Maestro (black-box) | same `testTag` on composables | `tapOn: { id: "confirm" }` **after** app enables `Modifier.semantics { testTagsAsResourceId = true }` once near the root | **Maestro selector priority** ([Jetpack Compose guide](https://docs.maestro.dev/get-started/supported-platform/android/jetpack)): prefer visible **text**, then **accessibility description** (`contentDescription`), then **`id`** (`testTag`) for duplicates, lists, or visreg anchors. @@ -249,7 +249,7 @@ yarn nx run cds-android:build | ---------------------- | ---------------------------------------- | ---------------------------------------------------------------------- | | Pure resolvers | JUnit | `resolveButtonColors`, `resolveCdsInteractionVisualState` priority | | Composition & behavior | Robolectric + `createComposeRule()` | click invokes callback, disabled blocks click, semantics | -| Test tags | Robolectric + `onNodeWithTag` | caller `Modifier.testTag` on root is queryable | +| Test tags | Robolectric + `onNodeWithTag` | caller `Modifier.testTag` on root is queryable | | Interaction production | Robolectric + `MutableInteractionSource` | press emits `PressInteraction.Press`; add when component hoists source | | Icon slots | Capture lambda args | tint Color and size Dp passed to slots | From 1badf09feeff6d277fee529afdbded48f8b47b0e Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Wed, 23 Sep 2026 17:21:59 -0500 Subject: [PATCH 7/7] Match master's Node CI format-job gating and docs exactly. Drop the branch-only tweak that let format run on docs/skills-only PRs; keep node.yml and the CI docs identical to master's language-arm behavior instead of carrying an arbitrary local divergence. Co-Authored-By: Claude --- .github/workflows/node.yml | 1 + docs/ci.md | 16 ++++++---------- docs/nx.md | 5 ++--- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index a9913f19a5..4e3ae84e1b 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -75,6 +75,7 @@ jobs: format: name: Format + if: ${{ inputs.affected == true }} runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) diff --git a/docs/ci.md b/docs/ci.md index feec246f09..65c2b8c218 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -2,8 +2,7 @@ [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) is the pull-request orchestrator. It classifies changed paths by toolchain, then always starts the Node, Android, and iOS lanes so -required checks are reported. Jobs inside each lane skip when that toolchain is unaffected, except -Node's format check, which always runs. +required checks are reported. Jobs inside each lane skip when that toolchain is unaffected. ## Toolchain tags @@ -22,8 +21,8 @@ The orchestrator determines whether Node, Gradle, or Xcode paths changed: - The [Node workflow](../.github/workflows/node.yml) is always called so required `Node / *` checks are reported. When Node paths changed, its Linux jobs use `nx affected` plus - `toolchain:node`. When Node is unaffected, each Node job is skipped except format, which always - runs `yarn nx format:check` so docs, skills, and other non-Node files still get checked. + `toolchain:node`. Format (`yarn nx format:check`) runs in that workflow when Node is selected. + When Node is unaffected, each Node job is skipped; GitHub treats skipped jobs as passing. - The [Android workflow](../.github/workflows/android.yml) is always called so required `Android / *` checks are reported. When Gradle paths changed, it builds and tests the Android library and demo app with JDK 21 and the Android SDK. When they did not, its jobs skip. @@ -36,13 +35,10 @@ This keeps toolchains isolated while preserving dependency-aware validation: - A web-only change does not run React Native, Android, or iOS work (those jobs skip). - A change to a shared Node dependency may affect multiple dependent Node projects, such as both web and React Native packages. -- An Android-only change runs the Gradle lane; Node and iOS jobs skip except Node's format check, - which still runs. -- An iOS-only change runs the Xcode lane; Node and Android jobs skip except Node's format check, - which still runs. +- An Android-only change runs the Gradle lane; Node and iOS jobs skip. +- An iOS-only change runs the Xcode lane; Node and Android jobs skip. - Changes under `docs/`, `.claude/`, `.agents/`, `skills/`, and root-level markdown that do not - belong to a Node project do not start Node, Gradle, or Xcode on their own; Node's format check - still runs. + belong to a Node project do not start Node, Gradle, or Xcode on their own. - Changes to centralized CI or Nx classification files safely enable all toolchains. Manually dispatching `ci.yml` enables every toolchain. The reusable Android and iOS workflows can diff --git a/docs/nx.md b/docs/nx.md index 78b002858c..11a41b1143 100644 --- a/docs/nx.md +++ b/docs/nx.md @@ -15,9 +15,8 @@ Nx 20 cannot use the tags to select `targetDefaults`, but CI already depends on 1. The change classifier maps a changed project root to its `toolchain:*` tag and decides which of the Node, Gradle, or Xcode workflows should execute work. Every lane is still invoked so - required checks report; unaffected lanes skip their jobs, except Node's format check, which - always runs. Documentation and skill files do not start a language toolchain. See - [`docs/ci.md`](ci.md). + required checks report; unaffected lanes skip their jobs. Documentation and skill files do + not start a language toolchain. See [`docs/ci.md`](ci.md). 2. The Node workflow positively filters `nx affected` to `toolchain:node`, preventing native `build` and `test` targets from running on Node-only Linux jobs. 3. The validator rejects missing or conflicting tags so a new project cannot silently enter the