From a2ef611d1e3e124d52bf781c5c28bddc655e4724 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 12 Jun 2026 22:55:14 -0300 Subject: [PATCH 01/18] WIP checkpoint: round-1 fixes waves 1-2 (partial, pre-repair) --- .claude/sweeps/107/contrast-usages.md | 139 +++ .claude/sweeps/107/i18n-additions/chat.json | 10 + .claude/sweeps/107/round-1/01-i18n.md | 60 ++ .claude/sweeps/107/round-1/02-parity.md | 96 ++ .claude/sweeps/107/round-1/03-performance.md | 47 + .claude/sweeps/107/round-1/04-a11y.md | 115 +++ .claude/sweeps/107/round-1/05-security.md | 58 ++ .claude/sweeps/107/round-1/06-quality.md | 630 ++++++++++++++ .claude/sweeps/107/round-1/07-architecture.md | 126 +++ .claude/sweeps/107/round-1/08-contract.md | 29 + .claude/sweeps/107/round-1/09-tests.md | 53 ++ .claude/sweeps/107/round-1/10-deps.md | 61 ++ .claude/sweeps/107/round-1/11-design.md | 57 ++ .claude/sweeps/107/round-1/12-ux.md | 69 ++ .claude/sweeps/107/round-1/13-keyboard.md | 116 +++ .claude/sweeps/107/round-1/14-validation.md | 57 ++ .claude/sweeps/107/seed-findings.md | 25 + .claude/sweeps/107/triage-round-1.md | 63 ++ CLAUDE.md | 2 +- DESIGN.md | 10 +- apps/mobile/CLAUDE.md | 7 +- apps/mobile/__tests__/lib/theme.test.ts | 26 + .../__tests__/stores/chat-store.test.ts | 9 +- apps/mobile/app/chat.styles.ts | 4 +- apps/mobile/components/chat/action-chips.tsx | 4 +- .../components/chat/chat-animations.tsx | 9 +- .../components/chat/chat-composer-input.tsx | 3 + .../components/chat/chat-input-area.tsx | 7 +- .../mobile/components/chat/chat-input-bar.tsx | 2 + .../components/chat/clarification-card.tsx | 6 +- .../components/chat/conflict-warning.tsx | 4 +- .../chat/pending-operation-card.tsx | 2 +- .../components/chat/typing-indicator.tsx | 9 +- apps/mobile/components/message-bubble.tsx | 7 +- apps/mobile/components/ui/app-text-input.tsx | 2 +- apps/mobile/hooks/use-chat-composer.ts | 2 +- apps/mobile/lib/habit-mutation-helpers.ts | 4 +- apps/mobile/lib/habit-optimistic-helpers.ts | 49 +- apps/mobile/lib/theme.ts | 15 +- apps/mobile/package.json | 5 +- apps/mobile/stores/chat-store.ts | 6 - apps/mobile/tailwind.config.js | 40 - apps/web/CLAUDE.md | 21 +- apps/web/app/actions/calendar.ts | 2 +- apps/web/app/actions/profile.ts | 6 - apps/web/app/globals.css | 37 +- apps/web/app/layout.tsx | 10 +- .../components/ui/create-api-key-modal.tsx | 54 +- apps/web/components/ui/field-input.tsx | 2 +- apps/web/hooks/use-drill-navigation.ts | 6 +- apps/web/lib/habit-optimistic-helpers.ts | 48 +- apps/web/lib/theme-dom.ts | 3 +- apps/web/package.json | 24 +- package-lock.json | 822 ++---------------- package.json | 3 +- packages/shared/CLAUDE.md | 3 +- packages/shared/eslint.config.mjs | 36 + packages/shared/package.json | 3 + .../src/__tests__/advanced-settings.test.ts | 18 + .../shared/src/__tests__/api-key-form.test.ts | 52 ++ .../__tests__/breakdown-suggestion.test.ts | 154 ++++ .../src/__tests__/dismiss-guard-core.test.ts | 32 + .../src/__tests__/drill-navigation.test.ts | 53 +- .../shared/src/__tests__/endpoints.test.ts | 5 +- .../__tests__/error-utils-extended.test.ts | 40 - packages/shared/src/__tests__/factories.ts | 21 - .../src/__tests__/habit-optimistic.test.ts | 144 +++ .../src/__tests__/habit-reorder.test.ts | 15 - .../__tests__/habit-schedule-match.test.ts | 10 +- .../src/__tests__/highlight-text.test.ts | 48 + .../pending-notification-deletes.test.ts | 87 ++ .../src/__tests__/profile-navigation.test.ts | 42 + packages/shared/src/__tests__/query.test.ts | 6 - .../src/__tests__/related-surfaces.test.ts | 3 - .../src/__tests__/tag-selection-core.test.ts | 319 +++++++ packages/shared/src/__tests__/theme.test.ts | 68 +- packages/shared/src/__tests__/types.test.ts | 128 +-- packages/shared/src/__tests__/utils.test.ts | 35 +- .../shared/src/__tests__/validation.test.ts | 27 - packages/shared/src/api/endpoints.ts | 3 +- packages/shared/src/chat/index.ts | 8 +- packages/shared/src/chat/related-surfaces.ts | 2 +- packages/shared/src/chat/sse-stream.ts | 6 +- .../shared/src/hooks/chat-composer-core.ts | 2 - .../shared/src/hooks/dismiss-guard-core.ts | 28 + packages/shared/src/hooks/index.ts | 2 + .../shared/src/hooks/tag-selection-core.ts | 242 ++++++ packages/shared/src/i18n/en.json | 36 +- packages/shared/src/i18n/pt-BR.json | 36 +- packages/shared/src/index.ts | 4 - packages/shared/src/query/index.ts | 1 + packages/shared/src/query/keys.ts | 5 + packages/shared/src/theme/color-schemes.ts | 9 + packages/shared/src/theme/neutral-ramp.ts | 25 +- packages/shared/src/theme/types.ts | 1 + packages/shared/src/tour/tour-mock-data.ts | 3 - packages/shared/src/types/ai.ts | 35 +- packages/shared/src/types/calendar.ts | 10 +- packages/shared/src/types/chat.ts | 16 +- packages/shared/src/types/config.ts | 13 +- packages/shared/src/types/gamification.ts | 14 +- packages/shared/src/types/goal.ts | 33 +- packages/shared/src/types/habit.ts | 50 +- packages/shared/src/types/subscription.ts | 17 - packages/shared/src/types/sync.ts | 34 +- packages/shared/src/types/tour.ts | 4 +- .../shared/src/utils/advanced-settings.ts | 31 + packages/shared/src/utils/auth-login.ts | 2 - .../shared/src/utils/breakdown-suggestion.ts | 80 ++ packages/shared/src/utils/drill-navigation.ts | 28 + packages/shared/src/utils/error-utils.ts | 9 +- packages/shared/src/utils/fresh-start.ts | 2 +- packages/shared/src/utils/goal-metrics.ts | 8 +- .../shared/src/utils/habit-detail-summary.ts | 2 +- .../shared/src/utils/habit-emoji-options.ts | 3 +- .../shared/src/utils/habit-normalization.ts | 2 +- packages/shared/src/utils/habit-optimistic.ts | 54 ++ packages/shared/src/utils/habit-query.ts | 2 +- packages/shared/src/utils/habits.ts | 4 +- packages/shared/src/utils/highlight-text.ts | 37 + packages/shared/src/utils/index.ts | 33 +- packages/shared/src/utils/locale-format.ts | 1 - .../shared/src/utils/notification-time.ts | 2 +- packages/shared/src/utils/pagination.ts | 2 +- .../src/utils/pending-notification-deletes.ts | 89 ++ .../shared/src/utils/profile-navigation.ts | 30 + packages/shared/src/utils/streak-week.ts | 4 +- packages/shared/src/utils/tag-cache.ts | 2 +- packages/shared/src/utils/upgrade.ts | 1 - .../shared/src/validation/api-key-form.ts | 40 + packages/shared/src/validation/constants.ts | 1 + packages/shared/src/validation/habit-form.ts | 2 +- packages/shared/src/validation/index.ts | 3 + 133 files changed, 3885 insertions(+), 1660 deletions(-) create mode 100644 .claude/sweeps/107/contrast-usages.md create mode 100644 .claude/sweeps/107/i18n-additions/chat.json create mode 100644 .claude/sweeps/107/round-1/01-i18n.md create mode 100644 .claude/sweeps/107/round-1/02-parity.md create mode 100644 .claude/sweeps/107/round-1/03-performance.md create mode 100644 .claude/sweeps/107/round-1/04-a11y.md create mode 100644 .claude/sweeps/107/round-1/05-security.md create mode 100644 .claude/sweeps/107/round-1/06-quality.md create mode 100644 .claude/sweeps/107/round-1/07-architecture.md create mode 100644 .claude/sweeps/107/round-1/08-contract.md create mode 100644 .claude/sweeps/107/round-1/09-tests.md create mode 100644 .claude/sweeps/107/round-1/10-deps.md create mode 100644 .claude/sweeps/107/round-1/11-design.md create mode 100644 .claude/sweeps/107/round-1/12-ux.md create mode 100644 .claude/sweeps/107/round-1/13-keyboard.md create mode 100644 .claude/sweeps/107/round-1/14-validation.md create mode 100644 .claude/sweeps/107/seed-findings.md create mode 100644 .claude/sweeps/107/triage-round-1.md create mode 100644 packages/shared/eslint.config.mjs create mode 100644 packages/shared/src/__tests__/api-key-form.test.ts create mode 100644 packages/shared/src/__tests__/breakdown-suggestion.test.ts create mode 100644 packages/shared/src/__tests__/dismiss-guard-core.test.ts create mode 100644 packages/shared/src/__tests__/habit-optimistic.test.ts create mode 100644 packages/shared/src/__tests__/highlight-text.test.ts create mode 100644 packages/shared/src/__tests__/pending-notification-deletes.test.ts create mode 100644 packages/shared/src/__tests__/tag-selection-core.test.ts create mode 100644 packages/shared/src/hooks/dismiss-guard-core.ts create mode 100644 packages/shared/src/hooks/tag-selection-core.ts create mode 100644 packages/shared/src/utils/breakdown-suggestion.ts create mode 100644 packages/shared/src/utils/habit-optimistic.ts create mode 100644 packages/shared/src/utils/highlight-text.ts create mode 100644 packages/shared/src/utils/pending-notification-deletes.ts create mode 100644 packages/shared/src/validation/api-key-form.ts diff --git a/.claude/sweeps/107/contrast-usages.md b/.claude/sweeps/107/contrast-usages.md new file mode 100644 index 0000000000..213e615dd4 --- /dev/null +++ b/.claude/sweeps/107/contrast-usages.md @@ -0,0 +1,139 @@ +# Wave-2 contrast migrations — CANON (wave 1) handoff + +Source: `.claude/sweeps/107/round-1/04-a11y.md` Domain 7 + CANON computations (D4/D5/D6). The tokens below are LIVE on `chore/107-code-health-sweep` (shared theme + globals.css + mobile theme.ts). Wave-2 agents apply the per-component migrations listed here inside their own file ownership. All ratios are WCAG relative-luminance contrast. + +## Final computed token values + +### `--fg-on-primary` / `tokens.fgOnPrimary` (D5 — already wired; no component migration needed where components already consume the token) + +| Scheme | Mode | Accent | white on accent | ink #020618 on accent | Resolved value | +|---|---|---|---|---|---| +| purple | dark | #7f46f7 | 5.10 | 3.96 | **#ffffff** | +| purple | light | #631df2 | 7.03 | 2.87 | **#ffffff** | +| blue | dark | #2b7fff | 3.76 | 5.36 | **#020618** | +| blue | light | #155dfc | 5.25 | 3.84 | **#ffffff** | +| green | dark | #00c950 | 2.22 | 9.09 | **#020618** | +| green | light | #00a63e | 3.22 | 6.26 | **#020618** | +| rose | dark | #ff2056 | 3.75 | 5.37 | **#020618** | +| rose | light | #ec003f | 4.53 | 4.45 | **#ffffff** | +| orange | dark | #ff6900 | 2.89 | 6.98 | **#020618** | +| orange | light | #f54900 | 3.60 | 5.60 | **#020618** | +| cyan | dark | #00b8db | 2.37 | 8.52 | **#020618** | +| cyan | light | #0092b8 | 3.62 | 5.57 | **#020618** | + +Any component hardcoding `#fff`/`text-white` on a primary fill must switch to `var(--fg-on-primary)` / `tokens.fgOnPrimary` or it stays broken in the 8 flipped combos. + +### `--status-overdue-text` / `--status-bad-text` (D6 — new tokens; text usages must migrate) + +| Token | Dark | on bg / card | Light | on bg / white card | +|---|---|---|---|---| +| status-overdue-text | #fe9a00 (= base) | 9.44 / 8.49 | **#b45b00** (darkened from #e17100, hue 30.1°→30.3°) | 4.52 / 4.73 | +| status-bad-text | #fb2c36 (= base) | 5.29 / 4.76 | #e7000b (= base) | 4.56 / 4.77 | + +Mobile: `tokens.statusOverdueText` / `tokens.statusBadText`. Dots, rings, icons KEEP the base tokens (3:1 non-text passes everywhere). + +### Placeholder + fg-3 reference (D4 — central defaults already flipped to fg-3) + +| Color (purple) | dark on bg / card | light on bg / white | +|---|---|---| +| fg-4 (#62748e dark / #90a1b9 light) | 4.23 / 3.80 | 2.51 / 2.63 | +| fg-3 (#90a1b9 dark / #62748e light) | 7.66 / 6.89 | 4.55 / 4.76 | + +Already done centrally (wave 1): `apps/web/components/ui/field-input.tsx`, `apps/web/app/globals.css` `.form-input`, `apps/mobile/components/ui/app-text-input.tsx` default. + +## Migration rules + +1. fg-4 used as TEXT (any size in light mode; <24px in dark) → fg-3. fg-4 stays for decorative icons and disabled states (D4). +2. Status-colored TEXT → the `-text` tokens; status dots/rings/icons keep base (D6). +3. Explicit `fg4` placeholder overrides → fg-3 (or drop the prop where the central default now applies). + +## fg-4-as-text sites (04-a11y Domain 7 HIGH) + +### Upgrade / billing +- apps/web/app/(app)/upgrade/page.tsx:549,635,819 (fine print) +- apps/mobile/app/upgrade.tsx:473,705,870 + +### Auth / login +- apps/web/app/(auth)/login/email-step.tsx:67,92 +- apps/mobile/app/login.tsx:717,724 + +### Advanced settings +- apps/web/app/(app)/advanced/page.tsx:556,577 +- apps/mobile/app/advanced.tsx:307,407 + +### Streak / retrospective +- apps/web/app/(app)/streak/_components/streak-sections.tsx:96,141 +- apps/mobile/app/streak-sections.tsx:105 +- apps/web/app/(app)/retrospective/page.tsx:243,255 +- apps/mobile/app/retrospective.tsx:430,435 + +### Today / AI summary +- apps/web/components/habits/today-ai-summary.tsx:184 +- apps/mobile/components/habits/today-ai-summary.tsx:204 + +### Profile / API keys / notifications +- apps/web/components/ui/create-api-key-modal.tsx:466 +- apps/web/components/navigation/notification-bell.tsx:285 +- apps/web/components/navigation/notification-detail-modal.tsx:73 + +### Calendar (weekday headers) +- apps/web/components/calendar/calendar-grid.tsx:144 +- apps/web/components/habits/habit-calendar.tsx:139 +- apps/mobile/app/(tabs)/calendar.tsx:330 + +### Shared pickers (mobile) +- apps/mobile/components/ui/app-select.tsx:146 +- apps/mobile/components/ui/app-date-picker.tsx:253,318 +- apps/mobile/components/ui/app-time-picker.tsx:242 + +## Explicit fg-4 placeholder overrides (bypass the new central fg-3 defaults) + +### Web (`placeholder:text-[var(--fg-4)]` → `--fg-3`) +- apps/web/components/habits/create-habit-modal.tsx:290 +- apps/web/components/habits/checklist-templates.tsx:132 +- apps/web/components/habits/habit-form-fields.tsx:152,577 +- apps/web/components/habits/habit-checklist.tsx:280 +- apps/web/app/(app)/support/page.tsx:65 +- apps/web/components/goals/create-goal-modal.tsx:507 +- apps/web/components/goals/edit-goal-modal.tsx:377 + +### Mobile (`placeholderTextColor={tokens.fg4}` → `tokens.fg3`) +- apps/mobile/components/ui/bottom-sheet-app-text-input.tsx:101 (central default for sheet inputs — `?? tokens.fg4` → `?? tokens.fg3`) +- apps/mobile/components/ui/field-input.tsx:39 (mirror of web field-input — parity-critical) +- apps/mobile/app/(tabs)/profile.tsx:855,1016 +- apps/mobile/components/habits/habit-form-fields/habit-emoji-selector.tsx:119 +- apps/mobile/components/habits/habit-checklist.tsx:361 +- apps/mobile/components/habits/create-habit-modal/sub-habit-editor.tsx:66 +- apps/mobile/components/goals/create-goal-modal.tsx:229,301,321 +- apps/mobile/components/goals/edit-goal-modal.tsx:197 +- apps/mobile/components/goals/goal-detail-drawer/goal-progress-form.tsx:64 + +## Status base→`-text` migration sites (04-a11y Domain 7 MED; status color used as TEXT) + +### Habits / today list +- apps/web/components/habits/habit-row.tsx:266 (streak), 465 (Skip menu item), 619 (overdue meta token) +- apps/mobile/components/habits/habit-row.tsx:340,349 +- apps/web/components/habits/habit-list/date-group-section.tsx:66 (overdue group header) +- mobile date-group-section mirror :59 (under apps/mobile/components/habit-list/) + +### Badges (amber tone) +- apps/web/components/ui/badge.tsx:23-24 +- apps/mobile/components/ui/badge.tsx:56-57 + +### Goals +- apps/web/components/goals/goal-detail-sections.tsx:184 +- apps/web/components/goals/edit-goal-modal.tsx:251,395 +- apps/web/components/goals/create-goal-modal.tsx:381,523 + +### Calendar sync / warnings / misc +- apps/mobile/app/calendar-sync.tsx:552,567,742 + the web calendar-sync equivalents (apps/web/app/(app)/calendar-sync/page.tsx — locate amber text) +- apps/mobile/components/ui/expiry-warning.tsx:151,156 +- apps/mobile/app/(tabs)/profile.tsx:1060 +- apps/mobile/app/login.tsx:691 + +## Adjacent Domain-7 findings NOT covered by the new tokens (computed reference) + +- ConfirmDialog danger pill, white text on `--status-bad` fill: dark 3.81 (fails), light 4.77. Ink #020618 on dark bad fill = 5.29 (passes) — apps/web/components/ui/confirm-dialog.tsx:229-239 + apps/mobile/components/ui/confirm-dialog.tsx:239-241,253-257. Text-on-fill, so neither `-text` token applies; needs the D5 ink-flip pattern or a darker fill. +- Inactive bottom-tab icons use fg-4: light 2.51 < 3:1 non-text minimum — apps/web/components/navigation/bottom-tab-bar.tsx:126 + apps/mobile/components/navigation/bottom-tab-bar.tsx:144 (fg-3 light = 4.55, passes). +- Unchecked control boundaries drawn with fg-4 fail 3:1 in light: select-check (web:14, mobile:18), plan-card (web:84, mobile:65), habit-checklist (web:231,361, mobile:449,491), habit-form-fields.tsx:1470 (web), calendar-day-detail.tsx:110 (web), breakdown-suggestion (web:284, mobile:417). +- `--primary` as small text: purple dark 3.96, green light 3.08, orange light 3.44, cyan light 3.46, rose light 4.33 — no decision covers this; carry to the round-2 register if unaddressed by area agents. diff --git a/.claude/sweeps/107/i18n-additions/chat.json b/.claude/sweeps/107/i18n-additions/chat.json new file mode 100644 index 0000000000..14a5c5f6d0 --- /dev/null +++ b/.claude/sweeps/107/i18n-additions/chat.json @@ -0,0 +1,10 @@ +{ + "chat.offline.title": { + "en": "You're offline", + "pt": "Você está offline" + }, + "chat.offline.description": { + "en": "Astra needs an internet connection. Reconnect to keep chatting.", + "pt": "A Astra precisa de conexão com a internet. Reconecte para continuar conversando." + } +} diff --git a/.claude/sweeps/107/round-1/01-i18n.md b/.claude/sweeps/107/round-1/01-i18n.md new file mode 100644 index 0000000000..02f5c2e6c4 --- /dev/null +++ b/.claude/sweeps/107/round-1/01-i18n.md @@ -0,0 +1,60 @@ +# Sweep #1 — i18n parity + hardcoded strings (issue #107, round 1) + +## Scope swept + +Working tree (uncommitted changes included) of: + +- `orbit-ui-mobile`: `packages/shared/src/i18n/{en.json,pt-BR.json}` (deep structural diff via node, 1633 leaf keys each); all `.ts/.tsx` under `apps/web/{app,components,hooks,lib,stores}`, `apps/mobile/{app,components,hooks,lib,stores}`, `packages/shared/src` (tests excluded for hardcoded-string findings, included as reference corpus for orphan analysis). Scans: JSX text nodes (same-line + bare multi-line + inline `{'...'}` expressions), attribute literals (`placeholder`, `aria-label`, `alt`, `title`, `label`, `accessibilityLabel`, `accessibilityHint`, `confirmLabel`, etc.), toast/alert/error-state calls (`toast.*`, `Alert.alert`, `showError/showSuccess/showQueued`, `setError`, `setSendError`, `throw new Error`), navigation titles, default props, object-literal `label:/title:/message:` fields, weekday/month arrays, share/clipboard payloads, web metadata, public legal pages, service worker, `app.json`. +- `orbit-api` (branch `chore/107-code-health-sweep`, backend tail only): `Orbit.Api/Controllers/*`, `Orbit.Application/**`, `Orbit.Domain/**` for English display sentences flowing into response payloads (`Result.Failure`, inline `BadRequest/NotFound/Unauthorized` payloads, FluentValidation messages, `ErrorCodes`, `ResultActionResultExtensions`); LocaleHelper surfaces verified exempt. +- Orphan analysis method: every en.json key checked for quoted full-path/suffix/leaf references across the whole corpus, then every candidate manually verified against dynamic constructors (`habits.frequency.every${unit}` / `everyN${unit}s`, `preferences.color${Scheme}`, `upgrade.plans.proFeatures.${key}`, `upgrade.features.${key}.label|free|pro|tooltip`, `upgrade.categories.${cat}`, `profile.facts.${norm}` with `normalizeUserFactCategory`'s closed set, i18next plural suffixes) and against orbit-api (no backend-driven key references). Uncertain candidates were dropped. + +## Findings + +### Locale key gaps / parity + +- MED · packages/shared/src/i18n/en.json + pt-BR.json (key `profile.facts.default` missing from BOTH) · packages/shared/CLAUDE.md §i18n parity + apps/mobile/CLAUDE.md:11 · `apps/mobile/app/ai-settings.tsx:250` calls `` t(`profile.facts.${norm}`) `` and `normalizeUserFactCategory` (packages/shared/src/utils/user-facts.ts:14) returns `'default'` for unknown categories; key is undefined so pt-BR users see raw "DEFAULT". Add `profile.facts.default` to both locales. + +### Hardcoded user-facing strings (apps) + +- MED · apps/web/app/(app)/ai-settings/page.tsx:92 · apps/web/CLAUDE.md:12 ("All user-facing strings through i18n") · fact category chip renders `category.toUpperCase()` — raw English enum (PREFERENCE/ROUTINE/CONTEXT) — while mobile localizes via `` t(`profile.facts.${norm}`) `` (ai-settings.tsx:250). Use the same t() call; also a cross-platform parity break (root CLAUDE.md §Cross-platform parity). +- LOW · apps/web/app/(app)/calendar-sync/page.tsx:172 + apps/mobile/app/calendar-sync.tsx:502 · apps/web/CLAUDE.md:12 / apps/mobile/CLAUDE.md:11 · "Google Calendar" hardcoded as JSX text while key `calendar.title` exists and is used elsewhere; only "Orbit"/"Astra" are exempt brands. Replace with `t('calendar.title')`. +- LOW · apps/mobile/hooks/use-push-notifications.ts:328,489,495 · apps/mobile/CLAUDE.md:11 · hardcoded English in the hook's exported `error` display state ('Push token is unavailable on this device.', 'Failed to sync push unsubscription.'); currently no consumer renders `error`, but it is public display state. Store i18n keys (or t() at the boundary). +- LOW · apps/web/hooks/use-push-notification-preferences.ts:146 · apps/web/CLAUDE.md:12 · `throw new Error('Failed to subscribe to push notifications')` — mutation error message authored as display text. Use an i18n key. +- LOW · apps/web/components/goals/goal-list.tsx:169 · apps/web/CLAUDE.md:12 · `aria-roledescription="draggable item"` is announced by screen readers in English regardless of locale. Route through t(). +- LOW · apps/mobile/hooks/use-push-notifications.ts:178 · apps/mobile/CLAUDE.md:11 · Android notification channel `name: 'Default'` is shown in system notification settings; not localized. Localize at channel creation (`i18n.t`). + +### Orphan locale keys (defined in BOTH locales, zero static or plausible dynamic reference; en.json line cited, pt-BR mirrors) + +- LOW · packages/shared/src/i18n/en.json:110,130,146 · root CLAUDE.md Code Standards rule 2 (delete unused code) · `common.noResults`, `common.yearlyBadge`, `common.moreActions` unused. Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:72 · rule 2 · `notifications.deleteQueuedMultiple` unused (only `deleteQueued`/`deleteUndo` referenced; no plural-suffix or dynamic builder). Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:451,453 · rule 2 · `habits.form.templateName`, `habits.form.deleteTemplate` unused (only `templateNamePlaceholder` / `deleteTemplateError` referenced). Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:494 · rule 2 · `habits.detail.streakDays` ("{n}d") unused. Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:754,758,759,770,772 · rule 2 · `profile.facts.add`, `.factPlaceholder`, `.category`, `.confirmBulkDelete`, `.limitReached` unused; dynamic `` profile.facts.${norm} `` only reaches `preference|routine|context|default`. Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:996 · rule 2 · `trial.expired.subtitle` unused (UI uses `subtitleQuiet`; `TRIAL_EXPIRED_FEATURE_KEYS` does not include it). Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:1015 · rule 2 · `metrics.longestStreak` unused (no `metrics.` namespace usage; `habits.detail.longestStreak` / `streakDisplay.detail.longestStreak` are separate, used keys). Delete from both locales (check siblings `metrics.currentStreak`/`metrics.monthly` before removing the block — they matched quoted-leaf references and were NOT verified orphans). +- LOW · packages/shared/src/i18n/en.json:1306 · rule 2 · `upgrade.currentPlan` unused (leaf appears nowhere in either app). Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:1401-1409 · rule 2 · entire `upgrade.plans.free.*` block unused (`name`, `cta`, `features.habits`, `features.ai`, `features.theme`, `features.ads`); upgrade pages render the Free column via `upgrade.free` + `upgrade.features.${key}.free`, and no `plans.free` reference or dynamic constructor exists. Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:1420 · rule 2 · `upgrade.plans.yearly.recommended` unused (`yearly.name/period/cta/includesMonthly` are used; no dynamic path reaches `recommended`). Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:1434 · rule 2 · `upgrade.plans.proFeatures.allFeatures` unused — `` upgrade.plans.proFeatures.${feature.key} `` draws from the closed sets `UPGRADE_PRO_FEATURES`/`UPGRADE_YEARLY_EXTRA_FEATURES` (packages/shared/src/utils/upgrade.ts:72-86) which never contain `allFeatures`. Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:1561 · rule 2 · `goals.streak.daysRemaining` unused (only `goals.streak.ofTarget` referenced). Delete from both locales. +- LOW · packages/shared/src/i18n/en.json:1855 · rule 2 · `orbitMcp.keyCreated` unused (only `keyCreatedWarning` referenced). Delete from both locales. + +### Backend tail (orbit-api English display sentences in response payloads) + +- HIGH · orbit-api src/Orbit.Application + src/Orbit.Domain (101 sites) · issue #107 agent-1 (English display sentences in responses must be client-localized codes) · 101 code-less `Result.Failure("English sentence")` flow into `{ error }` payloads — e.g. LogHabitCommand.cs:90,93,100; SkipHabitCommand.cs:41,77,80,86,98; MoveHabitParentCommand.cs:37,47; CreateTagCommand.cs:24; UpdateTagCommand.cs:33; CreateUserFactCommand.cs:32; GetCalendarMonthQuery.cs:40; GetDailySummaryQuery.cs:39; GetRetrospectiveQuery.cs:47; SubscribePushCommand.cs:27,32,44; subscription commands/queries; Domain entity guards (ApiKey.cs:29-48, ChecklistTemplate.cs:18-48, Goal.cs:56+). ~47 frontend call sites display these verbatim via `getErrorMessage` (e.g. apps/web/app/(app)/calendar-sync/page.tsx:123, apps/mobile/app/(tabs)/profile.tsx:345) → pt-BR users see English errors. Fix: assign `ErrorCodes` and have clients map code→i18n key (machinery already exists: `extractBackendErrorCode` + `getFriendlyErrorKey` code branch). +- HIGH · orbit-api src/Orbit.Api/Controllers/AuthController.cs:48,97,158,224,277,325,347 · issue #107 agent-1 · auth endpoints serialize `new { error = result.Error }` and DROP `result.ErrorCode` even where codes exist (`TOO_MANY_ATTEMPTS`, `CODE_EXPIRED`, `INVALID_VERIFICATION_CODE` set in VerifyCodeCommand.cs:60,65,73); two sentences have no code at all (SendCodeCommand.cs:49 + RequestAccountDeletionCommand.cs:30 "Please wait before requesting a new code", ConfirmAccountDeletionCommand.cs:28, GoogleAuthCommand.cs:69,77). This forces the client to exact-match English copy (`AUTH_BACKEND_ERROR_MAP`, packages/shared/src/utils/auth-login.ts:3-9) — any backend wording change silently degrades localization to generic keys. Fix: include `errorCode` in auth error payloads + add missing codes; key the client off codes. +- MED · orbit-api src/Orbit.Api/Controllers (HabitsController.cs:164-542 ~12 sites; GoalsController.cs:53,151,165; ChecklistTemplatesController.cs:27,46,63; AiController.cs:221,258; ApiKeysController.cs:76; AuthController above) · root CLAUDE.md rule 1 (root cause over workarounds) + #107 · controllers hand-roll `new { error = result.Error }` although `ResultActionResultExtensions` (src/Orbit.Api/Extensions/ResultActionResultExtensions.cs:14-43) already emits `errorCode` — this bypass is why codes never reach the wire. Fix: route failure serialization through the shared extension. +- MED · orbit-api src/Orbit.Application (64 `.WithMessage(...)` sites in validators) · issue #107 agent-1 · FluentValidation English sentences flow to clients as field errors; the client localizes them by SUBSTRING-matching English (`CONTEXTUAL_RULES`, packages/shared/src/utils/error-utils.ts:205-250) and unmatched messages render raw English (`extractBackendError` prioritizes the first field error). Fix: stable error codes / validation keys instead of prose. +- LOW · orbit-api src/Orbit.Api/Controllers inline English payloads · issue #107 agent-1 · AiController.cs:181,288 ("Pending operation not found or expired."); CalendarController.cs:75; ChatController.cs:40,85,196,202,205,216,228,241,248; SyncController.cs:329,332; OAuthController.cs:189,197,202. Replace with codes (ErrorCodes already covers several, e.g. `MESSAGE_TOO_LONG`, `CHAT_HISTORY_TOO_LARGE`, `INVALID_GOOGLE_TOKEN`). Note: OAuthController.cs:253 `error`/`error_description` follows the OAuth 2.0 wire format (machine code present) — not counted. + +## Verified NOT findings (do not re-flag) + +- en/pt-BR key parity: PERFECT — 1633 keys each, zero missing either direction, no structural mismatches. The 9 identical en==pt values are brand/format strings (Google Calendar, Orbit Pro, Claude Web/Desktop/Code, `{current} / {next} XP`, `{previous} -> {value} {unit}`). +- Dynamic key families all resolve: `habits.frequency.every*` (habit-card-helpers.ts:105-108), `preferences.color*` (preferences pages + onboarding), `upgrade.features.*` / `upgrade.categories.*` / `upgrade.plans.proFeatures.*` (upgrade pages), `profile.facts.{preference,routine,context}`. +- `alt="Orbit"` (app-logo), "Astra" text node (today-ai-summary) — exempt brands. CHAT_SPEECH_LANGUAGES labels are endonyms (locale-invariant data). `days: ['Monday',...]` in tour-mock-data are API data values localized at render. Composed accessibility labels (`${title}. ${description}`, `${checkedCount}/${items.length}`) are i18n'd parts/data. google-auth.ts thrown messages never reach UI (resolveAuthLoginErrorKey maps to keys). test-mocks/date-fns month array is test infra. +- Web public pages (/terms, /privacy, /delete-account), not-found.tsx, toasts, Share/clipboard payloads, Zod validation messages, navigation titles: fully i18n'd — zero hardcoded display text found. +- orbit-api LocaleHelper surfaces (emails via ResendEmailService, push via ReminderSchedulerService/GoalDeadlineNotificationService/StreakFreezeAutoActivationService/AiSlipAlertMessageService, AI via AiSummaryService/AiRetrospectiveService/AiGoalReviewService, gamification achievement names PT/EN table) are server-localized — exempt per sweep brief. Webhook/RTDN handler messages (HandleWebhookCommand, HandlePlayNotificationCommand) respond to Stripe/Google, not users. + +## Verdict + +2 HIGH · 4 MED · 19 LOW (25 finding lines; orphan lines cover 25 dead keys) diff --git a/.claude/sweeps/107/round-1/02-parity.md b/.claude/sweeps/107/round-1/02-parity.md new file mode 100644 index 0000000000..b210e310a6 --- /dev/null +++ b/.claude/sweeps/107/round-1/02-parity.md @@ -0,0 +1,96 @@ +# Sweep #2 — Web ↔ Mobile Structural + Behavioral Parity (issue #107, round 1) + +Read-only audit of the working tree at `C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile` (branch `main`, clean tree). Rule audited: root CLAUDE.md — "Every change MUST land in BOTH apps/web AND apps/mobile… Logic, features, behavior, data flow, error handling: identical." Allowed adapter differences (BFF/Server Actions vs apiClient, cookie vs SecureStore, shadcn vs StyleSheet, next-intl vs i18next, routers, web-only public pages, mobile-only offline/push/widget/Play/back-handling, perf list primitives) were excluded per brief. + +## Scope swept + +- **Hooks:** all 39 `apps/web/hooks/*.ts` vs all 43 `apps/mobile/hooks/*.ts`. Every one of the 34 same-named pairs diffed (numstat triage + full reads of all pairs with non-trivial deltas: summary, login-code-entry, chat-composer, go-back-or-fallback, subscription-plans, resolve-clarification, timezone-auto-sync, calendar-data, offline, profile, drill-navigation, habit-queries, gamification, billing, notifications, app-toast, calendar-auto-sync, habit-form, checklist-templates, habits, goals, tags). Every one-sided hook dispositioned: web-only `use-calendar-events`, `use-color-scheme`, `use-is-client`, `use-popover-menu`, `use-push-notification-preferences`; mobile-only `use-tour-target`, `use-tour-scroll-container`, `use-horizontal-swipe`, `use-ad-mob`, `use-chat-reward`, `use-review-reminder`, `use-version-check`, `use-play-billing`, `use-push-notifications`. +- **Screens/routes:** all 20 web `page.tsx` vs all mobile `app/**` route files — fully paired (web-only `(public)/delete-account` is Play-required; mobile delete/fresh-start flows live inline in profile.tsx; verified present). +- **Components:** full `git ls-files` inventory both sides; every one-sided component chased to its counterpart or platform disposition (controls-menu → inline mobile habit-list controls; sortable-habit-item → mobile habit-list DnD; tour-replay-modal exists both; calendar-import-prompt → web layout prompt; update-prompt ↔ version-update-drawer; review-reminder-card → Play store-review surface; chat input split files → same keys/behavior verified). +- **Stores:** auth/chat/tour/ui pairs diffed; mobile-only `app-toast-store` (toast adapter) and `review-reminder-store` (Play review) dispositioned. +- **Cross-checks:** shared i18n-key probes per screen pair (both quote styles), endpoint+HTTP-verb extraction across web Server Actions vs mobile apiClient/queued mutations (verbs verified against orbit-api controllers where flagged), global overlay gating in both layouts, query-client defaults, gamification milestone selector. + +## Findings + +### 1. HIGH · Web calendar-import dismissal uses the wrong HTTP verb — silently 405s, prompt re-appears forever +- **Web:** `apps/web/app/actions/calendar.ts:22` — `dismissCalendarImport()` sends `POST` to `API.calendar.dismiss`. +- **Mobile:** `apps/mobile/components/onboarding/calendar-import-prompt.tsx:48-55` — sends `PUT` (correct). +- **API:** `orbit-api/src/Orbit.Api/Controllers/CalendarController.cs:45` — `[HttpPut("dismiss")]`. +- **What diverges:** mobile dismissal persists `hasImportedCalendar`; web dismissal fails with 405 and the error is swallowed (`dismissCalendarImport().catch(() => {})` at `apps/web/app/(app)/layout.tsx:139,144`), so the web prompt reappears on every session until the user actually imports. Error handling + behavior divergence with a concrete user-facing bug on web. +- **Fix:** change the web action to `method: 'PUT'`. + +### 2. MED · Calendar-import prompt gating differs — mobile can show it on top of the onboarding tour +- **Web:** `apps/web/app/(app)/layout.tsx:107-112` — requires `hasCompletedOnboarding && hasCompletedTour && !hasImportedCalendar`. +- **Mobile:** `apps/mobile/components/onboarding/calendar-import-prompt.tsx:30-35` — requires only `hasCompletedOnboarding && !hasImportedCalendar` (+ not on /calendar-sync). The tour auto-starts at the same moment under identical conditions (`apps/mobile/app/_layout.tsx:312-323`), so the bottom sheet can stack over the running tour on mobile. +- **Fix:** add the `hasCompletedTour` condition to the mobile `shouldShow`. + +### 3. MED · OTP login auto-verify policy diverges +- **Mobile:** `apps/mobile/app/login.tsx:232-237` — effect auto-verifies whenever the 6 digits are complete (typing the last digit, autofill, or `?email&code` deep link). +- **Web:** `apps/web/hooks/use-login-code-entry.ts:71-73,104-106` + `apps/web/app/(auth)/login/use-login-flow.ts:50-52` — auto-verifies only on multi-char fill/paste; typing the 6th digit manually or arriving via the `?email&code` deep link (`use-login-flow.ts:67-76`) does NOT auto-submit; the user must press Verify. +- **Fix:** pick one policy and mirror it (either add a completeness watch on web or restrict mobile to fill/paste). + +### 4. MED · Reactivated-account notice missing on mobile +- **Web:** `apps/web/app/(auth)/login/login-form-helpers.ts:120-122` — shows `profile.deleteAccount.reactivated` when `loginResponse.wasReactivated` is true. +- **Mobile:** `apps/mobile/app/login.tsx:304-326` — `verifyCode` ignores `wasReactivated` entirely. The field is in the shared schema (`packages/shared/src/types/auth.ts:15`). +- **Fix:** surface the same notice after mobile login. + +### 5. MED · Chat breakdown editor: frequency quantity editable on web only +- **Web:** `apps/web/components/chat/breakdown-suggestion.tsx:235-253` — when a frequency unit is selected, a number input lets the user edit `frequencyQuantity` ("every N day/week/…", key `habits.breakdown.every`). +- **Mobile:** `apps/mobile/components/chat/breakdown-suggestion.tsx:222-254` — unit chips only; no quantity editor (AI-provided quantity is kept silently, defaulting to 1 via `resolveFrequencyQuantity`). +- **Fix:** add the quantity input to the mobile breakdown card. + +### 6. MED · Retrospective regenerate affordance is mobile-only +- **Mobile:** `apps/mobile/app/retrospective.tsx:407-424` — result card has a "Regenerate" chip re-invoking generation. +- **Web:** `apps/web/app/(app)/retrospective/page.tsx:282-311` — the Generate CTA renders only when `!retrospective`; once a result exists the sole path to regenerate is switching period away and back (`selectPeriod` clears state, lines 94-98). +- **Fix:** add a regenerate affordance to the web result card. + +### 7. LOW · Rewarded-ad chat bonus is a mobile-only user capability; web carries a dead twin action +- **Mobile:** `apps/mobile/hooks/use-chat-reward.ts` + `apps/mobile/hooks/use-ad-mob.ts` + chat input (`ads.watchForMessages`, `ads.dailyLimitReached`) — free users at the AI-message limit can watch an ad for bonus messages. +- **Web:** no equivalent earn path at the limit; `claimAdReward` exists as a Server Action (`apps/web/app/actions/subscription.ts:38`) with zero consumers. +- **Note:** the mobile JSDoc declares "ads are mobile-only — no web counterpart", so this is likely intentional monetization asymmetry; it is still outside the brief's allowed list, and the orphaned web action contradicts the intent. +- **Fix:** confirm the asymmetry is intended; delete the unused web `claimAdReward` action either way. + +### 8. LOW · Subscription plans: timeZone param + coupon discount applied on web only +- **Web:** `apps/web/hooks/use-subscription-plans.ts:17-22,33-38` — appends `?timeZone=` for regional pricing and exposes `discountedAmount` (applies `couponPercentOff`); upgrade page shows `upgrade.plans.coupon.discountBadge`. +- **Mobile:** `apps/mobile/hooks/use-subscription-plans.ts:9-23` — no timeZone, no discount helper; `apps/mobile/app/upgrade.tsx:181-182` falls back to raw `plans.*.unitAmount` when Play pricing is unavailable, so a referral-coupon user sees an undiscounted, possibly wrong-region price in the fallback path. (Primary Play-offer pricing is regional and referral-aware via `preferReferralOffer` — divergence is the fallback display only.) +- **Fix:** pass `getClientTimeZone()` and apply the coupon discount in mobile's backend-price fallback. + +### 9. LOW · profile.language sync policy: continuous on mobile, login-time-only on web +- **Mobile:** `apps/mobile/hooks/use-profile.ts` (effect) — calls `i18n.changeLanguage(profile.language)` whenever the fetched profile differs. +- **Web:** locale comes from the `i18n_locale` cookie (`apps/web/i18n/request.ts:8-13`), which is written only by `applyProfilePresentation` at login/auth-callback (`apps/web/lib/profile-presentation.ts:34`; callers `login-form-helpers.ts:115`, `auth-callback/page.tsx:168`) and by the preferences page. A language change made on another device never reaches an already-authenticated web session. +- **Fix:** refresh the cookie from the profile query on web (mirroring mobile), or make mobile login-time-only. + +### 10. LOW · Calendar events: TanStack query on web vs imperative screen state on mobile +- **Web:** `apps/web/hooks/use-calendar-events.ts` — cached query (staleTime 30s, `retry: false`, not-connected discriminated union), consumed by `apps/web/app/(app)/calendar-sync/page.tsx:279`. +- **Mobile:** `apps/mobile/app/calendar-sync.tsx:72-164` — ad-hoc `fetchCalendarEvents` + local `step` state machine; no query cache, different refetch semantics. Same not-connected detection via the shared util. +- **Fix:** extract a mobile `use-calendar-events` mirroring the web hook. + +### 11. LOW · Habit-mutation cache-invalidation scopes drift +- Web skip invalidates lists+summary only (`apps/web/hooks/use-habits.ts:329-333`); mobile skip also invalidates goals+profile+gamification (`apps/mobile/hooks/use-habits.ts:285-291`). +- Mobile log invalidates `profileKeys.all` (`apps/mobile/hooks/use-habits.ts:231-237`); web log does not (`apps/web/hooks/use-habits.ts:283-288`). +- Web create/delete/bulk-create/bulk-delete invalidate `habitKeys.count()` (`use-habits.ts:347-348,396,537,551`); mobile never invalidates count — it only adjusts optimistically (`apps/mobile/lib/habit-mutation-helpers.ts:639-673`), so the mobile count can drift until an unrelated refetch. +- Mobile `finalizeHabitMutation` skips ALL invalidation when the mutation errored (`habit-mutation-helpers.ts:686-688`); web invalidates on settle regardless. (Partly motivated by the offline queue, but it also applies to plain online failures.) +- **Fix:** align the invalidation matrices (add count invalidation + error-path refetch on mobile, or trim web). + +### 12. LOW · Mobile chat-store exposes dead `isStreaming` state web lacks +- **Mobile:** `apps/mobile/stores/chat-store.ts:5-14` — `isStreaming`/`setIsStreaming` exist with no production consumer (only the store test references them). +- **Web:** `apps/web/stores/chat-store.ts` — no such field. +- **Fix:** delete the dead field (or implement it on both sides if streaming UI needs it). + +### 13. LOW · Web exports `useInvalidateSummary` with no consumer; mobile has no counterpart +- **Web:** `apps/web/hooks/use-summary.ts:72-79` — exported, unused anywhere in apps/web (summary invalidation actually happens via `habitKeys.summaryPrefix()` in mutation `onSettled` on both platforms). +- **Mobile:** `apps/mobile/hooks/use-summary.ts` — intentionally mirrors the file but omits the export. +- **Fix:** delete the dead web export. + +## Non-findings (verified equivalent / allowed) + +- All 20 screen routes paired; web public pages + mobile platform screens as allowed. +- Habit list controls (select mode, collapse-all, refresh, show-completed), drag-reorder, drill navigation (+ mobile hardware-back), move-parent, bulk bars, confirm dialogs, empty states: parity holds (placement/copy differ only). +- Chat: draft persistence, speech transcript appending, send/retry/limit handling, SSE streaming, pending-operation confirm + step-up verify: fully mirrored (mobile splits input into components). +- Tour (provider/overlay/spotlight/tooltip/replay modal), onboarding steps incl. frequency picker, gamification celebrations + pro-gated toasts/level-up, referral, notifications (interval polling adapter), support drafts, profile delete/fresh-start/edit-name/data-export, AI settings facts management, timezone auto-sync, theme/scheme seeding+sync, query-client defaults, retry policy: equivalent. +- All shared endpoint verbs match across platforms except finding #1 (checked habits, goals, tags, notifications, profile, userFacts, apiKeys, calendar suggestion-dismiss, auth). +- Mobile-only: offline queue + optimistic inserts, widget refresh, Play billing/review/version-check, push-token plumbing, AppState bridges — allowed. Web-only: SSR/is-client, popover positioning, route transitions, BFF — allowed. + +## Verdict + +**13 findings: 1 HIGH · 5 MED · 7 LOW.** diff --git a/.claude/sweeps/107/round-1/03-performance.md b/.claude/sweeps/107/round-1/03-performance.md new file mode 100644 index 0000000000..f9590050cb --- /dev/null +++ b/.claude/sweeps/107/round-1/03-performance.md @@ -0,0 +1,47 @@ +# Sweep #3 — Performance (static analysis), issue #107 round 1 + +Scope: working trees of `orbit-ui-mobile` (main) and `orbit-api` (chore/107-code-health-sweep). Static reading + grep only. Lighthouse / runtime measurement explicitly out of scope (DEF-1, user-owned). Rules cited: issue #107 Strand-2 agent-3 bullets (Web / Mobile / API / Animations), DESIGN.md Motion (line 151) + Bans (line 159, "No `transition-all`. Animate `transform` and `opacity` only"), apps/mobile/CLAUDE.md (Android elevation + overflow bug, line 33). + +Format: `SEVERITY · file:line · rule · fix`. + +## Web (apps/web) + +- MED · `apps/web/app/globals.css:1007-1017` (`.collapsible`, consumed at `apps/web/components/habits/habit-form-fields.tsx:1339`) · CSS transition of `grid-template-rows` — a layout property (DESIGN.md Motion/Bans "transform and opacity only"; #107 agent-3 "No CSS layout-property animations"). Expanding/collapsing the advanced section reflows the whole form subtree every frame for `--dur-base`. Reduced-motion already disables it · replace with a transform/opacity reveal (measure once, animate `translateY`/`opacity` on the revealed block), or record a written deferral if the 0fr→1fr grid trick is accepted as the height-auto idiom. +- LOW · `apps/web/components/tour/tour-tooltip.tsx:328` · `transition-[width,background-color]` animates `width` (layout) on the tour progress dots (DESIGN.md Bans; #107 "Animations: only transform + opacity") · give every dot fixed width and animate `transform: scaleX()` (origin left), or crossfade two sizes with opacity. +- LOW · `apps/web/components/onboarding/onboarding-flow.tsx:325-327` · same `width` transition on onboarding `ProgressDots` (active dot 7→24px) · same `scaleX` fix; keep parity with the mobile fix below. +- LOW · `apps/web/app/globals.css:508-524` (`.loading-bar` + `@keyframes loading-slide`) · infinite animation of `background-position` — not `transform`/`opacity` (DESIGN.md Motion line 151); repaints the gradient for as long as the bar is mounted · move the gradient onto a child/pseudo-element sized 200% and animate `translateX` instead. +- LOW · `apps/web/app/globals.css:662-665` (`@keyframes status-sweep`, `stroke-dashoffset`) + `apps/web/components/goals/goal-detail-drawer.tsx:108` (`transition: 'stroke-dashoffset 280ms'`) + mobile mirror `apps/mobile/components/ui/status-dot.tsx:85-90` (`Animated.timing` on `strokeDashoffset`, necessarily `useNativeDriver: false`, i.e. JS-thread) · SVG `stroke-dashoffset` is neither transform nor opacity (DESIGN.md Motion literal text); paint-only and one-shot, and status-sweep is the canon's own Today completion signature · either document the deviation once in DESIGN.md (sanctioned SVG arc technique) or rebuild the sweep from rotating masked half-discs (transform-only). No layout cost today; flagged for rule consistency, not measured harm. + +Checked clean (web): zero `transition-all` anywhere; all other `transition-[...]` arbitrary values name compositor/paint-safe properties (transform/opacity/colors/box-shadow — kit pattern shipped by globals.css itself); all other `@keyframes` (incl. `streak.css`) are transform/opacity; `prefers-reduced-motion` kill switch at `globals.css:882`; no `el.animate()`/WAAPI, no animation libraries; the only continuous rAF path (`components/tour/tour-provider.tsx:268-300`) is event-gated, coalesced, and active only during a tour; no raw `` outside the `LocalImage` seam (`components/ui/local-image.tsx` — blob URLs next/image can't optimize) and `next/image` used for static assets (`components/ui/app-logo.tsx`). Per the brief, `useMemo`/`useCallback` hygiene is left to the React Compiler rules; no compiler-unfixable inline-allocation pattern with a documented stable-reference convention was found to cite. + +## Mobile (apps/mobile) + +- MED · `apps/mobile/components/navigation/notification-bell.tsx:276-290` · notifications rendered via `visibleNotifications.map(...)` inside a `ScrollView` (#107 agent-3 "FlatList vs `map` render in long lists"; named category: notifications). Server caps the payload at 50 (`orbit-api src/Orbit.Application/Common/AppConstants.cs:30`), so sheet open eagerly mounts up to 50 pressable cards. FlatList is the established in-repo pattern (`components/habit-list.tsx`, `app/chat.tsx`, `components/ui/app-select.tsx`) · render the sheet body with a `FlatList` (actions row as `ListHeaderComponent`). +- MED · `apps/mobile/components/goals/goal-list.tsx:28-45` hosted inside the goals-tab `ScrollView` at `app/(tabs)/index.tsx:1412-1427` · unbounded user-created goals list rendered via `.map()` in a ScrollView (#107 "FlatList vs map"; goals grow exactly like habits, which use FlatList) · convert the goals tab to a `FlatList` with `sharedHeader` as `ListHeaderComponent`. +- MED · `apps/mobile/app/(tabs)/calendar.tsx:454-470` · selected-day habit entries (`filteredEntries.map(...)` — unbounded per-day habit data) rendered inside the screen `ScrollView` (#107 "FlatList vs map"; named category: habits) · move day entries into a `FlatList` (calendar grid as `ListHeaderComponent`) or virtualize the day panel. +- LOW · `apps/mobile/app/achievements.tsx:169-178` + `apps/mobile/app/achievements-sections.tsx:39-50` · achievements catalog mapped inside a `ScrollView` (#107 named category: achievements). Catalog is currently 25 (`orbit-api src/Orbit.Application/Gamification/AchievementDefinitions.cs`) and grows per release; every tile mounts an `Animated.View` wrapper eagerly · acceptable today; switch to a `numColumns` FlatList when the catalog grows, or cap entrance-animation wrappers. +- LOW · `apps/mobile/components/onboarding/onboarding-flow.tsx:49-77` (`ProgressDot`) · `Animated.timing` animating `width` with `useNativeDriver: false` — a JS-thread layout-property animation (DESIGN.md Motion "transform + opacity only"; #107 mobile bullet keeps animation off the JS thread) · animate `transform: [{ scaleX }]` with `useNativeDriver: true` (mirror of the web ProgressDots fix above — land both together per parity). +- MED · `apps/mobile/hooks/use-horizontal-swipe.ts:21-48`, attached at `app/(tabs)/index.tsx:487/1047` and `app/(tabs)/calendar.tsx:163/320` · `PanResponder` runs `onMoveShouldSetPanResponder` on the JS thread for every touch-move on the two hottest screens, including while the habit list is scrolling (#107 "Reanimated worklets stay on the UI thread"; brief: JS-thread work inside gesture handlers that should be worklets) · migrate to RNGH `Gesture.Pan().activeOffsetX(±50).failOffsetY(...)` (+`onEnd` worklet, `runOnJS` only for navigation) — react-native-gesture-handler is already a dependency (`GHFlatList`/DraggableFlatList in habit-list.tsx). +- LOW · `apps/mobile/hooks/use-tour-scroll-container.ts:16-21` with `scrollEventThrottle={16}` at `app/(tabs)/index.tsx:1422`, `app/(tabs)/calendar.tsx:314`, `app/(tabs)/profile.tsx:538`, `components/habit-list.tsx:1774` · always-attached JS `onScroll` streams bridge events at up to 60fps on four hot scroll surfaces to track position for the rarely-active feature tour (brief: JS-thread work inside scroll handlers) · attach the handler only while a tour is active, or track via Reanimated `useAnimatedScrollHandler` + shared value. +- LOW · `apps/mobile/components/message-bubble.tsx:116-121` (chat attachment, 200×192) and `apps/mobile/components/chat/chat-input-area.tsx:141-145` (preview thumb, 64×64 per `app/chat.styles.ts:103-108`) · full-resolution user photos (picked at `quality: 1`, `hooks/use-chat-composer.ts:294`) decoded at native size and GPU-scaled into small thumbs; no `resizeMethod="resize"` anywhere in the app (#107 "image sizing matches display size") · add `resizeMethod="resize"` (or expo-image with explicit sizes) on both, and/or pick at reduced quality/size. + +Checked clean (mobile): all backdrop/overlay/celebration animations use `useNativeDriver: true` (confirm-dialog, anchored-menu, toasts, all gamification celebrations, fresh-start, login, chat) — no transparent backdrops on the JS thread; the only `useNativeDriver: false` sites are the two flagged above; `useAnimatedStyle` worklets (`habit-row.tsx:509`, `habit-checklist.tsx:52`) read shared values only — no React state per frame; `progress-bar.tsx` animates `scaleX` native-driven; habit list + chat are FlatList/DraggableFlatList, not nested in ScrollViews; all three `Image` usages have explicit dimensions; `AppLogo` renders the 96px `logo-no-bg.png` at 32dp (correct for 3x density); no same-file elevation-inside-`overflow:'hidden'` combos (apps/mobile/CLAUDE.md line 33) — elevation users (gamification cards, habit-row:540, settings-row:260, trial-expired-modal:126) do not sit in the overflow-hidden files list. + +## API (orbit-api) + +- HIGH · `src/Orbit.Application/Habits/Commands/BulkLogHabitsCommand.cs:83-90` · per-item awaited heavy call in a loop on a hot endpoint: `foreach (habitId in loggedHabitIds) await GamificationService.ProcessHabitLogged(...)` — each iteration re-runs user `FindOneTrackedAsync` + earned-achievements `FindAsync` + user-today + **all user habits with a 400-day filtered log Include** (+ conditional 90-day and 7-day log scans). Bulk-logging N habits ⇒ ~N×5-6 queries with repeated multi-thousand-row materialization (#107 "EF Core N+1 audit on hot endpoints"; brief "per-item awaited queries inside loops") · make `ProcessHabitLogged` batch-capable: load user/earned/habits+logs once, evaluate achievements for the whole logged set in one pass. +- MED · `src/Orbit.Application/Gamification/Services/GamificationService.cs:40-45` · every single habit-log mutation materializes ALL user habits + 400 days of logs to evaluate achievements; Liftoff/volume need only counts (`CountAsync`/SQL aggregate), perfect day/week/month need ≤31-day windows, the streak metric needs only the logged habit's logs (#107 API bullet "Sync-over-async + allocations" — allocations) · narrow to targeted aggregates and per-check date windows, or project to `(HabitId, Date, Value)` instead of entities. +- MED · `src/Orbit.Application/Habits/Commands/LogHabitCommand.cs:60-63` (`Include(h => h.Logs)` unfiltered) and `:236-241` (`Include(g => g.Habits).ThenInclude(h => h.Logs)` unfiltered) + `src/Orbit.Application/Habits/Commands/BulkLogHabitsCommand.cs:50-53` (`Include(h => h.Logs)` unfiltered) · the single-tap log path loads the habit's entire log history, then every linked goal's every habit's entire history, only to compute current streaks and a same-date duplicate check; the in-repo convention is date-bounded filtered includes (`GetDailySummaryQuery.cs:69`, `GetCalendarMonthQuery.cs:66`, `GamificationService.cs:41-44`, `UserStreakService` 365-day lookback) (brief: missing projection/filtered-include on hot endpoints; #107 allocations) · apply filtered includes bounded by `AppConstants.MaxStreakLookbackDays`-style windows. +- MED · `src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs:26-36` · one `FindOneTrackedAsync` round-trip per goal position inside `foreach` — N+1 on every goals drag-reorder; the sibling `ReorderHabitsCommand.cs:23-29` already demonstrates the batched pattern (single `FindTrackedAsync`, loop in memory) · fetch all goals for the position set in one tracked query, then update in memory. +- MED · `src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs:120` vs `src/Orbit.Application/Notifications/Queries/GetNotificationsQuery.cs:29-32` · index/query-shape mismatch on a polled endpoint: list reads `WHERE UserId = @u ORDER BY CreatedAtUtc DESC LIMIT 50` but Notifications has only `(UserId, IsRead)` — every bell fetch sorts the user's full notification set (brief: "indexes vs query shapes — WHERE/ORDER BY columns of hot queries") · add `HasIndex(n => new { n.UserId, n.CreatedAtUtc })` (descending) via migration; keep `(UserId, IsRead)` for the unread `CountAsync`. +- LOW · `src/Orbit.Application/UserFacts/Commands/BulkDeleteUserFactsCommand.cs:27-37` · per-factId `FindOneTrackedAsync` in `foreach` — N+1 on a bulk endpoint; batched precedent at `BulkDeleteHabitsCommand.cs:32` · one `FindTrackedAsync` with `factIds.Contains(f.Id) && f.UserId == ...`, soft-delete in memory. +- LOW · `src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs:110-135` (`ResolveTagsByNameAsync`) + `src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs:295-320` (`AssignTagsToHabitAsync`) · per-tag-name `FindOneTrackedAsync` inside a loop — N+1 (N = tag names) on agent tool paths (brief grep-sweep: per-item awaited queries inside loops) · batch-fetch existing tags by the capitalized name set in one query, create the misses. +- LOW · `src/Orbit.Api/Controllers/ConfigController.cs:19-38` · idempotent config-ish GET (limits, feature flags, static sync settings) returns no caching headers — the only Cache-Control in the API is ChatController's no-store (#107 "Caching headers on idempotent reads") · set `Cache-Control: private, max-age=300` (matches the advertised `syncIntervalSeconds: 300`) or `[ResponseCache]`. + +Checked clean (API): no sync-over-async — all `.Result`/`.Wait()` grep hits are the domain `Result` type or properties of awaited values (`RunCalendarAutoSyncCommand.cs:75`, `GetCalendarEventsQuery.cs:89` verified); no lazy-loading proxies configured ⇒ no lazy round-trip risk; no raw entity serialization — every handler projects to DTO records and no controller returns domain entities; named hot reads are set-based: `GetHabitsQuery` (1 query + in-memory tree), `GetDailySummaryQuery` (filtered include + IMemoryCache + time-bucketed key), `GetStreakInfoQuery`/`UserStreakService` (4 set-based queries, `Contains` over ids), `GetGamificationProfileQuery` (2 queries + static catalog join); index inventory otherwise matches hot shapes — Habits `(UserId, IsDeleted)` + partial scheduler indexes (`20260410235000_AddPerformanceIndexes.cs`), HabitLogs `(HabitId, Date)`, StreakFreezes `(UserId, UsedOnDate)`, UserAchievements `(UserId, AchievementId)`; bulk habit commands (delete/log/skip) batch their habit fetches; `GetHabitScheduleQuery` loop is intentional paging. + +## Verdict + +**20 findings — 1 HIGH · 9 MED · 10 LOW** (web 1 MED / 4 LOW · mobile 4 MED / 4 LOW counting the cross-platform stroke-dashoffset row once under web · API 1 HIGH / 4 MED / 3 LOW). + +No findings in: sync-over-async, response over-fetch/raw-entity serialization, `transition-all`, web image conventions, mobile transparent-backdrop JS-thread animation, elevation+overflow:'hidden' (same-file), `useAnimatedStyle` state-per-frame misuse. diff --git a/.claude/sweeps/107/round-1/04-a11y.md b/.claude/sweeps/107/round-1/04-a11y.md new file mode 100644 index 0000000000..346053b95c --- /dev/null +++ b/.claude/sweeps/107/round-1/04-a11y.md @@ -0,0 +1,115 @@ +# Sweep #4 — Accessibility (issue #107 agent-4, round 1, RETRY) + +Status: COMPLETE — all 9 domains swept (working tree, apps/web + apps/mobile, static analysis only; contrast ratios computed from the fixed token values with a WCAG-luminance node script). +Format: `SEV · file:line · rule · fix` + +## Domain 1 — accessible names (issue #107 bullet 1; WCAG 4.1.2 / 1.1.1 / 2.1.1) + +- HIGH · apps/web/components/habits/habit-form-fields.tsx:1477-1482 · Bad-habit checkbox is `className="hidden"` (display:none) — removed from tab order AND a11y tree; keyboard and SR users cannot toggle "bad habit" at all (mouse works via label click). WCAG 2.1.1 / 4.1.2. · Use the `sr-only` pattern from calendar-day-detail.tsx:96 instead of `hidden`. +- HIGH · apps/mobile/components/chat/chat-composer-input.tsx:116-128 · Chat send button (ArrowUp icon only) has `accessibilityRole="button"` but NO accessibilityLabel and no text child — the primary chat action is unnamed for TalkBack. WCAG 4.1.2. · Add `accessibilityLabel={t('chat.send')}` (web composer send button is labeled). +- MED · apps/web/components/chat/breakdown-suggestion.tsx:256-261 AND apps/mobile/components/chat/breakdown-suggestion.tsx:256-262 · Remove-habit X icon button unnamed on BOTH platforms. WCAG 4.1.2. · aria-label / accessibilityLabel with the habit name. +- MED · apps/web/components/chat/breakdown-suggestion.tsx:238-248 · Frequency-quantity `` has no label/aria-label (adjacent spans are not associated). WCAG 4.1.2 / 1.3.1. · Add aria-label. +- MED · apps/web/components/habits/create-habit-modal.tsx:293-299 · Remove sub-habit Trash2 icon button unnamed. WCAG 4.1.2. +- MED · apps/web/components/habits/habit-calendar.tsx:105,120 (prev/next month chevrons), 215 (close X) AND apps/mobile/components/habits/habit-calendar.tsx:112,131 (prev/next), 194 (close X) · Icon-only calendar nav buttons unnamed on BOTH platforms. WCAG 4.1.2. · Mirror app-date-picker which labels these (`common.previousMonth` / `common.nextMonth`). +- MED · apps/mobile/components/habits/habit-checklist.tsx:144,153,171,178 · Move-up, move-down, duplicate, remove checklist-item icon buttons — no label, no role. WCAG 4.1.2. +- MED · apps/mobile/components/habits/habit-form-fields/habit-tag-chip.tsx:52,64 · Tag edit (PenSquare) and delete (X) icon buttons unnamed (13px icons). WCAG 4.1.2. (Web tag editor passes aria-labels — parity gap.) +- MED · apps/mobile/components/habits/habit-form-fields/reminder-section.tsx:88-97 (remove reminder X), 165-171 (custom-add Plus) · unnamed icon buttons. WCAG 4.1.2. +- MED · apps/mobile/components/habits/habit-form-fields/scheduled-reminder-section.tsx:106-111 (remove X), 201-209 (cancel X) · unnamed icon buttons. WCAG 4.1.2. +- MED · apps/mobile/components/habits/habit-form-fields/advanced-section.tsx:155-161 · Remove end-date X unnamed. WCAG 4.1.2. +- MED · apps/mobile/components/habit-list.tsx:1623-1627 · Drill-reset chevron (back-to-root) unnamed, no role (the sibling back button at 1610 is labeled). WCAG 4.1.2. +- MED · apps/mobile/components/onboarding/onboarding-welcome.tsx:153-165 · Color-scheme swatch Pressables: no label, no role, and the choice is conveyed by swatch color only — selection ring is the only non-color cue. WCAG 4.1.2 + 1.4.1 (web onboarding labels its swatches — parity gap). · accessibilityLabel with scheme name + accessibilityState selected. +- MED · apps/mobile/components/onboarding/onboarding-complete-habit.tsx:113 · Demo habit completion dot Pressable: no role, no label (web mirror passes `ariaLabel={tapHint}` to StatusDot). WCAG 4.1.2. +- MED · apps/mobile/components/ui/app-time-picker.tsx:140-147 · Clock icon Pressable (opens picker) has no role/label; the clear variant directly above (line 129) is fully labeled. WCAG 4.1.2. +- MED · apps/mobile/components/ui/field-input.tsx:34-40 · FieldInput renders a visible label `Text` but never sets `accessibilityLabel` on the TextInput — RN has no label-for association, so unnamed unless caller passes placeholder. Web mirror wraps in `