Skip to content

Visual polish: Linear-tactical craft lift across web + mobile (#117) - #154

Merged
thomasluizon merged 27 commits into
mainfrom
feature/visual-polish-117
Jun 9, 2026
Merged

Visual polish: Linear-tactical craft lift across web + mobile (#117)#154
thomasluizon merged 27 commits into
mainfrom
feature/visual-polish-117

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Summary

App-wide visual craft lift inside the locked Linear-tactical anchor (DESIGN.md) - purge of AI-slop decoration, exact web/mobile parity, the three signature differentiators, the real logo, and the Android widget token migration. One branch, one PR, per the umbrella issue.

Ban purge (W1 + motion pass)

  • Gradient shimmer (pro-badge, skeletons) -> static hairline badge + opacity-pulse skeletons
  • All glows: celebration keyframes (dead code, deleted), ambient primary wash on every screen (-> flat near-black), drag-reorder halo, fresh-start orb; shared shadowGlow token chain removed
  • Colored side-stripes, surface sheens, top-highlights, 🔥 emoji -> StatusDot/Flame icon
  • Semantic red/amber FILL buttons -> hairline + status-color text (including the canonical mobile ConfirmDialog, which still filled)
  • Em dashes (i18n both locales), raw radius drift, hardcoded rgba -> tokens
  • Invisible perpetual motion deleted (chat empty-state spinning circles both platforms)
  • Mobile easing normalized to spec ease-out-quart via new toAnimatedEasing (Easing.out(Easing.cubic) extinct)

Differentiators (one per priority surface)

  • Today: completion ring-sweep - tapping a habit dot radially fills it with --primary (~420ms, ease-out-quart, reduce-motion aware on both platforms)
  • Habit drawer: top mono summary strip (streak / linked goal / checklist) via InfoRow, text from one shared formatHabitDetailSummary helper
  • Goals: card progress via a leading hairline ParentRing + mono tabular percent (linear bar gone)

Parity closed

  • Calendar: truthful legend (today/done/partial/missed matching the actual grid dots), month-summary subtitle on mobile, go-to-day on empty days, unified DayStatus enum, rewritten stale tour copy (both locales)
  • goal-metrics-panel: mobile rewritten to web hairline rows (tinted pills + shadowed stat cards gone)
  • ConfirmDialogV2 consolidated into the canonical dialog (both gained variant="info"); legacy icon stats grid deleted
  • W3 mirror primitives wired: InfoRow (web drawer), EmptyState (mobile goals), SettingsDescription (3 mobile settings pages + 4 missing-description parity adds), SkeletonLine (goal metrics)
  • Logo: real logo-no-bg.png replaces SaturnDropcap at all 6 sites
  • Android widget: Kotlin THEMES palette -> v8 tokens via syncTheme bridge (Android-only, not parity-bound)

Validation

  • turbo lint type-check test green across web / mobile / shared (web 150, mobile 85, shared 66 test files; i18n key sync included)
  • Final ban-greps clean: no transition-all, h-screen, em-dash, shimmer, glow identifiers
  • New tests: StatusDot sweep transition rules, formatHabitDetailSummary, HabitDetailStatsRow, calendar legend
  • Known not machine-verified: widget Kotlin compile (no Gradle in env) and on-device mobile animation feel - both need a device pass

Closes #117

🤖 Generated with Claude Code

thomasluizon and others added 16 commits June 9, 2026 12:38
App-wide decoration purge + logo swap + primitive reconciliation + Android
widget token migration, inside the locked Linear-tactical anchor (DESIGN.md).
Frontend only; net -444 lines.

W1 (ban purge, both platforms): pro-badge/skeleton gradient shimmer to opacity
pulse; fresh-start glow to hairline pulse; Goals card stripe/sheen/emoji; mobile
completed-glow + hardcoded rgba badge tints; chat amber/blue rgba to token tints
(fixes a red-on-red HIGH-severity bug); habit-summary glass + LinearGradient; em
dashes (both locales); paywall shadow-overuse + raw-px radius; mobile delete
red-fill to hairline + status-bad text; habit-calendar done to status-done;
create-habit upsell de-carded.

W2 (logo): AppLogo (web next/image, mobile RN Image) on logo-no-bg.png replaces
all 6 SaturnDropcap sites; both glyph components deleted.

W3 (primitives): StatusDot state required on both; MonoToggle rebuilt to the
canonical monospaced ON/OFF pill (primary fill) on both; created missing mirrors
(mobile skeleton/empty-state/settings-description, web info-row).

W4 (partial): Today/habit-list empty-state reconciled to web; goal-metrics +
tag-chip rgba to tokens; web onboarding-welcome subtitle parity; calendar
DayStatus enum unified (none/upcoming to empty/missed).

W5 (Android widget): hardcoded Kotlin THEMES palette to v8 tokens via the
syncTheme bridge; gradients + 9 dead drawables removed; flat surfaces + hairline
borders; no semantic red.

Validated: web lint+type+1464 tests, mobile lint+type+411 tests, i18n 1607 keys
in sync. Per-surface /animate + /polish (motion, incl. the remaining celebration
glows) deferred to a follow-up pass per #117.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent/drag glow bans

The celebration glow animations flagged in #117 (orbit-complete/creation/
perfect-day/ring-pulse + their .animate-* classes) were dead code with zero
consumers, so they were deleted rather than redesigned. Mobile's unread
creation/complete glow durations went with them.

The live glow bans removed instead:
- .ambient-glow primary radial wash on web (app)/(chat) shells and its mobile
  twin (the _layout ambient LinearGradient) -> flat near-black backgrounds
- drag-reorder --shadow-glow halo -> neutral elevation shadow

Removed the orphaned shadowGlow token chain (shared color-schemes x12 + type +
theme.test, web theme-dom/layout setters, globals.css vars) and the dead
--orbit-completion-peak/glow-scale + --orbit-route-tint-opacity vars.

Refs #117. Web 1464 / mobile 411 / shared tests + type-check + lint all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Logging a habit now plays a signature completion motion in StatusDot: a
--primary arc sweeps once around the dot, then the fill settles in
(~500ms, ease-out-quart). Fires only on an interactive non-done -> done
transition, never on mount of an already-done dot or on a read-only dot;
web also respects prefers-reduced-motion.

Web uses a CSS stroke-dashoffset keyframe; mobile mirrors it with
react-native-svg + RN Animated (the useMemo'd Animated.Value pattern).
Both use the render-phase adjust-state-on-prop-change pattern so the sweep
starts in the same commit as the state flip (no fill flash).

Adds createAnimatedComponent to the RN test mock (real RN has it) and a
StatusDot web test covering the transition rules.

Refs #117. Web 1464 / mobile 411 tests + type-check + lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…weep

Replace the two-phase completion motion (thin arc outline, then a separate
disc blooming in to fill) with one continuous --primary wedge that fills
the dot as it sweeps clockwise, landing on the solid done dot. The sweep
now IS the fill, so there is no separate pop. ~420ms ease-out-quart.

Drops the now-unused status-fill keyframe (web) and fillScale Animated
value (mobile). Refs #117. Type-check + lint + StatusDot/habit-list tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror web's prefers-reduced-motion handling on mobile: read
AccessibilityInfo.isReduceMotionEnabled (and subscribe to changes) and skip
the StatusDot completion sweep when reduce-motion is enabled. Closes the
accessibility gap flagged by the impeccable animate gate; both platforms now
honor the setting.

Refs #117. Mobile type-check + lint + habit-list render test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ba gradients

- Web StatusDot tap target 42px -> 44px (padding 10 -> 11), meeting the 44px
  minimum touch-target guideline.
- Delete the dead `gradients` export from mobile lib/theme.ts (surfaceSheen,
  surfaceSheenChild, logButtonDone, statusDue, statusOverdue): zero consumers
  after the W1 sheen removal, and hardcoded rgba (a DESIGN ban). Drop the
  vestigial gradients key from the profile-screen test mock too.

Refs #117. Type-check + lint + affected tests green on both platforms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The drawer differentiator: a one-line mono summary strip (streak, linked
goal, checklist X/Y) at the top of the habit detail drawer. Web gains it (it
had none); mobile's hand-rolled strip moves onto its InfoRow primitive. Both
derive the text from a new shared formatHabitDetailSummary helper, so the
content is one source of truth and stays in parity.

Refs #117. type-check + lint + tests green on web/mobile/shared (the lone web
i18n-request failure under parallel load is a known timing flake; passes in
isolation at 2.8s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HabitDetailStatsGrid (the icon Flame/Trophy/BarChart3 + "{n}d" stat grid)
had zero production consumers — the live habit drawer uses HabitDetailStatsRow.
Remove it along with its now-orphaned HabitDetailSectionStyles type and the
icon imports; repurpose its test to cover the live HabitDetailStatsRow.

Refs #117. Mobile type-check + lint + tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Goals differentiator: replace the generic linear progress bar with the
app's orbital-ring vocabulary - a leading 36px hairline ParentRing fills with
the goal's progress, and the percent readout moves to mono tabular numerals.
ParentRing gains an optional color prop (second real consumer): completed/high
keep status-done, streak keeps the amber flame identity, abandoned mutes to
fg-3.

A11y preserved: web keeps the sr-only progress element (and the
data-progress-state hook moves to the ring wrapper); mobile keeps
accessibilityRole progressbar + accessibilityValue, and the tour target now
points at the ring. Also fixes a radius parity drift: mobile card 20px ->
12px to match web.

Refs #117. Type-check + lint green; web 67 goal tests + mobile 412 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The calendar legend lied on both platforms: it labeled the primary dot
"Upcoming" when the grid uses primary for TODAY, and the hollow partial ring
had no legend item at all. The legend now mirrors the grid vocabulary
exactly: today (primary), done (solid fg-1), partial (hollow ring), missed
(amber). calendar.legend.today/partial added, legend.upcoming deleted, and
the stale tour copy (green/yellow/red dots that do not exist) rewritten to
describe the real dots - both locales.

Parity fixes folded in: mobile gains web's month summary subtitle
("3/12 days (25%)" next to the month name), and the go-to-day button now
also renders on empty days on mobile (web's footer always shows it);
extracted GoToDayButton to avoid duplicating the pressable.

Refs #117. Static checks + all three suites green (web 150 / mobile 85 /
shared 66 test files, i18n key sync included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewrite the mobile GoalMetricsPanel to mirror web exactly: SectionLabel +
flush SettingsRow strip (tracking-status row with leading dot, projected
completion, velocity/days-remaining) + hairline adherence rows (title, 3px
bg-sunk track bar, mono percent). Kills the tinted status pill, the shadowed
stat cards, the bgElev-on-bgElev bar track, and the redundant per-habit
streak badge web never had; loading state now uses the SkeletonLine
primitive (wires plan item 6 for this surface).

The drawer-level "Status: Active" SettingsRow (web has no counterpart; it
only rendered for active goals, so it was static) is gone along with its
now-orphaned goals.detail.statusLabel key in both locales.

Refs #117. Static checks green all 3 workspaces; mobile 85 + shared 66 test
files pass (i18n key sync included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dialog

Mobile's canonical ConfirmDialog still filled its confirm button with
statusBad/statusOverdue - the exact semantic-fill ban W1 purged elsewhere,
and a parity break with web (whose canonical is text actions, destructive =
italic, "no semantic fill"). The mobile canonical now mirrors web's action
treatment exactly: right-aligned text buttons, cancel fg-3/500, confirm
fg-1/600, italic when destructive.

Both canonicals gain a variant="info" mode (single close action, cancel
hidden, onConfirm optional) which absorbs ConfirmDialogV2's info-only use:
advanced.tsx's widget-info and revoke-key dialogs now use the canonical, and
confirm-dialog-v2.tsx is deleted (zero refs; its unused eyebrow/children
extras die with it).

Refs #117. Type-check + lint green; web 150 + mobile 85 test files pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…yState

The chat empty-state rings on both platforms were perfect circles spun with
infinite linear rotation - invisible motion (a rotating circle looks
identical), pure wasted GPU. Web drops the two spin animations (identical
pixels); mobile inlines the same static composition (primary outer ring +
hairline inner ring + static Sparkles), deleting AnimatedSparkle and its
icon pulse loop. The misleading sparkleGlow style is renamed
sparkleInnerRing (it was always a hairline ring, not a glow). Mobile also
gains web's missing chat.empty.title and the prompt drops to web's 14px
italic.

Mobile goals empty state moves off its hand-rolled icon-disc block onto the
EmptyState primitive (web's goals-view already uses its EmptyState), with
the same card chrome web applies.

Refs #117. Type-check + lint green; web 150 + mobile 85 test files pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tions

Wire the W3 SettingsDescription mirror into its consumers: ai-settings
(aiMemory/aiSummary descriptions move off hand-rolled italic blocks) and
calendar-sync (connectGoogleFirst hint). Close 4 content-parity gaps where
web showed a description mobile lacked entirely: language, color scheme,
week start day (preferences) and auto-sync (calendar-sync).

Remaining italicText uses in calendar-sync/ai-settings are warnings and
conditional states with status colors - different content class, left as-is.

Refs #117. Mobile type-check + lint + 85 test files green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace every Easing.out(Easing.cubic) with the spec ease-out-quart bezier
(cubic-bezier(0.16,1,0.3,1) = motionEasings.enter) via a new
toAnimatedEasing helper in lib/motion, giving lib/theme's until-now unused
`easings` control points their consumers. The today-screen's local
createAnimatedEasing (with its mock-guard fallback) folds into the helper;
the RN test mock gains the missing Easing.bezier so no runtime guard is
needed (same incompleteness as createAnimatedComponent earlier).

Touches: status-dot sweep, confirm-dialog, anchored-menu, goal-card press,
theme-provider transition, today screen date-label + timing configs.

Refs #117. Mobile type-check + lint + 85 test files green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the scale(1.08) emphasis on the active color-scheme swatch in mobile
onboarding; web marks the active swatch with a static ring only. The active
fg-1 border already carries selection.

Refs #117. Validated in the full final gate (lint + type-check + tests green
across web/mobile/shared).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orbit-ui-mobile-web Ignored Ignored Jun 9, 2026 11:22pm

Comment thread apps/mobile/components/ui/skeleton.tsx Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One dead-code issue on the new mobile skeleton primitive: SkeletonCard and SkeletonAvatar are exported from the new apps/mobile/components/ui/skeleton.tsx with zero callers (only SkeletonLine is imported anywhere in mobile). The same-named SkeletonCard definitions in habit-list/empty-state.tsx and goals-view.tsx are local components with a different styles prop — they don't import from this module. Violates CLAUDE.md rule #2 ('no just-in-case exports'). Fix: drop the two dead exports from the primitive and add them back when they have real callers. Everything else in this PR — the ban purge, completion ring-sweep, parity closes, Kotlin widget token migration, logo swap, and primitive reconciliation — is clean.

… derived title

The goal form has no title input - the title derives from the description
or "quantity unit". validateGoalForm checked the derived title FIRST, so an
empty form reported "title is required" for a field the user cannot see.
Inputs are now validated before the derivation: missing quantity ->
targetValueRequired (under Quantidade), missing unit -> unitRequired (under
Unidade). A title remains optional everywhere; with quantity + unit set it
always derives, so the title error is unreachable from the form flow.

Updates the shared test that had encoded the old behavior and adds the
reported repro cases (all-empty, and quantity-set + unit-empty).

Refs #117 (rides the open goals PR). Shared 66 + mobile 85 test files green;
web full suite green except the known i18n-request parallel-load flake
(passes in isolation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/mobile/components/ui/skeleton.tsx Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dead-export issue from the prior review is still open: SkeletonCard and SkeletonAvatar are exported from apps/mobile/components/ui/skeleton.tsx with zero callers — only SkeletonLine is imported anywhere in mobile. The two commits added since (7e02e64 onboarding-swatch fix, 56b80a4 goal-form validation reorder) don't touch the skeleton file. Everything else in the PR is solid.

…r wrap

Two defects made the goal detail drawer read broken:

- The metrics block printed "Goal analysis" twice in a row: the section label
  and the status row both used goals.metrics.title (pre-existing on web, and
  faithfully mirrored onto mobile in the panel parity rewrite - the wrong
  side of the pick, since mobile had the correct "Status" label). The status
  row is now labeled "Status" on both platforms; goals.detail.statusLabel
  restored in both locales.
- The web footer quiet-link actions wrapped mid-label into two cramped
  centered lines ("Marcar como / Concluida"). Buttons are now nowrap and the
  row itself wraps (mobile already did this via flexWrap).

Refs #117. Static checks green all 3 workspaces; shared 66 + mobile 85 test
files + web goals tests (67) pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dead-export issue from the two prior reviews is still unresolved. SkeletonCard and SkeletonAvatar remain exported from apps/mobile/components/ui/skeleton.tsx with zero callers — the SkeletonCard names in habit-list/empty-state.tsx and goals-view.tsx are locally-defined components with a styles: prop, not imports from this module.

The latest commit (c10e5b7 — status-label fix + web footer nowrap) is clean: the goals.detail.statusLabel restoration on both platforms and the whiteSpace: 'nowrap' + flex-wrap row fix are correct. No new issues introduced.

Single blocker to unblock: drop SkeletonCard and SkeletonAvatar from the exports in apps/mobile/components/ui/skeleton.tsx. Add them back when they have real callers (CLAUDE.md rule 2 — no just-in-case exports).

…r vocabulary

The goal drawer footer predated the redesign and read broken: Ask Astra was
a non-clickable decoration on BOTH platforms (the habit drawer wraps the
same block in a real button), and the actions were centered quiet-links
that wrapped into two cramped lines with no hover states.

Now mirrors the habit drawer exactly:
- Ask Astra is a real button on both platforms - seeds the chat draft with
  the goal title and navigates to /chat; web gets the same hover lift,
  active scale, and trailing chevron as the habit drawer footer; mobile
  gets the TouchableOpacity + chevron treatment.
- Actions become full-width hairline rows (15px labels, hover/pressed
  bg-elev, destructive = italic fg-3): Mark completed / Abandon (or
  Reactivate), Edit, Delete. GoalActionButton -> GoalActionRow on web;
  mobile GoalActionFooter rewritten to Pressable rows.
- "Atualizar Progresso" gains a hover color shift.

Tests: both drawer tests mock the new router dependency.

Refs #117. Static checks green; web 150 (goals 67) + mobile 85 test files pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/mobile/components/ui/skeleton.tsx Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dead-export issue flagged in the two prior reviews is still unresolved. SkeletonCard and SkeletonAvatar remain exported from apps/mobile/components/ui/skeleton.tsx with zero callers — the three SkeletonCard components in habit-list/empty-state.tsx, goals-view.tsx, and habit-list.tsx are locally-defined components with a styles: prop that don't import from this primitive. SkeletonAvatar has no callers at all.

Single blocker: delete SkeletonCard and SkeletonAvatar from apps/mobile/components/ui/skeleton.tsx. Re-add them when there's a real caller. CLAUDE.md rule 2 — no just-in-case exports.

Everything else in this PR — the ban purge, completion ring-sweep, parity closes, Kotlin widget token migration, logo swap, primitive reconciliation, and the latest goal drawer footer redesign — is clean and solid.

…, OTP cell overflow

Add --bg-elev-pressed to web globals (mirrors mobile bgElevPressed) so
hover/pressed states are visible on bg-elev modal surfaces; the old
hover painted bg-elev on bg-elev. Goal drawer actions become icon+label
menu rows with no internal dividers. Ask Astra hover fixed in both
drawers. Code input digit cells get fixed 44px width + fieldset
min-inline-size reset: the UA default min-content sizing let six
size=20 inputs stretch the row to 2410px on desktop viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/mobile/components/ui/skeleton.tsx Outdated
Mobile skeleton primitive keeps only SkeletonLine (its sole consumer
surface); web keeps SkeletonLine + SkeletonCard (goals-view caller) and
drops SkeletonAvatar, which only its own test referenced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dead-export issue from the three prior reviews is still unresolved. SkeletonCard and SkeletonAvatar remain exported from apps/mobile/components/ui/skeleton.tsx with zero callers — only SkeletonLine is imported from this module anywhere in mobile. The local SkeletonCard definitions in habit-list/empty-state.tsx and goals-view.tsx take a styles: prop and are entirely separate components.

The two commits added since the last review (94e1cfb goal-drawer footer redesign and 9a78ec3 hover-states + OTP overflow fix) are clean — both land correctly on web and mobile with no new issues.

Single blocker: drop SkeletonCard and SkeletonAvatar from the exports in apps/mobile/components/ui/skeleton.tsx. Add them back when they have real callers (CLAUDE.md rule 2 — no just-in-case exports).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dead-export blocker from the prior four reviews is resolved: SkeletonCard and SkeletonAvatar are gone from the mobile primitive (only SkeletonLine remains, with real callers), and SkeletonAvatar is gone from the web primitive too. SkeletonCard stays in web with its goals-view.tsx caller intact.

The rest of this PR — ban purge (glows, shimmer, stripes, em-dashes), completion ring-sweep with reduce-motion parity, habit-drawer mono summary strip via shared formatHabitDetailSummary, goal-card ParentRing differentiator, truthful calendar legend + month-summary parity, goal-metrics-panel hairline-row rewrite, ConfirmDialogV2 consolidation, chat empty-state static rings, Android widget token migration, and the logo swap — is clean and solid.

Self-review of PR #154 against DESIGN.md found and fixed:
- goal-card web hover used bg-elev/80 dim; now bg-elev-pressed lift
- onboarding-complete trial box double hairline on both platforms
- today-shell date label hover aligned to sibling bg treatment
- profile delete-modal pressed states were bg-elev on a bg-elev sheet
- widget filled-circle drawable dropped its glow halo layer
- fresh-start: spring easing replaced with ease-out-quart on both
  platforms (web keyframe overshoot removed), mobile gains the
  reduce-motion guard and Geist type
- all remaining Animated.spring swept to timing + ease-out (toasts,
  onboarding check pop); web --ease-spring token deleted with its
  last three usages retargeted to --ease-out
- mobile goal-card text styles set Geist explicitly
- habit drawer parity: Ask Astra prompt keyed to checklist items on
  both platforms; reminder rows use label + mono time value on both
- mobile code-input mirrors fixed 44px centered cells

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@thomasluizon thomasluizon left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #154 (design-system conformance, issue #117)

Scope: All 120 changed files on feature/visual-polish-117, reviewed against DESIGN.md (v8 OKLCH tokens, Linear-tactical anchor, bans), root CLAUDE.md code standards, and web<->mobile parity. Review lenses: interactive-state visibility, divider/hairline overuse, token + type discipline, motion law, parity. Conducted with three parallel review lanes (web / mobile / shared+widget+parity) plus targeted gap re-reviews; every flagged finding was independently verified against the source before acting.

Recommendation: APPROVE (all findings fixed in 4045193, pushed to this branch)

Findings (all verified, all FIXED in 4045193)

High

  1. apps/web/app/globals.css + 5 mobile files: banned spring/elastic motion. --ease-spring (cubic-bezier(0.34, 1.56, 0.64, 1), overshoot) drove check-pop, tag-pop, and the fresh-start orb on web; Animated.spring appeared 9 times across mobile (fresh-start, app-toast, achievement-toast, welcome-back-toast, onboarding check pop) while the web twins use plain ease-out. Fixed: all retargeted to ease-out-quart (--ease-out / toAnimatedEasing(easings.out)), the orb keyframe's baked 1.1 overshoot frame removed, and the now-unused --ease-spring token deleted.
  2. apps/mobile/components/ui/fresh-start-animation.tsx: no reduce-motion guard on a 2.5s full-screen celebration (web twin already neutralizes under prefers-reduced-motion). Fixed: AccessibilityInfo.isReduceMotionEnabled() gate that skips rings/scale and keeps only the opacity fade.
  3. apps/mobile/app/(tabs)/profile.tsx:802,866: delete-account confirm buttons used pressed ? tokens.bgElev inside the bottom-sheet (surface = bgElev) — invisible pressed state, the same bug class fixed earlier in the goal drawer. Fixed: tokens.bgElevPressed. (The same pattern in profile-nav-card / profile-action-button was verified CORRECT — those rows sit on the --bg screen.)
  4. Parity drift, habit-detail-drawer: web keyed the Ask Astra prompt on checklistItems.length, mobile on hasSubHabits — same habit produced different prompts per platform. Fixed: both platforms now key on checklist items.

Medium

  1. apps/web/components/goals/goal-card.tsx:98: hover was bg-[var(--bg-elev)]/80 — a dimming blend toward the page color (sinks on hover instead of lifting). Fixed: --bg-elev-pressed.
  2. Double hairline, onboarding trial box, BOTH platforms (apps/web/components/onboarding/onboarding-complete.tsx, apps/mobile/components/onboarding/onboarding-complete.tsx): recap list's last borderBottom stacked against the trial card's borderTop. Fixed: trial card keeps only its bottom rule.
  3. apps/mobile/modules/orbit-widget/.../widget_circle_filled.xml: layer-list carried a literal "outer glow ring" (#33FFFFFF halo) — DESIGN.md glow ban, and an explicit #117 widget acceptance criterion. Fixed: single solid 22dp oval (tinted at runtime as before).
  4. Parity drift, reminder rows: web drawer interpolated time into the label (...At keys) while its own dueTime row directly above — and the mobile drawer — use the SettingsRow label + mono value vocabulary. Fixed: web drawer now renders plain label + displayTime mono value; the ...At keys remain in use by both platforms' habit forms (correct context there).
  5. Geist discipline (mobile): goal-card.tsx title/progress/badge texts and fresh-start-animation.tsx title/subtitle set no fontFamily, falling back to the system font. Fixed: explicit Geist; fresh-start title moved from off-ramp 24px to --fs-lg 22.

Low

  1. apps/web/app/(app)/today-shell.tsx:180: date label hovered via opacity-80 while its sibling chevrons use hover:bg-[var(--bg-elev)] — inconsistent feedback inside one control cluster. Fixed: matched to the sibling treatment.

Findings raised by review lanes and REJECTED after verification

  • conflict-warning.tsx (mobile) hex-alpha severity tints: identical to the web twin's /10/30 Tailwind alphas; sanctioned severity-banner vocabulary, not a button fill.
  • goal-card (mobile) status/deadline badges bgElev-on-bgElev: exact mirror of web's badge classes; hairline-ringed Chip vocabulary where the ring, not the fill, carries the boundary.
  • Three "add explanatory comments" suggestions: rejected outright — root CLAUDE.md rule 5 bans narration comments (lint-enforced).

Parity verification

10 pairs read side by side: status-dot, confirm-dialog, goal-detail-drawer (+footer/styles), goal-card, calendar-shell, code-input, parent-ring, app-logo, goal-metrics-panel, habit-detail-drawer. 8 PAIRED as-is; the 2 drifts found (Ask Astra discriminator, reminder rows) are fixed above. i18n: en.json and pt-BR.json structurally paired, no missing keys either direction, zero em dashes in copy.

Widget acceptance (issue #117)

  • No per-scheme palette remains in Kotlin: single neutral bootstrap fallback only; resolved token hexes arrive via syncTheme from createTokensV2 (Kotlin is presentation-only).
  • Status colors keyed to tokens (done = primary, overdue token, neutral empty); no red/amber fills; drawables are flat solids + strokes (glow halo removed in this review).
  • Data contract (GetHabitWidgetQuery, refresh, size classes) untouched.

Validation

Check Status
Lint (web + mobile + shared) PASS
Type check (3 workspaces) PASS
Web tests PASS (150 files, 1465 tests)
Mobile tests PASS (85 files, 412 tests)
i18n locale parity PASS

What's Good

The token system holds across every changed surface: hairlines carry structure, status reads from canonical --status-*, the single accent stays reserved, mono metadata is consistent, and the new --bg-elev-pressed token closes the invisible-state class on both platforms. The widget migration is clean single-source-of-truth work. Motion is now uniformly ease-out-quart with reduce-motion respected on every celebratory surface.

Recommendation

Fixes are on the branch as 4045193. Ready for the bot re-review and merge.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dead-export blocker (SkeletonCard/SkeletonAvatar) was resolved in the previous commit, and the final commit is clean.

The final commit's changes are all correct: profile pressed states use bgElevPressed (not bgElev) on the delete-modal sheet; Animated.spring swept to timing + ease-out-quart across toasts, onboarding-complete-habit, and fresh-start; the fresh-start reduce-motion guard follows the correct async/cancelled-flag pattern; Geist font explicitly set on goal-card text styles; the mobile habit-drawer Astra prompt keyed to checklistItems.length (exact parity with the web side at habit-detail-drawer.tsx:107); reminder rows restructured to label + mono value on both platforms; --ease-spring CSS token fully removed with all three callers retargeted to --ease-out; goal-card hover corrected to bg-elev-pressed; widget glow layer dropped.

The full scope of this PR — ban purge (glows, shimmer, animated circles, stripes, em-dashes, --ease-spring), completion ring-sweep with reduce-motion parity on both platforms, habit-drawer mono summary strip via shared formatHabitDetailSummary, goal-card ParentRing differentiator, truthful calendar legend + month-summary subtitle parity, goal-metrics-panel hairline-row rewrite, ConfirmDialogV2 consolidation, Android widget v8 token migration, and the AppLogo swap — all landed correctly.

The bottom-tab-bar FAB replaced the old floating button without carrying
data-tour="tour-fab-button", so the habits tour timed out on step 5 and
silently skipped it. StatusDot also rendered a <button> even with no
onToggle, nesting a button inside GoalCard's motion.button (hydration
error during the goals tour). Read-only dots now render a span with an
img role, matching the mobile StatusDot's View branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/components/ui/status-dot.tsx Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correctness bug in the new commit: the read-only span condition in status-dot.tsx is (!onToggle || disabled), but habit-row.tsx passes both onToggle and disabled together. When disabled=true, the dot now renders as a span with role=img even though it is a temporarily-disabled toggle button — wrong ARIA role for AT users. Fix: drop the || disabled clause and keep only !onToggle; the button branch already handles the disabled state correctly on lines 98-99. One-character fix. Everything else in the PR is solid.

Onboarding: web week-start selector rendered seven hardcoded English
day initials with five dead chips; now mirrors mobile's two localized
chips from ONBOARDING_WEEK_START_OPTIONS. Flow step content vertically
centers via auto margins inside the scroll column. Section labels
center on both platforms to match the hero composition. The features
step referenced onboarding.flow.features.subHabits.* which existed in
neither locale (web crashed with MISSING_MESSAGE; mobile pointed at a
paywall object key) — keys added to both locales, mobile retargeted.

StatusDot: review-bot catch — disabled interactive dots demoted to
role=img instead of a disabled button. Read-only branch now gates on
onToggle only; mobile Pressable branch gains disabled handling it
never had (disabled prop, accessibilityState, dimmed style).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The StatusDot disabled-ARIA bug flagged in the previous review is correctly fixed on both platforms: the read-only branch now gates on !onToggle only, so temporarily-disabled interactive dots stay as <button disabled> (correct role for AT users) rather than being demoted to role=img. The mobile Pressable branch gains the matching disabled prop, accessibilityState, and 0.4 opacity dim it was missing. The rest of the final commit — onboarding week-start chip parity (ONBOARDING_WEEK_START_OPTIONS replacing the 7-hardcoded-initial row), missing subHabits i18n keys added to both en.json and pt-BR.json, and vertical centering — is all clean. This is a very large, well-executed craft lift: ban purge, completion ring-sweep with reduce-motion parity, habit-drawer mono summary strip via shared formatHabitDetailSummary, goal-card ParentRing differentiator, truthful calendar legend + month-summary subtitle parity, goal-metrics-panel hairline-row rewrite, ConfirmDialogV2 consolidation, Android widget v8 token migration, and the AppLogo swap. Approving.

tour-goals-tab, tour-calendar-month-nav, and tour-profile-streak had
no DOM anchors after the polish, so the tour timed out and skipped
goals step 1, calendar step 3, and profile step 1. SectionHeadTabs
gains per-tab dataTour plus the tab-<id> element ids its keyboard
focus restore already targeted; the calendar month-nav cluster and
the profile streak row carry their anchors again. Mobile already
registers all three via useTourTarget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three web tour anchors missing after the polish pass (tour-goals-tab on the goals SectionHeadTab, tour-calendar-month-nav on the month-nav cluster, tour-profile-streak on the profile streak row) are correctly restored. Mobile already had all three via useTourTarget — parity is intact. The id={tab-${tab.id}} addition to SectionHeadTabs is already consumed by today-shell.tsx:105 for keyboard focus restore (not dead code). No high-signal issues in any of the new commits. The full scope of this PR — ban purge, completion ring-sweep with reduce-motion parity, habit-drawer mono summary strip via shared formatHabitDetailSummary, goal-card ParentRing differentiator, truthful calendar legend + month-summary parity, goal-metrics-panel hairline-row rewrite, ConfirmDialogV2 consolidation, Android widget v8 token migration, AppLogo swap, StatusDot ARIA semantics, and these tour anchor restores — is solid.

SettingsDescription drops its hardcoded closing rule on both platforms;
mobile SettingsRow gains the divider prop web already had. Calendar-sync,
ai-settings, advanced, upgrade and preferences stop ruling sparse status
notes, empty states and feature lists; web advanced also gates the Pro
wrapper (free users saw an empty bordered box) and spaces the MCP
instructions block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final commit (574099c) is a clean whitespace-over-hairlines cleanup: both SettingsDescription components drop their hardcoded closing rule, mobile SettingsRow gains the divider prop that web already had (default true, divider={false} on callers with following helper text), and web advanced correctly gates the Pro API-keys block so free users no longer see an empty bordered box. All changes land on both platforms. No new issues introduced. Full scope of this PR — ban purge, completion ring-sweep with reduce-motion parity, habit-drawer mono summary strip via shared formatHabitDetailSummary, goal-card ParentRing differentiator, truthful calendar legend + month-summary parity, goal-metrics-panel hairline-row rewrite, ConfirmDialogV2 consolidation, Android widget v8 token migration, AppLogo swap, StatusDot ARIA semantics, and tour anchor restores — is solid and ready to merge.

…Astra

The lucide Sparkles glyph read as generic AI iconography. Every surface
where it meant Astra/AI now uses lucide Orbit, stroke-only (the summary
header previously filled it): Today summary, chat header/empty state,
message bubbles, typing indicators, Ask-Astra drawer footers,
retrospective, AI settings, onboarding, and the profile nav (shared
iconKey sparkles renamed to orbit). Sparkles survives only where it
means Pro shine (upgrade) or referral reward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final commit (4ac6d12) is a clean Sparkles → Orbit icon swap for all AI/Astra surfaces. Both web and mobile updated in parity: Today summary, chat header/empty state, message bubbles, typing indicators, Ask-Astra drawer footers (habits + goals), retrospective, AI settings, onboarding meet-astra and features, and the profile nav (shared iconKey 'sparkles' → 'orbit' with both ProfileNavIcon switch arms updated). fill removed from the Orbit glyph on today-ai-summary (stroke-only per design spec). Sparkles correctly preserved on upgrade/referral surfaces. Tests updated throughout. No dead code, no type violations, no parity gaps. Full scope of this PR — ban purge (glows, shimmer, animated circles, em-dashes, --ease-spring), completion ring-sweep with reduce-motion parity, habit-drawer mono summary strip via shared formatHabitDetailSummary, goal-card ParentRing differentiator, truthful calendar legend + month-summary parity, goal-metrics-panel hairline-row rewrite, ConfirmDialogV2 consolidation, Android widget v8 token migration, AppLogo swap, StatusDot ARIA semantics, tour anchor restores, and now the Orbit mark for Astra — is solid and ready to merge.

@thomasluizon
thomasluizon merged commit 915c65e into main Jun 9, 2026
7 checks passed
@thomasluizon
thomasluizon deleted the feature/visual-polish-117 branch June 9, 2026 23:24
@sonarqubecloud

sonarqubecloud Bot commented Jun 9, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frontend visual polish (Linear-tactical, anti-AI-slop) (umbrella)

1 participant