Skip to content

combined: habits redesign + streak merit + audit fixes - #52

Closed
thomasluizon wants to merge 35 commits into
mainfrom
combined-habits-streak-audit
Closed

thomasluizon wants to merge 35 commits into
mainfrom
combined-habits-streak-audit

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Combined Changes

Bundles three worktree branches' frontend work (web + mobile + shared):

  • task1-habits-redesign (8 commits) — Notion-style emoji picker (frimousse web / rn-emoji-keyboard mobile), full habit-card rewrite with "Avatar + Arc" design (web 1218→446 LOC, mobile 1524→568 LOC), shared emoji utils + habit-progress utils, consistent spacing pass on habit form modal
  • task2-streak-merit (1 commit) — UI for merit-based streak freeze (held count, monthly usage, earn progress, at-cap state), new i18n keys (en + pt-BR), optimistic update, full web+mobile parity
  • task3-audit-fixes (9 commits) — forward client IPs on auth BFF routes, cookie maxAge 1d→7d, DOMPurify on chat rendering, separate habitKeys.fullDetail(id), goal deadline + reminder minutes + due-time validators, mobile getItemLayout, single-flight refresh-token mutex, verification-code paste error, mobile parity ports (bulk-action-bar, controls-menu, today-filters, calendar-grid, calendar-day-detail + 6 hooks), web tour-replay-modal back-port, server-authoritative conversationId wiring

Skipped

  • task3 commit 5fa7a69 (mobile habit-card/habit-list split) — fully obsoleted by task1's habit-card rewrite (568 LOC is cleaner than task3's 868 LOC split). Two TODO(task3-p1) markers remain in apps/mobile/components/habit-list.tsx — one accurately describes the un-split list (2100+ LOC), one is stale.

Conflicts Resolved

  • packages/shared/src/__tests__/validation.test.ts — additive merge (icon + due-time + reminder tests coexist)

Cross-Platform Parity

Every behavioral / validation change landed on both web and mobile. Shared package holds all Zod schemas, query keys, i18n, utilities.

Test Plan

  • npm install (new deps: frimousse, rn-emoji-keyboard)
  • npx turbo run type-check — should pass cleanly
  • npx vitest run packages/shared — 835+ tests expected
  • npx vitest run apps/mobile/__tests__/components/habits/
  • Manual smoke: create habit with emoji, Today view renders new card, streak page shows earn progress, chat persists across reload
  • E2E sanity against staging

Paired Backend PR

thomasluizon/orbit-api#150


🤖 Generated with Claude Code

thomasluizon and others added 18 commits April 14, 2026 21:50
… i18n

Threads an optional `icon` string through every habit schema and request
builder so both apps can carry the emoji. Adds platform-agnostic helpers
for recent-emoji tracking and a grapheme-aware title initial fallback so
the redesigned card always has an identity.

- Types: icon on baseHabitFieldsSchema + create/update/subHabit/bulk request schemas
- Validation: validateHabitIcon + normalizeHabitIcon (1..32 chars, no control chars), MAX_HABIT_ICON_LENGTH constant, threaded into validateHabitForm
- Request builders: icon normalized into create/sub-habit/update payloads; update only writes icon when the form field is defined so untouched forms stay lean
- Utils: emoji-recents (parse/add/remove/stringify with 24-item limit and dedupe) and getHabitInitial (Intl.Segmenter with UTF-16 fallback)
- i18n: habits.form.icon* and habits.emojiPicker.* keys in en + pt-BR with proper diacritics
- Tests: 37 new unit tests covering null/emoji/ZWJ/flag/over-length/control-char paths and recents CRUD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduces a frimousse-backed emoji picker inside AppOverlay so the habit
form gains a real Unicode icon field, and threads the picked emoji
through the card as a 40px avatar tile with title-initial fallback.

- frimousse@0.3.0 wraps EmojiPicker.Root/Search/Viewport/List with Tailwind-styled primitives, Notion-style recents row, and skin-tone selector
- HabitEmojiPicker renders inside existing AppOverlay (Dialog on desktop, Drawer on mobile) so it matches every other modal surface
- HabitIconField trigger in habit-form-fields.tsx wires icon into react-hook-form via setValue with shouldDirty+shouldValidate; errors flow through errors.icon.message as an i18n key
- HabitEmoji renders icon or getHabitInitial() in a themed tile; used on card header for parent habits (hidden on narrow mobile for now)
- useEmojiRecents hook persists last 24 emojis to localStorage via the shared addRecent/parseRecents helpers
- useHabitForm and buildEmptyHabitFormValues / buildEditHabitFormState default icon to null; edit modal seeds from habit.icon so existing habits survive
- Shared utils index re-exports getHabitInitial + emoji-recents helpers for mobile parity

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

Mirrors the web emoji picker on mobile using rn-emoji-keyboard's modal
EmojiPicker themed to the active color scheme, plus a HabitEmoji avatar
tile that renders the picked emoji or the title initial fallback.

- rn-emoji-keyboard@1.7.0 EmojiPicker with enableSearchBar + enableRecentlyUsed + categoryPosition top, themed via active scheme tokens (primary, surface, surfaceElevated, textPrimary/Secondary/Muted, borderEmphasis)
- HabitEmojiPicker swaps the en/pt translation packs based on i18n.language so PT and EN users see translated category labels
- HabitIconField trigger uses HabitEmoji + ChevronDown row on form, with an inline Clear button when an icon is set
- HabitEmoji uses theme primary_15 for tint, primary for filled state, red500_10/danger for bad habits, red500_30 ring for overdue
- habit-card renders the avatar tile for parent habits (not children, not in select mode, not when there are sub-habit progress rings)
- useHabitForm seeds icon: null in defaultValues; useEmojiRecents persists to AsyncStorage via shared addRecent/parseRecents helpers
- habit-form-state empty-values test updated to include icon: null

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

Moves getHabitProgressStrokeDasharray from apps/mobile/lib into
packages/shared/src/utils/habit-progress.ts alongside two new helpers
used by the Avatar+Arc redesign:

  - getHabitProgressRatio(habit, { hasChildren, childrenDone,
    childrenTotal }) - 0..1 fill ratio for the arc, covering completed,
    parent-with-children, and flexible habits.
  - shouldShowHabitProgressArc(habit, context) - whether the arc should
    render around the avatar tile at all.

Keeps apps/mobile/lib/habit-progress.ts as a thin re-export so the
existing mobile test and any direct imports keep working.
Full visual rewrite of the Today / habits list card:

  - New 52px rounded-square avatar tile anchors the card on the left;
    renders the chosen emoji or the title's initial as fallback. Single
    accent surface per card so a 15-card Today view stays calm.
  - Tapping the avatar logs / unlogs the habit (or toggles selection in
    select-mode, or force-logs a parent with unfinished children).
  - Optional thin SVG progress arc wraps the tile for parent-with-
    children and flexible habits, sharing the shared progress helpers.
  - Slim 3px left bar marks due-today / overdue status; 1px destructive
    ring on the tile hints overdue.
  - Completed state: 0.55 opacity, solid primary tile, tiny check badge
    in the bottom-right corner with a surface ring so it reads clean
    against the background.
  - Compact meta row with overflow +N collapse keeps the Today view
    dense; tags / streak / checklist / goals all flow through one row.
  - Checklist progress renders as a 3px full-bleed bottom strip instead
    of a chip badge, staying out of the meta row.
  - Menu (kebab) extracted to habit-card-menu.tsx with portal,
    keyboard navigation, viewport flip, and bottom-nav awareness.
  - Card drops 900+ lines of legacy layered-glass styling in globals.css
    in favour of a cleaner v4 block with CSS variables and hover/press
    transitions that respect motion-reduction.
  - habit-card.tsx itself drops from 1,218 to ~440 lines by leaning on
    habit-avatar-tile, habit-meta-row, and habit-card-menu primitives.

Preserves every existing behavior: long-press, drag-to-reorder hooks
(data-no-drag), bulk select mode, checklist progress, parent/sub-habit
nesting, tour anchors (tour-habit-card / tour-habit-tags), and the
portal-based actions menu with ArrowUp / ArrowDown / Home / End nav.

Tests: the existing 43 behavioral tests pass against the new
implementation (one assertion tweak for the renamed completed state
class); 8 new tests cover emoji rendering, initial fallback, arc
presence, tile-tap log/unlog, and status-bar class for overdue.
Mobile counterpart to the web rewrite. Same visual direction, same
interaction model, same cross-platform parity:

  - New HabitAvatarTile (components/habits/habit-avatar-tile.tsx):
    52 px rounded-square surface with the user's emoji or the title's
    initial fallback. Tap to log. Optional 2 px SVG arc wraps the tile
    via react-native-svg using the shared getHabitProgressStrokeDasharray
    and getHabitProgressRatio helpers.
  - HabitMetaRow (components/habits/habit-meta-row.tsx): compact chip
    strip with +N overflow, mirroring the web layout.
  - HabitCard rebuilt from scratch (~1,524 LOC -> ~570 LOC) around the
    shared primitives. LinearGradient-heavy v3 chrome + log-button
    ceremony replaced by a calm neutral surface with a single accent
    in the avatar tile. Slim 3 px status bar marks due-today / overdue.
  - Tile pulse on completion (spring scale 1 -> 1.08 -> 1) and tile
    creation glow moved off the whole card onto the tile itself so the
    attention lands where the identity is.
  - Behavior preserved verbatim: long-press to drag, bulk select mode
    with checkbox in tile slot, anchored actions menu, parent/sub-habit
    expand toggle, checklist progress strip along the bottom edge,
    tour anchors (tour-habit-card / tour-habit-tags).
  - Child habits drop the arc and status bar; indent and smaller
    rounded radius preserve the nesting signal.

Tests: all 5 existing mobile habit-card behavioral tests still pass,
plus 3 new tests exercising emoji rendering, initial fallback, and
tile-tap log flow.
Polish pass on the create / edit habit modals so every section reads
with a steady rhythm:

  - Outer <form> bumped from space-y-4 / space-y-5 to space-y-6 (1.5rem
    between every top-level section). User screenshot showed the gap
    after FREQUÊNCIA, around TAGS, and around the 'Mais opções' divider
    felt cramped at the previous gap.
  - The 'Mais opções' divider now uses mt-2 + pt-3 with a tighter inner
    py-2 button so the toggle has breathing room above and below the
    horizontal rule instead of being glued to it.
  - Inside the collapsible advanced section, pt-4 instead of pt-2 so the
    Description / CHECKLIST sections start cleanly below the toggle.
  - Checklist + Templates inner gap bumped from mt-3 to mt-4 so MODELOS
    sits clearly below the input row instead of glued to it.

No structural changes; pure spacing polish. All 70 affected
form-fields / create-modal / edit-modal tests still pass.
Mirror of the web spacing polish so both platforms read the same:

  - Container gap bumped from 22 to 24 so every top-level section sits
    on the same vertical rhythm as the web 1.5rem (space-y-6).
  - fieldGroup gap bumped from 8 to 10 so each section's label sits
    further from its input.
  - 'Mais opções' divider expanded its paddingTop from 8 to 14 with a
    fresh marginTop: 4 so the horizontal rule isn't glued to the
    section above; the button itself uses paddingVertical 6 so the
    toggle row stays compact.
  - advancedSection gap bumped from 24 to 28 with paddingTop 12 so the
    expanded options breathe and start cleanly below the toggle.
  - Bad-habit checkboxRow paddingVertical bumped from 4 to 6 to match
    the looser cadence of its neighbors.

No new tests required for a pure spacing pass.
Mirror the API contract change: freezes are earned (1 per 7-day streak,
cap 3 held) and usage is capped at 3 per calendar month. Display both
caps separately and show earn-progress with a 7-step bar.

Shared:
- streakInfoSchema + streakFreezeResponseSchema extended with the new
  merit-based fields (balance, held cap, days/progress to next, etc.).
- deriveStreakFreezeState returns the extra fields plus canEarnMore and
  monthlyLimitReached; canFreeze now requires a positive balance.
- formatFreezeEarnProgress helper; factories gain createMockStreakInfo.
- i18n: new keys balanceHeld, monthlyUsage, earnProgress*, capped*,
  noneEarned*, earnedCelebration*; confirmBody rewritten to surface
  held and monthlyRemaining; new toast errors for the two error codes
  (pt-BR with proper diacritics).

Web + Mobile parity:
- use-gamification applies server-authoritative balance/usage values
  on freeze success and returns the full derived state.
- Streak page / screen renders held + monthly usage side-by-side and
  a 7-dot progress bar (or the "max held" banner when capped).
- Profile streak card shows "Next freeze in N days" or the held cap.
- Freeze error handling maps NO_STREAK_FREEZES_EARNED and
  STREAK_FREEZE_MONTHLY_LIMIT_REACHED to distinct i18n messages.
…ferral/JWT

Security hardening on the web BFF auth layer:

- Dedicated /api/auth/{send-code,verify-code,google,logout} routes now call
  buildForwardedClientHeaders(request) so X-Forwarded-For / CF-Connecting-IP /
  X-Orbit-Country-Code / Accept-Language / X-Orbit-Time-Zone reach the .NET
  backend. Previously these routes sent no client context, so the backend's
  DistributedRateLimitFilter partitioned every anonymous auth request under
  the Next.js server IP — collapsing the per-client 5 req/min bucket.

- auth_token cookie maxAge changed from 86_400 (1 day) to 604_800 (7 days) so
  it matches the backend JwtSettings.ExpiryHours=168. Prevents daily forced
  token refresh round-trips.

- Length-cap the JWT payload segment in /api/auth/session before JSON.parse
  (4 KB ceiling). Blocks a memory-amplification vector where a malicious
  cookie contains a megabyte-scale base64 segment.

- Cap referral query-param at 40 chars before cookie write on the login page.

Includes new unit tests for send-code header forwarding and updates
auth-api.test.ts to assert the 7-day maxAge.

Covers frontend plan Area A #1, #2, #4, #5.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
formatChatMessage previously only ran a hand-rolled regex that escaped
& < > ". It was then passed straight into dangerouslySetInnerHTML. That is
safe today, but:
- Single-quote and backtick were not escaped.
- A malicious AI reply could emit RTL overrides, zero-width joiners, or
  homograph URL fragments that the regex never touches.

The formatter now:
1. Escapes &, <, >, ", ', and `.
2. Applies the **bold** / *italic* markdown replacements.
3. Runs DOMPurify as defense-in-depth with ALLOWED_TAGS=['strong','em']
   and no attributes, so even if future markdown rules introduce a tag
   (e.g. <a>), it cannot leak executable markup.

DOMPurify was already a direct dependency (used in retrospective/page.tsx
and app-overlay.tsx), so this adds no new package weight.

Adds tests for single-quote/backtick escaping and DOMPurify's strip-unknown-
tag behavior.

Covers frontend plan Area A #3 (P0 #7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
useHabitDetail (HabitDetail shape) and useHabitFullDetail (HabitFullDetail
shape) both cached under habitKeys.detail(id). Whichever hook ran second
won the cache entry; the other consumer then read mis-typed data, risking
runtime crashes where fields were missing.

Adds habitKeys.fullDetail(id) and retargets useHabitFullDetail on web +
mobile. Cache invalidations in use-habits and habit-mutation-helpers now
invalidate both keys on habit mutation.

Covers frontend plan P0 #3 / Area C #8.

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

Covers the P1 missing-validation findings in the audit:

- validateGoalDeadline(deadline, today): blocks past dates, rejects garbage
  strings (goals.form.deadlinePast / goals.form.deadlineInvalid i18n keys).
- validateReminderTimes(minutes): enforces the backend contract that
  minutes-before-due offsets are integers in [0, 1440] and unique.
- validateDueTimes(dueTime, dueEndTime): blocks the case where a user sets
  only the end time without a start time.

validateReminderSelection and validateHabitForm now delegate into the new
validators so inline errors fire on the existing form flows without
touching each component.

Adds en/pt-BR i18n keys with proper diacritics on the Portuguese strings.
Extends validation.test.ts and goal-form.test.ts with exhaustive boundary
cases.

Covers frontend plan Area D #1, #2, #3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DraggableFlatList previously had no getItemLayout, so React Native had to
measure every row before rendering during initial scroll and drag events.
With 40+ habits on mid-tier Android this produces visible jank.

Adds an approximate ESTIMATED_HABIT_ITEM_HEIGHT=104 and wires getItemLayout
onto the primary DraggableFlatList driver. The value does not need to be
perfect: telling RN that rows are of roughly uniform height unlocks
scroll-to-offset fast-paths and smoother drag.

Covers frontend plan Area C #3 (P0 #9).

Note: Splitting habit-list.tsx + habit-card.tsx into smaller files
(Area C #1, #2) is deferred as a TODO — it is a large refactor across
2,119 + 1,511 LOC and is better done in a dedicated PR with profiling
measurements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Non-shippable fixes from the audit that require architectural decisions,
new migrations, or large refactors have TODO comments citing PLAN.md:

- auth-api.ts: tryRefreshSession mutex — needs single-flight dedup (Area A #6)
- actions/chat.ts: server-persisted conversations — needs DB migration + API
  contract change (coordinated with backend F4 / P0 #5)
- habit-list.tsx: split into smaller components (Area C #1)
- habit-list.tsx: mobile parity — port bulk-action-bar, controls-menu,
  today-filters from web (Area E #1)

Each TODO references PLAN.md with the audit section + priority so the
follow-up PR author can find the context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Single-flight map in apps/web/lib/auth-api.ts keyed by refresh-token
  value. Five concurrent 401 retries now issue ONE /auth/refresh request
  and receive the same result, so backend token rotation cannot
  invalidate overlapping refreshes. Test covers 5 concurrent callers.
- hasInvalidVerificationCodeChars helper in @orbit/shared/utils.
  use-login-code-entry (web + mobile) now surfaces an inline error when
  the paste/input contains non-digits, instead of silently stripping.
- Added i18n key auth.errors.codeMustBeDigits in en + pt-BR locales.
  Login UI on both platforms renders the error inline with role=alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mobile parity ports of the web-only components and hooks the audit
flagged. Each is a faithful port (same i18n keys, same prop names,
same behaviour) using NativeWind + RN primitives instead of HTML.

Mobile components added:
- habits/bulk-action-bar.tsx — floating select-mode action bar
- habits/controls-menu.tsx   — Modal sheet for select / collapse /
                                refresh / show-completed
- habits/today-filters.tsx    — search bar + frequency / tag chips
- calendar/calendar-grid.tsx  — month grid with status colouring
- calendar/calendar-day-detail.tsx — bottom-sheet day detail

Mobile hooks added (parity surface for cross-platform call sites):
- use-summary.ts — re-exports useSummary + adds useInvalidateSummary
- use-chat-composer.ts — debounced AsyncStorage draft persistence
- use-calendar-data.ts — direct parity with web's useCalendarData
- use-color-scheme.ts — wraps useAppTheme with the web hook surface
- use-popover-menu.ts — open/close/toggle (sheet equivalent on RN)
- use-push-notification-preferences.ts — wraps usePushNotifications

Web addition:
- tour/tour-replay-modal.tsx — back-port of the mobile-only modal so
  both platforms expose the same tour-replay UX. Uses AppOverlay so
  it adapts to Dialog (desktop) / Drawer (mobile <640px).

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

- Shared chat-store now exposes conversationId + setConversationId
  (server is the source of truth; clients only persist a pointer).
- Web chat composer and mobile chat screen now send conversationId
  on every turn after the first and capture the server-issued id from
  the response. Legacy 'history' field still sent for one release.
- ChatResponse Zod schema gains optional conversationId so TypeScript
  consumers see the field on res.data.
- Web chat.ts now imports types via subpath (@orbit/shared/types/chat)
  to bypass a tsconfig path-mapping quirk in worktrees that resolves
  the bare '@orbit/shared' import to the main repo's symlink instead
  of the worktree copy.
- The TODO(task3-p1) marker on chat.ts is removed; the doc comment is
  updated to describe the new conversationId flow.
- Mobile + web chat-store clearMessages also resets conversationId so
  'New chat' starts a brand-new server-side conversation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Apr 15, 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 Apr 15, 2026 1:59pm

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ef07deb26

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

cy="18"
r="15"
fill="none"
stroke={isCompleted || progressRatio === 1 ? colors.primary : primaryRgba(0.7)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use active theme color for mobile avatar arc

The incomplete progress arc uses primaryRgba(0.7), which defaults to a hardcoded purple instead of the currently selected theme color. On non-purple schemes, the tile background/badge follow colors.primary but the arc (and related glow path in this component) stays purple, causing inconsistent accent colors in the habit card UI. Derive the tint from colors.primary (or pass its RGB into primaryRgba) so arc/glow stay in sync with the active scheme.

Useful? React with 👍 / 👎.

thomasluizon and others added 10 commits April 14, 2026 22:23
Adds editorial depth to the habit card v4: a subtle vertical gradient,
1px top-edge highlight, and hover lift with a hint of accent-tinted
shadow. Replaces the muted "Atrasado" chip with a colored status label
(warm coral) plus a 6px breathing pulse dot — urgent but not alarming —
and matching variants for Today and Completed. Refines the type chip
with small-caps + tighter tracking so the title holds focus, and adds
a hover-only accent shadow on the avatar tile with a gradient-feel ring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mirrors the web card polish: a 1px top-edge highlight overlay, a warm
coral for overdue (vs. hot alarm red), a more restrained 0.985 press-in
scale, richer shadows, and slightly stronger dark-mode hover borders.
The meta row now renders Today / Overdue / Completed as a colored label
with a 6px dot instead of a destructive chip, and the type chip reads
slightly de-emphasized so the title holds focus. Completed state also
desaturates the emoji/initial on the tile to match web parity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a native <dialog open> reset in globals.css so the picker's panel
properly participates in the backdrop's flex centering (was pushed to
one side by the UA margin: auto left/right). Introduces a Notion-style
active-emoji name bar in the footer, driven by frimousse's
useActiveEmoji so it tracks hover and keyboard focus with a colon-style
shortcode hint. Refines the search input with a leading magnifier + soft
focus ring, gives the category headers tighter tracking, adds a fading
top scroll shadow on the grid, and the "no results" empty state now
uses a subtle glyph illustration with a helper hint. Adds en and pt-BR
keys for hoverHint and noResultsHint with proper diacritics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a preview / confirm flow in rn-emoji-keyboard's customButtons slot
so mobile users see the emoji's name bar react to their taps before
committing — the RN platform has no hover event, so this is the closest
analogue to the web's Notion-style hover name bar. The name bar shows
the currently tapped emoji's name (or the saved icon as a fallback),
with explicit Confirm and Clear actions. Threads currentIcon and
onClear through habit-icon-field so the picker can preview existing
icons and clear them from inside the sheet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Avatar+Arc redesign layered a card-wide <button> at z-0 beneath
title/meta content at z-10, which meant clicks on the actual text never
reached the click handler. Effect on users:

  - In select mode, tapping the habit title/body did nothing; only the
    avatar tile toggled selection.
  - Outside select mode, tapping the habit title/body did not open the
    detail modal.

Fix: move the click handler onto the card container itself (role="button"
+ tabIndex for keyboard parity), and let interactive children
(expand toggle, avatar tile, kebab menu) stopPropagation on their own
click handlers. Belt-and-braces: a data-card-click-ignore attribute on
those wrappers makes the card-level handler bail out via closest(), so
a descendant forgetting stopPropagation (e.g. portal) still won't
double-fire.

The SelectionCircle is now presentational only — the card-level click
owns the single source of truth for selection toggling, and its
pointer-events-none wrapper guarantees the click bubbles up from the
avatar slot.

Regression guards added:
  - Tapping the title text opens the detail modal outside select mode
  - Tapping the title text toggles selection in select mode
  - Avatar tile stopsPropagation so detail does not double-open

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The search field used the shared `form-input` utility which bakes in
`px-4`. Combined with `pl-9`, the magnifier glyph sat on top of the
placeholder/value text depending on CSS output ordering.

Replace `form-input` with explicit padding + border + focus ring on
the input itself, and bump the left padding to `pl-10` (2.5 rem) so the
icon (left-3 + 16 px wide) always has an 8 px gap to the text. The
wrapper stays `relative` with the absolutely-positioned icon vertically
centered via top-1/2 + -translate-y-1/2.

Mobile uses a third-party emoji keyboard (`rn-emoji-keyboard`) with
its own themed search bar, so no mobile counterpart is needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The habit form's outer rhythm is space-y-6 / gap 24, but the Frequency
section had its cards, flexible-hint (`<p class="-mt-1">`), quantity +
unit row, and active-days picker all as separate siblings of the form.
That meant the 24 px gap lived BETWEEN each frequency piece too, and
the flexible hint's -1 rem pulled the next block upward — so Dates &
Times read as glued to whatever frequency block was visible above it.

Wrap the Frequency cards + all of their type-adaptive sub-fields in
one container with a tighter internal rhythm:

  - Web: `<div className="space-y-3">` around the group. Removed the
    `-mt-1` from the flexible hint.
  - Mobile: a new `frequencyGroup` style with `gap: 12`, and removed
    the `marginTop: -12` from the flexibleHint.

The outer container's 24 px gap now lands cleanly between the
Frequency group and the Dates & Times row on every state
(one-time / recurring / flexible / general).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Today-view cards already imply due-today context; the explicit label
was noise. Overdue and completed statuses still surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The avatar progress arc was rendered for every completed/logged habit,
including simple daily ones, drawing a grey track ring and a stroke-cap
dot even when progress was zero. The solid-primary completed tile plus
the corner check badge already communicate completion; a surrounding
ring adds noise without information.

Changes:
- shouldShowHabitProgressArc no longer returns true just because a habit
  is completed or logged. Arc is reserved for habits where progress is a
  meaningful number (parents-with-children, flexible habits).
- Removed the grey track circle from both platforms. The arc now renders
  only when there is an actual filled portion to show.
- Guard against rendering the stroke at all when progressRatio is 0 so
  strokeLinecap="round" can't leak a tiny dot artifact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Avatar tile's interactive button now explicitly bg-transparent with
  border-0/p-0 and rounded-[14px] matching the inner tile radius. Some
  browsers (notably Windows) default <button> to a buttonface background
  that showed as a faint square halo around the rounded tile.
- Quantity+Unit and DueDate+DueTime rows unified to div+label on both
  sides. The fieldset/legend on one side had different baseline behavior
  than the div/label on the other, producing a subtle vertical
  misalignment inside the 2-col grid.
- Added items-start to both rows so fields align to their tops
  regardless of future content height differences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
thomasluizon and others added 7 commits April 15, 2026 10:27
Touch taps were leaving a sticky hover state that rendered a visible
1px outline ring around the avatar tile and a translated+shadowed card
surface. That made the Meditate tile look like it had a squared frame
behind it after a tap. Moved the hover rules under
@media (hover: hover) and (pointer: fine) so only real pointers trigger
them, and dropped the explicit 0 0 0 1px outline from the avatar hover
shadow (the soft drop shadow alone gives the lift without a visible
ring).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The 1 px ring-primary/25 applied to every tile rendered as a visible
rounded frame around the emoji, more noticeable around colorful emojis
than around the monochrome initial fallback. The inner-highlight radial
gradient and the arc drop-shadow filter were stacking additional subtle
outlines on top. Kept only meaningful rings now: accent ring on the
completed state and destructive ring on overdue. Everything else is a
flat colored tile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The wrapper was 56 px to reserve room for the 2 px SVG arc to draw around
the 52 px tile. On habits where no arc renders (simple recurring, or
parents with no children logged), those 4 px of extra space picked up the
native <button> rendering (Windows/Chromium draws an inset background +
border on buttons even with bg-transparent), showing as a darker squared
halo around the tile. Now the wrapper collapses to exactly 52 px when no
arc is drawn, and adds appearance-none + background: none + m-0 to fully
neutralize the native button. Full 56 px is only used while the arc is
visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts the stripdown of ring and inner-highlight (user confirmed those
weren't the cause and the flat tile looked worse). Adds overflow-hidden
on the inner tile so any visual bleed inside the tile (emoji glyph
bounds, anti-aliasing artifacts, native rendering) is clipped to the
rounded-14px shape. Combined with the earlier wrapper-size-collapse,
nothing can render a sharp-cornered square behind the rounded tile.

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

The Avatar+Arc redesign merged the log action with the emoji identity into
one tile. Separate them back so each card reads as:

  [status-bar][chevron?][LOG button][EMOJI tile?][title + meta][kebab]

- Add HabitLogButton: interactive 52 px square, owns completion, progress
  arc for parent-with-children + flexible habits, center "x/n" label,
  overdue ring, bad-habit variant, pulse / glow animations, and the
  tap-to-log / force-log-parent / unlog / toggle-selection logic.
- Trim HabitAvatarTile down to a purely decorative 52 px emoji tile
  (no onClick, arc, centerLabel, or state overrides). The card omits
  the tile entirely when the habit has no icon so horizontal space is
  reclaimed for title + meta.
- Update habit-card.tsx to render the pair with `gap-2` between the two
  tiles. Emoji tile sits outside `data-card-click-ignore` so taps on it
  fall through to the card handler (opens detail / toggles selection).
- Update tests to cover: emoji-only when icon is set, no tile when icon
  is null, and decorative emoji tile is non-interactive.

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

Mirror the web split so both platforms render the classic layout:

  [status-bar][chevron?][LOG button][EMOJI tile?][title + meta][kebab]

- Add HabitLogButton (mobile): 52 px TouchableOpacity wrapping the SVG
  progress arc + rounded-square surface, owns tap-to-log / force-log /
  unlog behavior plus pulse + glow animations. Surface tokens match
  the web button (primary/14 → solid primary on completion, coral ring
  when overdue, destructive tint for bad habits).
- Trim HabitAvatarTile down to a decorative 52 px emoji tile (no
  onPress, arc, centerLabel). The card skips rendering it when the
  habit has no icon to save horizontal space on the row.
- Wire the pair inside habit-card.tsx with an 8 px gap; kept selection
  box behavior unchanged.
- Update tests to cover: emoji-only when icon is set, no tile when icon
  is null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two visual refinements requested on the new split-tile layout:

- Log button no longer renders a tiny centered dot as an idle "tap to
  log" hint. It now shows either the Check, the x/n counter, or
  nothing. The dot was reading as an unintended artifact.
- Emoji avatar is no longer wrapped in its own tinted rounded-square
  surface. The log button owns the "action" square; the emoji is the
  habit's identity, so it renders as a bare 26 px glyph floating in its
  slot. No background, no ring, no inner highlight. Smaller than the
  previous 22 px inside a 52 px tile because without a container the
  emoji reads at its natural character size — so we bumped the glyph
  itself up slightly while removing the visual weight of the box.
- Card already omits the slot entirely when the habit has no icon; the
  fallback-initial-letter branch is therefore dropped from the tile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@thomasluizon
thomasluizon deleted the combined-habits-streak-audit branch April 15, 2026 14:02
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.

1 participant