Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a2ef611
WIP checkpoint: round-1 fixes waves 1-2 (partial, pre-repair)
thomasluizon Jun 13, 2026
ae5c150
recovery: repair partial wave-2 state to green build/test/lint
thomasluizon Jun 13, 2026
068aad8
fix(fe): r2 batch1 — keyboard/overlay LIFO, ErrorBoundary, submit-gat…
thomasluizon Jun 13, 2026
a946724
fix(fe): r2 batch2 — invalidation parity, ControlsMenu/drill keyboard…
thomasluizon Jun 13, 2026
59ddd32
fix(fe): r2 batch3 — calendar tour-gating + events hook, FlatLists, b…
thomasluizon Jun 13, 2026
61739eb
fix(fe): r2 batch4 — auth OTP/wasReactivated parity, BFF Zod+D32 erro…
thomasluizon Jun 13, 2026
9bdcdff
fix(i18n): central merge — chat.offline, breakdown labels, errors.off…
thomasluizon Jun 13, 2026
45d6312
refactor(fe): wave-3 rule-7 function splits across web+mobile pages/c…
thomasluizon Jun 13, 2026
6399d00
docs: finalize wave-3 deferral register (orchestration roots + non-fu…
thomasluizon Jun 13, 2026
c8f4292
fix(a11y): complete contrast/token migration app-wide — fg-3 text, st…
thomasluizon Jun 13, 2026
3520d10
refactor(fe): r4 — split useChatComposer/ChatPage/CalendarSync, highl…
thomasluizon Jun 13, 2026
1dd5c3d
fix(r6): amber-text AA migration, focus/reduced-motion a11y, config c…
thomasluizon Jun 13, 2026
d920589
fix(a11y): r7 exhaustive convergence — all status-text→token, 30 mobi…
thomasluizon Jun 13, 2026
715eec8
fix(a11y): align web amber-on-tint text to -text token (mobile parity…
thomasluizon Jun 13, 2026
dd935bf
chore: gitignore #107 audit-sweep working artifacts
thomasluizon Jun 13, 2026
b94ff06
test(web): cover extracted split components (chat/ai-settings/retrosp…
thomasluizon Jun 13, 2026
b04bece
test(web): fix preference-picker test prop types (theme is dark|light…
thomasluizon Jun 13, 2026
bd6e63e
refactor(web): drop console.error auth-proxy logger per CLAUDE.md rule 4
thomasluizon Jun 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,5 @@ apps/mobile/ios/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

.claude/sweeps/
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Orbit

Personal habit tracker. Turborepo monorepo: `apps/web` (Next.js 15) + `apps/mobile` (Expo SDK 55) + `packages/shared`.
Personal habit tracker. Turborepo monorepo: `apps/web` (Next.js 16) + `apps/mobile` (Expo SDK 55) + `packages/shared`.

Cross-cutting rules only live here. Each workspace has a scoped `CLAUDE.md` Claude auto-loads when touching files in that subtree. Read them before writing code in those areas.

Expand Down
10 changes: 8 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ Canonical CSS lives in `apps/web/app/globals.css`; the mobile equivalent is `cre
--hairline rgba(248,250,252,0.10)
--hairline-strong rgba(248,250,252,0.18)
--fg-1 rgb(248,250,252) --fg-2 rgb(202,213,226) --fg-3 rgb(144,161,185) --fg-4 rgb(98,116,142)
--fg-on-primary rgb(255,255,255)
--fg-on-primary rgb(255,255,255) /* scheme×mode-resolved; see hand-tune log */
--status-done var(--primary) · empty rgba(248,250,252,0.22) · skip rgb(144,161,185)
--status-overdue rgb(254,154,0) · bad rgb(251,44,54) · frozen rgb(0,211,243)
--status-overdue-text rgb(254,154,0) · bad-text rgb(251,44,54) /* = base; AA ≥4.5 on canvas */
--selection-bg rgba(var(--primary-rgb),0.32)
--gradient-header linear-gradient(180deg, rgb(34,9,79) 0%, rgba(2,6,24,0) 100%) /* violet-950 → transparent */
```
Expand All @@ -44,6 +45,7 @@ Canonical CSS lives in `apps/web/app/globals.css`; the mobile equivalent is `cre
--hairline rgba(2,6,24,0.08) · --hairline-strong rgba(2,6,24,0.16)
--fg-1 rgb(15,23,43) · --fg-2 rgb(49,65,88) · --fg-3 rgb(98,116,142) · --fg-4 rgb(144,161,185)
--status-empty rgba(2,6,24,0.18) · skip rgb(98,116,142) · overdue rgb(225,113,0) · bad rgb(231,0,11) · frozen rgb(0,146,184)
--status-overdue-text rgb(180,91,0) · bad-text rgb(231,0,11) /* overdue darkened to AA; bad = base */
--selection-bg rgba(var(--primary-rgb),0.18)
--gradient-header linear-gradient(180deg, rgb(233,212,255) 0%, rgba(248,250,252,0) 100%)
```
Expand Down Expand Up @@ -87,6 +89,7 @@ Hand-tune log (rule 3):
- **orange:** Δ-derived hue 18.36° read rose-tinted; nudged to 32 (warm brown-black canvas).
- **blue / cyan:** canvas chroma gamut-clamped at the locked L (scaleBg 0.6226 / 0.5167); cyan fg ramp also clamped (scaleFg 0.843). Values are the sRGB gamut ceiling ×0.985 so web CSS and the TS pipeline resolve identical bytes.
- **Light gradient stops:** purple keeps the handoff's +21.71° lilac rotation from the accent hue (byte-exact `#e9d4ff`); the rotation misreads on rose (peach) and cyan (periwinkle), so all non-purple schemes use their light accent hue directly at the locked L/C, chroma gamut-clamped.
- **fg-on-primary (scheme×mode):** white fails 4.5:1 AA on green/orange/cyan (both modes) and blue/rose (dark), so those eight resolve to the locked canvas ink rgb(2,6,24) (≥5.36:1 on every flipped accent); white stays on purple (both modes), blue light, rose light (4.53–7.03:1). Data in `color-schemes.ts` `fgOnPrimary`, mirrored per scheme×mode in `globals.css`.

## Type roles

Expand Down Expand Up @@ -138,18 +141,21 @@ Exact dimensions in `design/handoff/orbit/project/orbit-kit.jsx`. Web in `apps/w
- Opaque white cards on light — never translucent.
- Inset 1px hairline rings instead of borders (web `box-shadow: inset 0 0 0 1px var(--hairline)`; RN border with the same color reads equivalently).
- No opaque card-on-card on dark; surfaces stack by alpha, not by lighter hexes.
- Gradient-header usage: profile, paywall, auth, onboarding, celebrations, Início.
- Gradient-header usage: profile, paywall, auth, onboarding, celebrations, Início, calendar, chat.

## Status, emoji, icons

- done = primary · overdue = amber · bad/danger = red · frozen = cyan · skip/empty = neutral (exact values in the token spec).
- Dots, rings and icons use the base status tokens; status-colored TEXT uses `--status-overdue-text` / `--status-bad-text` (mobile `statusOverdueText` / `statusBadText`) — AA ≥4.5 on the mode canvas (only light overdue differs from base).
- Emoji deliberately, where the artboards show them: habit emoji wells, stat tiles, streak flame, celebration heroes. Never as decoration elsewhere.
- Lucide 22px, strokeWidth 1.8 (2.2 active/emphasis).

## Motion

Unchanged philosophy: transform + opacity only, durations 160/220/280, `--ease-standard` for state changes, `--ease-out` for entrances. Presets in `packages/shared/src/theme/motion.ts`. Respect `prefers-reduced-motion`.

SVG `stroke-dashoffset` ring/progress sweeps are sanctioned (paint-only property, no layout; the `status-sweep` keyframe is the precedent).

## Bans

- No raw `--slate-*` references in app code — semantic tokens only (the raw-slate mapping rule above).
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Mobile client (Android only — no iOS app exists). Direct calls to `orbit-api`
- **Named exports only.** `kebab-case` filenames, `PascalCase` components.
- **Zero `any`** — root `CLAUDE.md` Code Standards rule 3.
- **No `console.log`** in production code — rule 4.
- **No narration comments** (lint-enforced `local/no-comments`) — rule 5. Only `/** */` JSDoc on exports, WHY notes linking an upstream URL, and tooling directives.
- **All user-facing strings through i18n** (react-i18next). Read locale via `i18n.language`, not `useDeviceLocale`. Never hardcode display text.
- **All mutations through `apiClient`** (`lib/api-client.ts`). Never call `fetch` directly.
- **Imports:** types from `@orbit/shared/types`, query keys from `@orbit/shared/query`, endpoints from `@orbit/shared/api`.
Expand All @@ -20,7 +21,7 @@ Mobile client (Android only — no iOS app exists). Direct calls to `orbit-api`
| Theme provider | `lib/theme-provider.tsx` |
| API client | `lib/api-client.ts` |
| Offline queue (SQLite) | `lib/queued-api-mutation.ts` |
| Auth storage | SecureStore — see `lib/auth.ts` |
| Auth storage | SecureStore — see `lib/secure-store.ts` |
| TanStack Query hooks | `hooks/use-*.ts` |
| Zustand stores | `stores/*-store.ts` |
| Primitives (mirror of `apps/web/components/`) | `components/*` |
Expand Down Expand Up @@ -68,11 +69,11 @@ React 19 forbids several patterns that worked before. The compiler will fail the

| Want to add… | Look at… |
|---|---|
| New screen | `app/(tabs)/today.tsx` |
| New screen | `app/(tabs)/index.tsx` |
| New API call hook | `hooks/use-habits.ts` |
| New Zustand store | `stores/ui-store.ts` |
| New primitive | `components/ui/section-label.tsx` |
| Offline-safe mutation | `hooks/use-update-habit.ts` (search for `performQueuedApiMutation`) |
| Offline-safe mutation | `hooks/use-habits.ts` (search for `performQueuedApiMutation`) |
| Theme-aware component | `components/ui/theme-toggle.tsx` |

## Testing
Expand Down
150 changes: 150 additions & 0 deletions apps/mobile/__tests__/components/chat/breakdown-suggestion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SuggestedSubHabit } from '@orbit/shared/types/chat'
import { BreakdownSuggestion } from '@/components/chat/breakdown-suggestion'

const bulkCreateMock = vi.fn().mockResolvedValue(undefined)

vi.mock('@/hooks/use-habits', () => ({
useBulkCreateHabits: () => ({
mutateAsync: bulkCreateMock,
isPending: false,
}),
}))

vi.mock('@/lib/theme', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/theme')>()
return {
...actual,
createTokensV2: () => new Proxy({}, { get: () => '#111111' }),
tintFromPrimary: () => 'rgba(127,70,247,0.1)',
}
})

vi.mock('lucide-react-native', () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement(name, props)
return {
Check: createIcon('Check'),
X: createIcon('X'),
Plus: createIcon('Plus'),
}
})

const TestRenderer = require('react-test-renderer')

const subHabits: SuggestedSubHabit[] = [
{ title: 'Push-ups', description: '3 sets of 10' },
{ title: 'Squats', description: '3 sets of 15' },
]

const defaultProps = {
parentName: 'Exercise',
subHabits,
onConfirmed: vi.fn(),
onCancelled: vi.fn(),
}

function renderBreakdown() {
let tree: { root: { findAll: (predicate: (node: { props?: Record<string, unknown>; type?: unknown }) => boolean) => { props: Record<string, unknown> }[] } } | undefined
TestRenderer.act(() => {
tree = TestRenderer.create(<BreakdownSuggestion {...defaultProps} />)
})
if (!tree) throw new Error('failed to render')
return tree
}

function findByAccessibilityLabel(tree: ReturnType<typeof renderBreakdown>, label: string) {
return tree.root.findAll(
(node) => node.props?.accessibilityLabel === label,
)
}

function findInputByAccessibilityLabel(tree: ReturnType<typeof renderBreakdown>, label: string) {
return tree.root.findAll(
(node) =>
node.props?.accessibilityLabel === label &&
typeof node.props?.onChangeText === 'function',
)
}

function collectText(children: unknown): string {
if (children == null || typeof children === 'boolean') return ''
if (typeof children === 'string' || typeof children === 'number') {
return String(children)
}
if (Array.isArray(children)) {
return children.map(collectText).join(' ')
}
if (typeof children === 'object' && 'props' in (children as { props?: unknown })) {
return collectText((children as { props: { children?: unknown } }).props.children)
}
return ''
}

function pressableContainingText(
tree: ReturnType<typeof renderBreakdown>,
needle: string,
) {
return tree.root
.findAll(
(node) =>
typeof node.props?.onPress === 'function' && typeof node.type !== 'string',
)
.find((node) => collectText(node.props.children).includes(needle))
}

beforeEach(() => {
defaultProps.onConfirmed.mockClear()
defaultProps.onCancelled.mockClear()
bulkCreateMock.mockReset()
bulkCreateMock.mockResolvedValue(undefined)
})

describe('BreakdownSuggestion (mobile)', () => {
it('labels each remove button with the habit name for screen readers', () => {
const tree = renderBreakdown()
expect(
findByAccessibilityLabel(tree, 'habits.breakdown.removeHabit:{"name":"Push-ups"}').length,
).toBeGreaterThanOrEqual(1)
})

it('reveals the frequency-quantity editor only after a recurrence unit is chosen', () => {
const tree = renderBreakdown()
expect(
findInputByAccessibilityLabel(tree, 'habits.breakdown.frequencyQuantityLabel').length,
).toBe(0)

const chipWithWeekly = pressableContainingText(tree, 'habits.filter.weekly')
expect(chipWithWeekly).toBeDefined()
TestRenderer.act(() => {
;(chipWithWeekly!.props.onPress as () => void)()
})

expect(
findInputByAccessibilityLabel(tree, 'habits.breakdown.frequencyQuantityLabel').length,
).toBeGreaterThanOrEqual(1)
})

it('shows the friendly i18n fallback instead of a raw Error message on failure', async () => {
bulkCreateMock.mockRejectedValue(new Error('ECONNREFUSED 127.0.0.1:5432'))
const tree = renderBreakdown()

const submit = pressableContainingText(tree, 'habits.breakdown.createCount')
expect(submit).toBeDefined()
await TestRenderer.act(async () => {
;(submit!.props.onPress as () => void)()
await Promise.resolve()
await Promise.resolve()
})

const errorNodes = tree.root.findAll((node) =>
collectText(node.props?.children).includes('errors.bulkCreateHabits'),
)
expect(errorNodes.length).toBeGreaterThanOrEqual(1)
const rawNodes = tree.root.findAll((node) =>
collectText(node.props?.children).includes('ECONNREFUSED'),
)
expect(rawNodes.length).toBe(0)
})
})
Loading
Loading