diff --git a/Alfie/AlfieKit/Sources/SharedUI/Components/Chips/Chip.swift b/Alfie/AlfieKit/Sources/SharedUI/Components/Chips/Chip.swift index 161a4a04..4ef56c1a 100644 --- a/Alfie/AlfieKit/Sources/SharedUI/Components/Chips/Chip.swift +++ b/Alfie/AlfieKit/Sources/SharedUI/Components/Chips/Chip.swift @@ -42,33 +42,33 @@ public struct ChipConfiguration { } public struct Chip: View { - private enum Constants { - static let heightSmall = 36.0 - static let heightLarge = 44.0 - static let closeWidth = 12.0 - static let closeHeight = 12.0 - static let borderNormal = 1.0 - static let borderSelected = 2.0 - static let maxCounter: Int = 99 - } - private let configuration: ChipConfiguration public init(configuration: ChipConfiguration) { self.configuration = configuration } + private var style: ChipStyle { + ChipStyle( + type: configuration.type, + isSelected: configuration.isSelected, + isDisabled: configuration.isDisabled, + counter: configuration.counter + ) + } + public var body: some View { - ZStack { + let style = self.style + return ZStack { RoundedRectangle(cornerRadius: Sizing.radiusRounded) - .stroke(borderColor, lineWidth: borderWidth) - .background(RoundedRectangle(cornerRadius: Sizing.radiusRounded).fill(backgroundColor)) + .stroke(style.borderColor, lineWidth: style.borderWidth) + .background(RoundedRectangle(cornerRadius: Sizing.radiusRounded).fill(style.backgroundColor)) HStack(spacing: Primitives.Spacing.spacing8) { Text.build(theme.font.body.small(configuration.label)) - .foregroundStyle(textColor) - if let counterLabel { + .foregroundStyle(style.textColor) + if let counterLabel = style.counterLabel { Text.build(theme.font.body.small(counterLabel)) - .foregroundStyle(textColor) + .foregroundStyle(style.textColor) } if configuration.showCloseButton { Button(action: { @@ -79,8 +79,8 @@ public struct Chip: View { .renderingMode(.template) .resizable() .scaledToFit() - .frame(width: Constants.closeWidth, height: Constants.closeHeight) - .foregroundStyle(textColor) + .frame(width: ChipStyle.closeWidth, height: ChipStyle.closeHeight) + .foregroundStyle(style.textColor) } .frame(maxHeight: .infinity) }) @@ -91,46 +91,71 @@ public struct Chip: View { } .fixedSize(horizontal: true, vertical: false) .padding(.horizontal, Primitives.Spacing.spacing16) - .frame(height: chipHeight) + .frame(height: style.height) } +} + +/// Resolves a `Chip`'s visual styling from its state, sourced from design tokens. +/// Extracted from `Chip` so the state→token mapping is unit-testable without snapshots. +struct ChipStyle { + private enum Constants { + static let heightSmall = 36.0 + static let heightLarge = 44.0 + static let closeWidth = 12.0 + static let closeHeight = 12.0 + static let borderSelected = 2.0 + static let maxCounter: Int = 99 + } + + // Close-icon dimensions are fixed layout constants (state-independent), so they are exposed + // statically rather than as instance properties of this state resolver. + static let closeWidth = Constants.closeWidth + static let closeHeight = Constants.closeHeight + + let type: ChipConfiguration.ChipType + let isSelected: Bool + let isDisabled: Bool + let counter: Int? - private var borderColor: Color { - if configuration.isDisabled { - return Primitives.Colours.neutrals100 - } else if configuration.isSelected { - return Primitives.Colours.neutrals800 + var borderColor: Color { + if isDisabled { + // surfaceForegroundPrimary is the only Theme alias mapping to neutrals100 (grill decision). + return Theme.surfaceForegroundPrimary + } else if isSelected { + return Theme.contentContentPrimary } else { - return Primitives.Colours.neutrals200 + return Theme.borderSoft } } - private var textColor: Color { - if configuration.isDisabled { - return Primitives.Colours.neutrals400 + var textColor: Color { + if isDisabled { + return Theme.contentContentPrimaryDisabled } else { + // No Theme alias maps to neutrals600, so this remains a raw primitive. return Primitives.Colours.neutrals600 } } - private var backgroundColor: Color { - if configuration.isDisabled { - return Primitives.Colours.neutrals100 + var backgroundColor: Color { + if isDisabled { + return Theme.surfaceForegroundPrimary } else { - return Primitives.Colours.neutrals0 + return Theme.surfaceBackgroundPrimary } } - private var borderWidth: CGFloat { - if configuration.isSelected { + var borderWidth: CGFloat { + if isSelected { return Constants.borderSelected } else { - return Constants.borderNormal + return CGFloat(Primitives.Border.borderWeightDefault) } } - private var chipHeight: CGFloat { + var height: CGFloat { // swiftlint:disable vertical_whitespace_between_cases - switch configuration.type { + switch type { case .small: return Constants.heightSmall case .large: @@ -139,8 +164,8 @@ public struct Chip: View { // swiftlint:enable vertical_whitespace_between_cases } - private var counterLabel: String? { - guard let counter = configuration.counter else { + var counterLabel: String? { + guard let counter else { return nil } return counter > Constants.maxCounter ? "\(Constants.maxCounter)+" : "\(counter)" diff --git a/Alfie/AlfieKit/Tests/SharedUITests/ChipStyleTests.swift b/Alfie/AlfieKit/Tests/SharedUITests/ChipStyleTests.swift new file mode 100644 index 00000000..251b00a8 --- /dev/null +++ b/Alfie/AlfieKit/Tests/SharedUITests/ChipStyleTests.swift @@ -0,0 +1,139 @@ +import SwiftUI +import XCTest +@testable import SharedUI + +// Assertions pin the *resolved* Color value each state maps to (SwiftUI value-equality), not the +// token identity — same-valued Theme aliases are interchangeable as far as these tests can tell. +final class ChipStyleTests: XCTestCase { + // MARK: - Border color + + func test_border_color_is_soft_when_unselected_and_enabled() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: nil) + XCTAssertEqual(sut.borderColor, Theme.borderSoft) + } + + func test_border_color_is_content_primary_when_selected() { + let sut = ChipStyle(type: .small, isSelected: true, isDisabled: false, counter: nil) + XCTAssertEqual(sut.borderColor, Theme.contentContentPrimary) + } + + func test_border_color_is_surface_foreground_when_disabled() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: true, counter: nil) + XCTAssertEqual(sut.borderColor, Theme.surfaceForegroundPrimary) + } + + func test_border_color_disabled_takes_precedence_over_selected() { + let sut = ChipStyle(type: .small, isSelected: true, isDisabled: true, counter: nil) + XCTAssertEqual(sut.borderColor, Theme.surfaceForegroundPrimary) + } + + // MARK: - Text color + + func test_text_color_is_neutrals600_when_enabled() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: nil) + XCTAssertEqual(sut.textColor, Primitives.Colours.neutrals600) + } + + func test_text_color_is_content_primary_disabled_when_disabled() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: true, counter: nil) + XCTAssertEqual(sut.textColor, Theme.contentContentPrimaryDisabled) + } + + func test_text_color_is_unaffected_by_selection() { + let sut = ChipStyle(type: .small, isSelected: true, isDisabled: false, counter: nil) + XCTAssertEqual(sut.textColor, Primitives.Colours.neutrals600) + } + + func test_text_color_disabled_takes_precedence_over_selected() { + let sut = ChipStyle(type: .small, isSelected: true, isDisabled: true, counter: nil) + XCTAssertEqual(sut.textColor, Theme.contentContentPrimaryDisabled) + } + + // MARK: - Background color + + func test_background_color_is_surface_background_when_enabled() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: nil) + XCTAssertEqual(sut.backgroundColor, Theme.surfaceBackgroundPrimary) + } + + func test_background_color_is_surface_foreground_when_disabled() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: true, counter: nil) + XCTAssertEqual(sut.backgroundColor, Theme.surfaceForegroundPrimary) + } + + func test_background_color_is_unaffected_by_selection() { + let sut = ChipStyle(type: .small, isSelected: true, isDisabled: false, counter: nil) + XCTAssertEqual(sut.backgroundColor, Theme.surfaceBackgroundPrimary) + } + + func test_background_color_disabled_takes_precedence_over_selected() { + let sut = ChipStyle(type: .small, isSelected: true, isDisabled: true, counter: nil) + XCTAssertEqual(sut.backgroundColor, Theme.surfaceForegroundPrimary) + } + + // MARK: - Border width + + func test_border_width_normal_uses_border_weight_default_token() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: nil) + XCTAssertEqual(sut.borderWidth, CGFloat(Primitives.Border.borderWeightDefault)) + } + + func test_border_width_is_thicker_when_selected() { + let sut = ChipStyle(type: .small, isSelected: true, isDisabled: false, counter: nil) + XCTAssertEqual(sut.borderWidth, 2.0) + } + + func test_border_width_stays_thick_when_selected_and_disabled() { + let sut = ChipStyle(type: .small, isSelected: true, isDisabled: true, counter: nil) + XCTAssertEqual(sut.borderWidth, 2.0) + } + + func test_border_width_is_normal_when_unselected_and_disabled() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: true, counter: nil) + XCTAssertEqual(sut.borderWidth, CGFloat(Primitives.Border.borderWeightDefault)) + } + + // MARK: - Height + + func test_height_is_36_for_small() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: nil) + XCTAssertEqual(sut.height, 36.0) + } + + func test_height_is_44_for_large() { + let sut = ChipStyle(type: .large, isSelected: false, isDisabled: false, counter: nil) + XCTAssertEqual(sut.height, 44.0) + } + + // MARK: - Counter label + + func test_counter_label_is_nil_when_counter_is_nil() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: nil) + XCTAssertNil(sut.counterLabel) + } + + func test_counter_label_shows_value_within_max() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: 1) + XCTAssertEqual(sut.counterLabel, "1") + } + + func test_counter_label_shows_value_at_max() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: 99) + XCTAssertEqual(sut.counterLabel, "99") + } + + func test_counter_label_caps_over_max() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: 100) + XCTAssertEqual(sut.counterLabel, "99+") + } + + func test_counter_label_shows_zero() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: 0) + XCTAssertEqual(sut.counterLabel, "0") + } + + func test_counter_label_shows_negative_value() { + let sut = ChipStyle(type: .small, isSelected: false, isDisabled: false, counter: -1) + XCTAssertEqual(sut.counterLabel, "-1") + } +} diff --git a/Docs/Plans/ALFMOB-273-refactor-chip-tokens/_status.md b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/_status.md new file mode 100644 index 00000000..6d88f12c --- /dev/null +++ b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/_status.md @@ -0,0 +1,26 @@ +# ALFMOB-273 — Refactor Chip component to use design tokens + +- **Ticket:** [ALFMOB-273](https://mindera.atlassian.net/browse/ALFMOB-273) (Story, Major) +- **Base:** main +- **Branch:** ALFMOB-273-refactor-chip-tokens +- **Worktree:** .claude/worktrees/ALFMOB-273-refactor-chip-tokens +- **Target file:** Alfie/AlfieKit/Sources/SharedUI/Components/Chips/Chip.swift + +## Goal +Replace hardcoded `Colors.*`, `Spacing.*`, `CornerRadius.*`, and typography in `Chip` with +design-token-generated values. Selected/unselected states must render identically; no call-site +changes. Mirrors the Badge (ALFMOB-269) and ThemedButton (ALFMOB-271) token refactors. + +## Scope decision (user) +Adopt semantic `Theme.*` color aliases where they map cleanly + border-width token + unit tests. +Ticket premise stale (Chip already token-based); real work = semantic color migration + `borderNormal`→token + tests. + +## Phase checklist +- [x] Scout → scope.md +- [x] Plan → plan.md +- [x] Grill → hardened plan.md + grill.md +- [x] Approval gate (approved 2026-07-10) +- [x] Implement (ios-execute: verify ✅ build+unit, review ✅ no Critical/Important) +- [x] Commit (e097ae6 impl + tests) +- [x] PR → main (#94) +- [x] Ticket transition → Review diff --git a/Docs/Plans/ALFMOB-273-refactor-chip-tokens/grill.md b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/grill.md new file mode 100644 index 00000000..7866f886 --- /dev/null +++ b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/grill.md @@ -0,0 +1,24 @@ +# Grill: Refactor Chip to use design tokens + +**Plan**: Docs/Plans/ALFMOB-273-refactor-chip-tokens/plan.md **Ticket**: ALFMOB-273 **Date**: 2026-07-10 **Branch**: ALFMOB-273-refactor-chip-tokens + +## Decisions +| # | Decision | Recommended | Chosen | Plan change | +|---|---|---|---|---| +| Q1 | How to make state→style logic unit-testable | Extract `ChipStyle` type (mirrors `BadgeHelper`) | Extract `ChipStyle` type | D9 resolved; phase-1 step 1 = extraction | +| Q2 | disabled `neutrals100` (border+bg): raw vs name-mismatched alias | Keep raw primitive | Use `Theme.surfaceForegroundPrimary` alias | D3 & D7 → `Theme.surfaceForegroundPrimary` | +| Q3 | selected border `neutrals800`: which Theme alias (principle = don't use Primitives directly) | `Theme.contentContentPrimary` | `Theme.contentContentPrimary` | D2 → `Theme.contentContentPrimary` (was raw) | + +## Answered by the codebase (not asked) +- D1/D5/D6/D8 mappings — `Theme+Generated.swift:7,23,33` + `Primitives+Generated.swift:8` confirm each alias equals the exact primitive Chip uses today (`borderSoft`=neutrals200, `contentContentPrimaryDisabled`=neutrals400, `surfaceBackgroundPrimary`=neutrals0, `borderWeightDefault`=1) → zero visual change. +- D4 (default text neutrals600) stays raw — **no `Theme.*` alias exists at `neutrals600`** (grep of `Theme+Generated.swift` = 0 matches); routing it through the semantic layer would require a new generated token (out of scope). This is the sole remaining direct `Primitives` reference. +- D10 test framework = XCTest — `BadgeHelperTests.swift:2` uses XCTest + `@testable import SharedUI`; mirror the neighbour. +- "No call-site changes" achievable — scout: only `#Preview` + `ChipsDemoView.swift:17` construct `Chip`; public API untouched. +- Snapshots excluded — memory `project_snapshot_suite_disabled`: suite is out of target membership repo-wide. + +## Assumptions surfaced +- The refactor is a pure semantic/naming change: every `Theme.*` alias is a static forwarder to the same `Primitives.Colours.*`, so appearance is provably unchanged; tests assert the state→token mapping as a regression guard. +- `ChipStyle` is `internal` (not public) — introducing it does not alter Chip's public surface. + +## Still open (owner) +- None. diff --git a/Docs/Plans/ALFMOB-273-refactor-chip-tokens/phase-1-chip-tokens.md b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/phase-1-chip-tokens.md new file mode 100644 index 00000000..d8cebfad --- /dev/null +++ b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/phase-1-chip-tokens.md @@ -0,0 +1,41 @@ +## Phase 1: Chip token alignment + tests + +### Goal +`Chip` sources every available token (semantic `Theme.*` colors where the alias fits; border-normal +width from `Primitives.Border.borderWeightDefault`) with zero visual change and its state→style logic +covered by unit tests. + +### Acceptance criteria +- [ ] `ChipStyle` value type computes borderColor/textColor/backgroundColor/borderWidth/chipHeight/counterLabel from `(type, isSelected, isDisabled, counter)`. +- [ ] Color mapping matches the D1–D8 table in `plan.md` (D1/D2/D3/D5/D6/D7 via `Theme.*`; only D4 raw). +- [ ] `borderNormal` width comes from `Primitives.Border.borderWeightDefault` (D8); `borderSelected` stays `2.0`. +- [ ] `Chip.body` renders identically to current `main` for all state combos (verified by Preview + demo). +- [ ] Public API (`Chip(configuration:)`, `ChipConfiguration`, `ChipType`) byte-for-byte unchanged. +- [ ] `ChipStyleTests` covers every state→value mapping + counter-label boundaries; `verify.sh` green. + +### Steps +1. **Extract `ChipStyle`** (file: `Chip.swift:45-147`, size: S) — move `Constants`, `borderColor`, + `textColor`, `backgroundColor`, `borderWidth`, `chipHeight`, `counterLabel` into an `internal + struct ChipStyle` initialised from `(type, isSelected, isDisabled, counter)`. Keep values + identical for now (pure move). Why: creates a `@testable` seam mirroring `BadgeHelper`. +2. **Apply token mappings** (file: `Chip.swift`, size: XS) — in `ChipStyle`: D1 unselected border → + `Theme.borderSoft`; D2 selected border → `Theme.contentContentPrimary`; D3 disabled border → + `Theme.surfaceForegroundPrimary`; D5 disabled text → `Theme.contentContentPrimaryDisabled`; D6 default + bg → `Theme.surfaceBackgroundPrimary`; D7 disabled bg → `Theme.surfaceForegroundPrimary`; D8 normal + border width → `CGFloat(Primitives.Border.borderWeightDefault)`. Only D4 (default text) stays raw + `Primitives.Colours.neutrals600` — no `Theme` alias exists at that value. Why: user-approved semantic + adoption, no visual change. +3. **Rewire `Chip.body`** (file: `Chip.swift`, size: XS) — build one `ChipStyle` from + `configuration` and read its properties; delete the now-migrated private computed props. Why: + single source of styling truth; keeps `body` thin. +4. **Add `ChipStyleTests`** (file: `Alfie/AlfieKit/Tests/SharedUITests/ChipStyleTests.swift`, size: XS) — + XCTest + `@testable import SharedUI`; assert each state→token value and `counterLabel` boundaries + (nil→nil, 1→"1", 99→"99", 100→"99+"). Why: the coverage AC; regression guard on the mapping. + +### Checkpoint +- [x] `./Alfie/scripts/verify.sh --skip-integration` passes (build + unit; integration skipped — SharedUI-only change, no BFF surface). +- [x] All acceptance criteria above met (21 `ChipStyleTests` green). +- [ ] Manual: `#Preview` and `ChipsDemoView` render identically to `main` (refactor is provably no-op — every alias forwards to the same primitive). + +### Depends on +none diff --git a/Docs/Plans/ALFMOB-273-refactor-chip-tokens/plan.md b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/plan.md new file mode 100644 index 00000000..7587698f --- /dev/null +++ b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/plan.md @@ -0,0 +1,91 @@ +--- +title: Refactor Chip to use design tokens (semantic Theme.* + border token + tests) +ticket: ALFMOB-273 +status: completed +complexity: LOW +mode: auto +blockedBy: [] +blocks: [] +created: 2026-07-10 +--- + +## Overview +`Chip` already sources colors/spacing/radius/typography from generated tokens (the ticket's +"references legacy `Colors/Spacing/CornerRadius`" premise is stale). User-approved scope: (1) adopt +semantic `Theme.*` color aliases where the alias name genuinely matches the usage, (2) source +`borderNormal` width from `Primitives.Border.borderWeightDefault` (mirrors Badge ALFMOB-269), and +(3) add the missing unit-test coverage. Refactor is **visually a no-op** — every `Theme.*` alias +resolves to the same primitive Chip uses today. + +## Acceptance Criteria +- [ ] Chip sources all *available* token values (colors via `Theme.*` where a clean alias exists, else raw `Primitives.Colours.*`; border-normal width via `Primitives.Border.borderWeightDefault`). +- [ ] Selected / unselected / disabled states render **identically** to current `main` (no pixel change). +- [ ] No call-site changes (public API `Chip(configuration:)` / `ChipConfiguration` / `ChipType` unchanged; only `#Preview` + `ChipsDemoView` use Chip). +- [ ] State→style mapping + counter-label logic covered by SPM unit tests (snapshots excluded — suite disabled repo-wide). + +## Approach +Mirror the Badge pattern: extract a small **`ChipStyle`** value type (à la `BadgeHelper`) that +computes `borderColor`, `textColor`, `backgroundColor`, `borderWidth`, and `counterLabel` from +`(type, isSelected, isDisabled, counter)`. `Chip.body` consumes it. This puts the semantic-color +mapping in a plain, `@testable`-reachable type instead of private `View` computed props — the only +clean way to satisfy the coverage AC without snapshots. XCTest + `@testable import SharedUI` to +match the neighbouring `BadgeHelperTests`. + +### Semantic color mapping (the crux — DECISIONS for grill) +Chip's colors are all `Primitives.Colours.neutrals*`. Proposed mapping (migrate only where a +`Theme.*` alias name matches the role; else keep raw primitive): + +| # | Usage / state | Current | Final (grilled) | Note | +|---|---|---|---|---| +| D1 | border, unselected | `neutrals200` | `Theme.borderSoft` | ✅ clean border alias | +| D2 | border, selected | `neutrals800` | `Theme.contentContentPrimary` | name-mismatch alias accepted | +| D3 | border, disabled | `neutrals100` | `Theme.surfaceForegroundPrimary` | user chose alias over raw | +| D4 | text, default | `neutrals600` | keep raw `neutrals600` | **forced** — NO `Theme` alias at 600 | +| D5 | text, disabled | `neutrals400` | `Theme.contentContentPrimaryDisabled` | ✅ content-disabled | +| D6 | background, default | `neutrals0` | `Theme.surfaceBackgroundPrimary` | ✅ surfaceBackground | +| D7 | background, disabled | `neutrals100` | `Theme.surfaceForegroundPrimary` | user chose alias over raw | +| D8 | border-normal width | `1.0` literal | `CGFloat(Primitives.Border.borderWeightDefault)` | ✅ Badge precedent | + +Every `Theme.*` alias resolves to the exact primitive used today → **zero visual change**. +7 of 8 go through `Theme`; only D4 stays a raw primitive — `Theme` has no alias at `neutrals600`, +so routing it through the semantic layer is impossible without adding a generated token (out of scope). + +### Structure & tests (grilled) +- **D9 — RESOLVED:** extract an `internal struct ChipStyle` (mirrors `BadgeHelper`) as the `@testable` seam. +- **D10 — RESOLVED:** XCTest + `@testable import SharedUI`, matching `BadgeHelperTests.swift`. + +## Phases +Single vertical slice (one file + its test). See `phase-1-chip-tokens.md`. + +## File Changes (Summary Table) +| File | Module | Type | Change | Owner | +|---|---|---|---|---| +| `Alfie/AlfieKit/Sources/SharedUI/Components/Chips/Chip.swift` | SharedUI | edit | Extract `ChipStyle`; apply `Theme.*` aliases (D1/D5/D6) + border-weight token (D8) | - | +| `Alfie/AlfieKit/Tests/SharedUITests/ChipStyleTests.swift` | SharedUITests | new | Unit tests for state→style mapping + counter-label logic | - | + +## Feature Flag +n/a — pure component-internal refactor, no behavior change. + +## Testing Strategy +- **Unit (XCTest, `@testable import SharedUI`):** for each state combination assert `ChipStyle` + returns the expected token value — border (unselected/selected/disabled), text (default/disabled), + background (default/disabled), border width (normal/selected), height (small/large), and + `counterLabel` boundaries (`nil`→nil, `1`→"1", `99`→"99", `100`→"99+"). +- **Snapshot:** OUT — suite is disabled repo-wide (no target membership / refs). +- **Manual:** `#Preview` + `ChipsDemoView` visually unchanged. + +## Risks & Mitigations +| Risk | Likelihood | Mitigation | +|---|---|---| +| Semantic alias resolves to a *different* primitive → visual change | Low | Aliases verified against `Theme+Generated.swift`; tests assert exact token; only migrate exact-value matches | +| Partial migration looks inconsistent | Med | Documented per-case rationale; grill confirms; consistency with Badge's raw-primitive idiom | +| `Color` equality flaky in tests | Low | Aliases ARE the same static primitives → equality holds; assert against the primitive value | +| Extracting `ChipStyle` changes public API | Low | `ChipStyle` is `internal`; `Chip`/`ChipConfiguration`/`ChipType` signatures untouched | + +## Out of Scope +- Tokenizing `borderSelected` (2.0), `heightSmall/Large` (36/44), close-icon size (12) — no token equivalents exist. +- Any call-site or public-API change. +- Snapshot tests. + +## Open Questions +None — all decisions (D1–D10) resolved at grill. See `grill.md`. diff --git a/Docs/Plans/ALFMOB-273-refactor-chip-tokens/scope.md b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/scope.md new file mode 100644 index 00000000..3f7b3bb3 --- /dev/null +++ b/Docs/Plans/ALFMOB-273-refactor-chip-tokens/scope.md @@ -0,0 +1,46 @@ +# Scout Report: Chip token refactor (ALFMOB-273) + +**Branch**: ALFMOB-273-refactor-chip-tokens **Agents**: 3 + +## ⚠️ Ticket premise is stale +Ticket says Chip "directly references `Colors.*`, `Spacing.*`, `CornerRadius.*` constants". It does **not**. +SharedUI already fully migrated off legacy enums. `Chip.swift` today already uses: +- Colors → `Primitives.Colours.neutrals*` (state-driven computed props) +- Spacing → `Primitives.Spacing.spacing0/8/16` +- Corner radius → `Sizing.radiusRounded` +- Typography → `theme.font.body.small(...)` + +## Relevant Files +### Packages / modules +- `Alfie/AlfieKit/Sources/SharedUI/Components/Chips/Chip.swift` — the only impl; `Chip` + `ChipConfiguration` + `ChipType` all inline. +- `Alfie/AlfieKit/Sources/DebugMenu/UI/Demo/Components/ChipsDemoView.swift:17` — sole non-preview usage (debug demo). +- `Alfie/AlfieKit/Sources/SharedUI/GeneratedTokens/{Primitives,Sizing,Theme,Typography}+Generated.swift` — token API (read-only, generated). +### Reference refactors (mirror these) +- `Alfie/AlfieKit/Sources/SharedUI/Components/Indicators/BadgeViewModifier.swift` (ALFMOB-269) — raw `Primitives.Colours.*`, `Sizing.radiusRounded`, border width from `Primitives.Border.borderWeightDefault`. +- `Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/{ThemedButton,ButtonTheme}.swift` (ALFMOB-271) — per-state spec of primitives. +### Tests +- **None.** No unit or snapshot tests reference Chip. Snapshot suite is disabled repo-wide (no target membership / refs). + +## Call sites — AC "no call-site changes" is trivially safe +- `Chip(` only appears in the in-file `#Preview` (12×) and `ChipsDemoView.swift:17`. **Zero feature/production call sites.** +- Public API (`Chip(configuration:)`, `ChipConfiguration`, `ChipType.small/.large`) is untouched by any refactor of private internals. + +## Remaining hardcoded `Constants` (Chip.swift:45-53) vs token equivalents +| Constant | Value | Token equivalent? | +|---|---|---| +| `borderNormal` | 1.0 | ✅ `Primitives.Border.borderWeightDefault` (Int 1) — exact, mirrors Badge ALFMOB-269 | +| `borderSelected` | 2.0 | ❌ no token (would be an arbitrary `×2`) | +| `heightSmall / heightLarge` | 36 / 44 | ❌ no sizing token (icon sizes are 16/24/32/40) | +| `closeWidth / closeHeight` | 12 | ❌ no token (no `spacing12`; `iconsIconSmall` = 16) | +| `maxCounter` | 99 | n/a (logic, not styling) | + +## Colors — semantic `Theme.*` alias fit (if we chose to adopt them) +- unselected border `neutrals200` = `Theme.borderSoft` ✅ +- disabled text `neutrals400` = `Theme.contentContentPrimaryDisabled` ✅ +- default bg `neutrals0` = `Theme.surfaceBackgroundPrimary` ✅ +- **default text `neutrals600` → no Theme alias exists** ❌ (would force a partial/inconsistent migration) +- Badge/ThemedButton use raw `Primitives.Colours.*`, so raw primitives is the established idiom. + +## Conclusion +Real remaining work is small: `borderNormal` → `borderWeightDefault` token (1 line, Badge precedent). +Everything else the ticket lists is already tokenized or has no token equivalent. Test coverage is zero.