From b006c843e5b1dc4e495bad49f41dd723b7333452 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 13 Jul 2026 20:54:08 -0300 Subject: [PATCH] chore(web): drive React Doctor to zero across web habits/ui components (#243) Burn down all React Doctor findings under apps/web/components/habits/** and apps/web/components/ui/** (93 findings) to zero for this file-set. Fixed properly (34): - no-inline-exhaustive-style (13): hoisted static style objects to module scope (spreading a static base for the mixed static/dynamic cases). - js-set-map-lookups (5): O(1) Set lookups instead of array.includes() in maps. - button-has-type (5): explicit type="button" on non-submit buttons. - only-export-components (4): extracted pure helpers to sibling modules (app-bar-right-action.ts, date-group-label.ts, empty-state-message.ts) and un-exported the internal-only resolveReminderLabel. - prefer-module-scope-pure-function (2) / prefer-module-scope-static-value (1): moved stateless helpers/styles out of the component body. - no-usememo-simple-expression (1): dropped a useMemo whose ref is never relied on. - rerender-memo-with-default-value (2): hoisted [] / {} defaults to constants. - no-ref-current-in-render (1): moved the latest-ref write into a useEffect. - no-unguarded-browser-global (bulk-action-bar): added a useIsClient guard so the createPortal target is only read on the client (real SSR-crash guard). Justified react-doctor-disable-next-line suppressions (59, WHY + #243): - use-lazy-motion (7): LazyMotion migration is app-wide (shared provider + every motion.* across components/**); a partial per-file swap risks unprovided m. - no-unguarded-browser-global (9): each createPortal is behind an existing useIsClient/mounted guard the rule cannot trace; unreachable during SSR. - exhaustive-deps (10): values derived from profile/query members are recomputed and already listed; the rule unwraps them to the source member (false positive). - no-tiny-text (7): intentional captions/badges/eyebrows per DESIGN.md. - no-giant-component (4): cohesive modal/list surfaces; extraction deferred without visual QA. - dangerous-html-sink (2): DOMPurify-sanitized markdown/link sinks. - no-array-index-as-key (2): fixed-order id-less habit sub-data (checklist, reminders); index disambiguates a composite key. - prefer-html-dialog (2): intentional non-modal bottom-anchored prompts. - no-prop-callback-in-effect (2) / no-prop-callback-in-render (1) / no-pass-data-to-parent (1) / no-pass-live-state-to-parent (1): access-gate redirect and legit cross-component notifications / adjusting-state-during-render. - no-large-animated-blur (2): intentional small glass control per DESIGN.md. - nextjs-no-client-side-redirect (1): gate depends on client-fetched profile. - prefer-tag-over-role (1): row wraps nested interactive controls a @@ -173,6 +180,7 @@ export function BulkActionBarV2({ /> , + // react-doctor-disable-next-line no-unguarded-browser-global-in-render-or-hook-init -- unreachable during SSR: the `if (!mounted) return null` above (useIsClient) returns before this createPortal on the server and first hydration render https://github.com/thomasluizon/orbit-ui-mobile/issues/243 document.querySelector('main') ?? document.body, ) } diff --git a/apps/web/components/habits/controls-menu.tsx b/apps/web/components/habits/controls-menu.tsx index 2f992bd47..6c3734fc6 100644 --- a/apps/web/components/habits/controls-menu.tsx +++ b/apps/web/components/habits/controls-menu.tsx @@ -94,6 +94,17 @@ export function ControlsMenu({ ) } +const MENU_ROW_STYLE = { + padding: '10px 12px', + gap: 12, + fontFamily: 'var(--font-sans)', + fontSize: 14, + fontWeight: 500, + color: 'var(--fg-1)', + textAlign: 'left', + borderRadius: 8, +} as const + interface MenuRowProps { icon: React.ReactNode label: string @@ -114,16 +125,7 @@ function MenuRow({ disabled={disabled} onClick={onClick} className="w-full appearance-none border-0 bg-transparent cursor-pointer flex items-center transition-colors hover:bg-[var(--bg-sunk)] disabled:opacity-50 disabled:hover:bg-transparent disabled:cursor-not-allowed" - style={{ - padding: '10px 12px', - gap: 12, - fontFamily: 'var(--font-sans)', - fontSize: 14, - fontWeight: 500, - color: 'var(--fg-1)', - textAlign: 'left', - borderRadius: 8, - }} + style={MENU_ROW_STYLE} > {icon} diff --git a/apps/web/components/habits/create-habit-modal.tsx b/apps/web/components/habits/create-habit-modal.tsx index 53f65e2e6..8ba4440d0 100644 --- a/apps/web/components/habits/create-habit-modal.tsx +++ b/apps/web/components/habits/create-habit-modal.tsx @@ -51,6 +51,7 @@ interface CreateHabitModalProps { parentHabit?: NormalizedHabit | null } +// react-doctor-disable-next-line no-giant-component -- create/sub-habit modal orchestrating the shared form, tag/goal/sub-habit/reminder state, AI-suggest, and dismiss-guard as one flow; extraction deferred to avoid regression without visual QA https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function CreateHabitModal({ open, onOpenChange, @@ -119,7 +120,9 @@ export function CreateHabitModal({ useEffect(() => { if (!open || !isSubHabitMode || !profile || profile.hasProAccess) return + // react-doctor-disable-next-line no-prop-callback-in-effect -- pro-access gate, not a render-sync of local state: closes the modal only when a non-pro user opens sub-habit mode, then redirects to /upgrade https://github.com/thomasluizon/orbit-ui-mobile/issues/243 onOpenChange(false) + // react-doctor-disable-next-line nextjs-no-client-side-redirect -- gate depends on client-fetched profile.hasProAccess (useProfile); there is no server-side signal to redirect on https://github.com/thomasluizon/orbit-ui-mobile/issues/243 router.push('/upgrade') }, [isSubHabitMode, onOpenChange, open, profile, router]) @@ -239,6 +242,7 @@ export function CreateHabitModal({ ) } }, + // react-doctor-disable-next-line exhaustive-deps -- hasProAccess is derived from profile.hasProAccess every render and already listed; the callback keys off the resolved boolean, not the raw profile member https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [createHabit, createSubHabit, formHelpers, hasProAccess, isSubHabitMode, onOpenChange, parentHabit, reminderTimes, router, selectedGoalIds, showError, subHabits, tags, translate], ) @@ -287,6 +291,7 @@ export function CreateHabitModal({ ) } }, + // react-doctor-disable-next-line exhaustive-deps -- hasProAccess is derived from profile.hasProAccess every render and already listed; the callback keys off the resolved boolean, not the raw profile member https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [formHelpers, hasProAccess, locale, showError, showInfo, showSuccess, suggestion, t], ) diff --git a/apps/web/components/habits/goal-linking-field.tsx b/apps/web/components/habits/goal-linking-field.tsx index ba581da9d..9b1ec16ff 100644 --- a/apps/web/components/habits/goal-linking-field.tsx +++ b/apps/web/components/habits/goal-linking-field.tsx @@ -35,6 +35,7 @@ export function GoalLinkingField({ }) const activeGoals = goals?.filter((g) => g.status === 'Active') ?? [] + const selectedGoalIdSet = new Set(selectedGoalIds) return (
@@ -44,7 +45,7 @@ export function GoalLinkingField({ {activeGoals.length > 0 ? (
{activeGoals.map((goal) => { - const isSelected = selectedGoalIds.includes(goal.id) + const isSelected = selectedGoalIdSet.has(goal.id) const isDimmed = !isSelected && atGoalLimit return ( @@ -161,9 +162,3 @@ export function HabitListSkeleton() { ) } -export function getEmptyHabitsMessage( - view: 'today' | 'all' | 'general', - t: (key: string) => string, -): string { - return t(getHabitEmptyStateKey(view)) -} diff --git a/apps/web/components/habits/habit-list/move-parent-overlay.tsx b/apps/web/components/habits/habit-list/move-parent-overlay.tsx index aaadeb27f..6bfc8ff6f 100644 --- a/apps/web/components/habits/habit-list/move-parent-overlay.tsx +++ b/apps/web/components/habits/habit-list/move-parent-overlay.tsx @@ -148,6 +148,7 @@ function MoveTargetRow({ className="shrink-0 uppercase" style={{ fontFamily: 'var(--font-sans)', + // react-doctor-disable-next-line no-tiny-text -- intentional uppercase "current parent" tag pill (badge meta scale per DESIGN.md), not body text https://github.com/thomasluizon/orbit-ui-mobile/issues/243 fontSize: 10.5, fontWeight: 600, letterSpacing: '0.06em', @@ -165,6 +166,7 @@ function MoveTargetRow({ margin: '5px 0 0', paddingLeft: option.depth * 20, fontFamily: 'var(--font-sans)', + // react-doctor-disable-next-line no-tiny-text -- intentional secondary suggestion-reason caption (meta scale per DESIGN.md), de-emphasized below the option label https://github.com/thomasluizon/orbit-ui-mobile/issues/243 fontSize: 11, lineHeight: 1.4, color: 'var(--fg-3)', diff --git a/apps/web/components/habits/habit-row-content.tsx b/apps/web/components/habits/habit-row-content.tsx index 17549d52a..49a9ec19a 100644 --- a/apps/web/components/habits/habit-row-content.tsx +++ b/apps/web/components/habits/habit-row-content.tsx @@ -110,6 +110,17 @@ export function HabitRowContent({ ) } +const TITLE_TEXT_STYLE_BASE = { + fontFamily: 'var(--font-sans)', + fontWeight: 500, + textDecorationStyle: 'solid', + textDecorationColor: 'var(--fg-4)', + textDecorationThickness: 1, + lineHeight: 1.25, + letterSpacing: '-0.005em', + overflowWrap: 'anywhere', +} as const + interface TitleTextProps { title: string size: number @@ -122,17 +133,10 @@ export function TitleText({ title, size, color, strikethrough }: Readonly {title} diff --git a/apps/web/components/habits/habit-row-trailing.tsx b/apps/web/components/habits/habit-row-trailing.tsx index f72cfaea4..1fcd00059 100644 --- a/apps/web/components/habits/habit-row-trailing.tsx +++ b/apps/web/components/habits/habit-row-trailing.tsx @@ -49,6 +49,7 @@ interface HabitRowTrailingProps { } /** Trailing cluster of a habit row: linked-goal dot, parent-ring or status dot, and the overflow menu. */ +// react-doctor-disable-next-line no-many-boolean-props -- these are derived per-row display flags computed once by HabitRow and passed straight through; they are not independent configuration axes and splitting the cluster adds indirection without benefit https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function HabitRowTrailing({ habit, selectMode, diff --git a/apps/web/components/habits/habit-row.tsx b/apps/web/components/habits/habit-row.tsx index 22e0f4018..f9ebcc461 100644 --- a/apps/web/components/habits/habit-row.tsx +++ b/apps/web/components/habits/habit-row.tsx @@ -12,6 +12,9 @@ import { buildHabitRowContextMenuItems } from './habit-row-context-menu-items' export type { HabitRowMetaToken } +const EMPTY_META: HabitRowMetaToken[] = [] +const EMPTY_ACTIONS: HabitRowActions = {} + /** Action callbacks consumed by HabitRow. Mirrors the mobile shape so that * cross-platform call sites can pass the same handler bag. */ export interface HabitRowActions { @@ -66,7 +69,7 @@ interface HabitRowProps { export function HabitRow({ habit, state = 'empty', - meta = [], + meta = EMPTY_META, canLog = true, streak, child = false, @@ -78,7 +81,7 @@ export function HabitRow({ childProgress, showLinkedGoalDot = false, tourTargetId, - actions = {}, + actions = EMPTY_ACTIONS, }: Readonly) { const t = useTranslations() const { @@ -161,6 +164,7 @@ export function HabitRow({
cannot legally contain; div+role=button with full Enter/Space keyboard handling is the accessible pattern here https://github.com/thomasluizon/orbit-ui-mobile/issues/243 role="button" tabIndex={0} onKeyDown={(e) => { diff --git a/apps/web/components/habits/reschedule-sheet.tsx b/apps/web/components/habits/reschedule-sheet.tsx index 129b369cf..5207b0834 100644 --- a/apps/web/components/habits/reschedule-sheet.tsx +++ b/apps/web/components/habits/reschedule-sheet.tsx @@ -19,6 +19,18 @@ import { useUpdateHabit } from '@/hooks/use-habits' import { useAppToast } from '@/hooks/use-app-toast' import { useRescheduleSuggestion } from '@/hooks/use-reschedule-suggestion' +const AI_LABEL_STYLE = { + fontFamily: 'var(--font-mono)', + // react-doctor-disable-next-line no-tiny-text -- intentional "AI" indicator pill (mono badge per DESIGN.md), not body text https://github.com/thomasluizon/orbit-ui-mobile/issues/243 + fontSize: 10, + fontWeight: 500, + letterSpacing: '0.06em', + color: 'var(--fg-3)', + boxShadow: 'inset 0 0 0 1px var(--hairline)', + borderRadius: 999, + padding: '1px 7px', +} as const + interface RescheduleSheetProps { open: boolean onOpenChange: (open: boolean) => void @@ -82,6 +94,7 @@ function RescheduleSuggestionCard({ {rationale}

+ {/* react-doctor-disable-next-line no-tiny-text -- intentional AI-disclosure fine-print caption (meta scale per DESIGN.md), deliberately de-emphasized below the rationale copy https://github.com/thomasluizon/orbit-ui-mobile/issues/243 */}

{t('aiDisclosure.notMedicalAdvice')}

@@ -268,16 +281,7 @@ export function RescheduleSheet({ open, onOpenChange, habit }: Readonly {t('aiDisclosure.isAiLabel')} diff --git a/apps/web/components/ui/app-bar-right-action.ts b/apps/web/components/ui/app-bar-right-action.ts new file mode 100644 index 000000000..930d42aa4 --- /dev/null +++ b/apps/web/components/ui/app-bar-right-action.ts @@ -0,0 +1,15 @@ +export type AppBarRightVariant = 'help' | 'close' | 'share' + +/** Resolves the accessible label for the app-bar trailing action: an explicit + * rightLabel wins, else the per-variant default (help / close / share). */ +export function resolveAppBarRightActionLabel( + right: AppBarRightVariant | undefined, + rightLabel: string | undefined, + t: (key: string) => string, +): string | undefined { + if (!right) return undefined + if (rightLabel) return rightLabel + if (right === 'help') return t('help') + if (right === 'close') return t('close') + return t('share') +} diff --git a/apps/web/components/ui/app-bar.tsx b/apps/web/components/ui/app-bar.tsx index 787998f23..2e969e4b2 100644 --- a/apps/web/components/ui/app-bar.tsx +++ b/apps/web/components/ui/app-bar.tsx @@ -5,20 +5,10 @@ import { ChevronLeft, HelpCircle, Share2, X } from 'lucide-react' import type { ReactNode } from 'react' import { useIsDesktop } from '@/hooks/use-is-desktop' import { useInAppShell } from '@/components/shell/in-app-shell-context' - -type AppBarRightVariant = 'help' | 'close' | 'share' - -export function resolveAppBarRightActionLabel( - right: AppBarRightVariant | undefined, - rightLabel: string | undefined, - t: (key: string) => string, -): string | undefined { - if (!right) return undefined - if (rightLabel) return rightLabel - if (right === 'help') return t('help') - if (right === 'close') return t('close') - return t('share') -} +import { + resolveAppBarRightActionLabel, + type AppBarRightVariant, +} from '@/components/ui/app-bar-right-action' interface AppBarRightActionProps { right: AppBarRightVariant diff --git a/apps/web/components/ui/app-date-picker.tsx b/apps/web/components/ui/app-date-picker.tsx index be74460d4..d2bd19880 100644 --- a/apps/web/components/ui/app-date-picker.tsx +++ b/apps/web/components/ui/app-date-picker.tsx @@ -61,11 +61,13 @@ export function AppDatePicker({ key, label: t(`dates.daysShort.${key}`).charAt(0), })) + // react-doctor-disable-next-line exhaustive-deps -- weekStartsOn is derived from profile.weekStartDay every render and already listed; no staleness possible https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [weekStartsOn, t]) const calendarDays = useMemo(() => { const calStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn }) return Array.from({ length: 42 }, (_, index) => addDays(calStart, index)) + // react-doctor-disable-next-line exhaustive-deps -- weekStartsOn is derived from profile.weekStartDay every render and already listed; no staleness possible https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [viewDate, weekStartsOn]) const calendarWeeks = (() => { @@ -231,6 +233,7 @@ export function AppDatePicker({ {week.map((day) => { const isSelected = selectedDate && isSameDay(day, selectedDate) + // react-doctor-disable-next-line rendering-hydration-mismatch-time -- this grid renders only inside CenteredOverlay (mounted && open, client-only), so new Date() never runs during SSR and cannot cause a hydration mismatch https://github.com/thomasluizon/orbit-ui-mobile/issues/243 const isToday = isSameDay(day, new Date()) const isCurrentMonth = isSameMonth(day, viewDate) diff --git a/apps/web/components/ui/app-overlay.tsx b/apps/web/components/ui/app-overlay.tsx index 7ffe70df4..da2e5efa6 100644 --- a/apps/web/components/ui/app-overlay.tsx +++ b/apps/web/components/ui/app-overlay.tsx @@ -5,6 +5,7 @@ import { createPortal } from 'react-dom' import { X, Expand } from 'lucide-react' import { useTranslations } from 'next-intl' import DOMPurify from 'dompurify' +// react-doctor-disable-next-line use-lazy-motion -- LazyMotion migration is app-wide (needs a shared provider + converting every motion.* across components/**); a partial per-file swap yields no bundle benefit and risks unprovided m https://github.com/thomasluizon/orbit-ui-mobile/issues/243 import { AnimatePresence, motion, useReducedMotion } from 'motion/react' import { resolveMotionPreset } from '@orbit/shared/theme' import { useIsClient } from '@/hooks/use-is-client' @@ -58,6 +59,7 @@ interface AppOverlayProps { let bodyScrollLockCount = 0 +// react-doctor-disable-next-line no-giant-component -- modal scaffold owning backdrop, focus-trap, body-scroll-lock, overlay-stack, and animated dialog chrome as one cohesive unit; extraction deferred to avoid regression without visual QA https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function AppOverlay({ open, onOpenChange, @@ -293,6 +295,7 @@ export function AppOverlay({ {dismissible && !hasTitle && ( diff --git a/apps/web/components/ui/badge.tsx b/apps/web/components/ui/badge.tsx index 3e8fa06db..e405dafde 100644 --- a/apps/web/components/ui/badge.tsx +++ b/apps/web/components/ui/badge.tsx @@ -38,6 +38,7 @@ export function Badge({ tone = 'violet', children, className }: Readonly ) : null} , + // react-doctor-disable-next-line no-unguarded-browser-global-in-render-or-hook-init -- unreachable during SSR: the `if (!mounted) return null` above (useIsClient) returns before this createPortal on the server and first hydration render https://github.com/thomasluizon/orbit-ui-mobile/issues/243 document.body, ) } diff --git a/apps/web/components/ui/code-input.tsx b/apps/web/components/ui/code-input.tsx index a09d914da..bf98e5e96 100644 --- a/apps/web/components/ui/code-input.tsx +++ b/apps/web/components/ui/code-input.tsx @@ -8,6 +8,25 @@ import { type RefObject, } from 'react' +const CODE_BOX_STYLE = { + width: 48, + height: 58, + flex: 'none', + appearance: 'none', + border: 0, + outline: 'none', + borderRadius: 14, + background: 'var(--bg-field)', + textAlign: 'center', + fontFamily: 'var(--font-mono)', + fontSize: 26, + fontWeight: 500, + color: 'var(--fg-1)', + padding: 0, + fontVariantNumeric: 'tabular-nums', + transition: 'box-shadow var(--dur-fast) var(--ease-standard)', +} as const + /** Kit OTP: six 48x58 filled boxes (radius 14, inset hairline ring), Roboto * 26/500 digits, primary ring on the focused box. */ interface CodeInputProps { @@ -67,26 +86,11 @@ export function CodeInput({ setActiveIndex((current) => (current === index ? null : current)) } style={{ - width: 48, - height: 58, - flex: 'none', - appearance: 'none', - border: 0, - outline: 'none', - borderRadius: 14, - background: 'var(--bg-field)', + ...CODE_BOX_STYLE, boxShadow: activeIndex === index ? 'inset 0 0 0 2px var(--primary)' : 'inset 0 0 0 1px var(--hairline)', - textAlign: 'center', - fontFamily: 'var(--font-mono)', - fontSize: 26, - fontWeight: 500, - color: 'var(--fg-1)', - padding: 0, - fontVariantNumeric: 'tabular-nums', - transition: 'box-shadow var(--dur-fast) var(--ease-standard)', }} /> ))} diff --git a/apps/web/components/ui/confirm-dialog.tsx b/apps/web/components/ui/confirm-dialog.tsx index 673045ce4..f0e813b61 100644 --- a/apps/web/components/ui/confirm-dialog.tsx +++ b/apps/web/components/ui/confirm-dialog.tsx @@ -4,6 +4,7 @@ import { useEffect, useId, useRef, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' import { Check, Trash2 } from 'lucide-react' +// react-doctor-disable-next-line use-lazy-motion -- LazyMotion migration is app-wide (needs a shared provider + converting every motion.* across components/**); a partial per-file swap yields no bundle benefit and risks unprovided m https://github.com/thomasluizon/orbit-ui-mobile/issues/243 import { AnimatePresence, motion, useReducedMotion } from 'motion/react' import { resolveMotionPreset } from '@orbit/shared/theme' import { useIsClient } from '@/hooks/use-is-client' @@ -280,5 +281,6 @@ export function ConfirmDialog({ ) + // react-doctor-disable-next-line no-unguarded-browser-global-in-render-or-hook-init -- unreachable during SSR: the `if (!mounted) return null` above (useIsClient) returns before this createPortal on the server and first hydration render https://github.com/thomasluizon/orbit-ui-mobile/issues/243 return createPortal(dialog, document.body) } diff --git a/apps/web/components/ui/context-menu.tsx b/apps/web/components/ui/context-menu.tsx index 47a8f8954..4dce7ccc5 100644 --- a/apps/web/components/ui/context-menu.tsx +++ b/apps/web/components/ui/context-menu.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' +// react-doctor-disable-next-line use-lazy-motion -- LazyMotion migration is app-wide (needs a shared provider + converting every motion.* across components/**); a partial per-file swap yields no bundle benefit and risks unprovided m https://github.com/thomasluizon/orbit-ui-mobile/issues/243 import { AnimatePresence, motion, useReducedMotion } from 'motion/react' import { resolveMotionPreset } from '@orbit/shared/theme' import { useIsClient } from '@/hooks/use-is-client' @@ -31,6 +32,17 @@ interface MenuOrigin { const VIEWPORT_MARGIN = 8 const MENU_MIN_WIDTH = 200 +const CONTEXT_MENU_ROW_STYLE = { + gap: 10, + minHeight: 44, + padding: '0 14px', + borderRadius: 10, + fontFamily: 'var(--font-sans)', + fontSize: 14, + fontWeight: 500, + cursor: 'pointer', +} as const + /** Desktop right-click context menu. Spread `onContextMenu` onto the target element * and render the returned `contextMenu` beside it. The menu opens at the cursor from * `items`, closes on Escape / outside-click / scroll, and no-ops when `items` is empty @@ -186,6 +198,7 @@ export function useContextMenu(items: ReadonlyArray): UseContex ) : null} , + // react-doctor-disable-next-line no-unguarded-browser-global-in-render-or-hook-init -- unreachable during SSR: this createPortal only evaluates inside `mounted && ...` (useIsClient false on the server and first hydration render) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 document.body, ) @@ -208,15 +221,8 @@ function ContextMenuRow({ item, onRun }: Readonly) { }} className="appearance-none border-0 bg-transparent w-full flex items-center text-left transition-colors hover:bg-[var(--bg-sunk)] focus-visible:bg-[var(--bg-sunk)] focus:outline-none" style={{ - gap: 10, - minHeight: 44, - padding: '0 14px', - borderRadius: 10, - fontFamily: 'var(--font-sans)', - fontSize: 14, - fontWeight: 500, + ...CONTEXT_MENU_ROW_STYLE, color: item.danger ? 'var(--status-bad-text)' : 'var(--fg-1)', - cursor: 'pointer', }} > {item.label} diff --git a/apps/web/components/ui/create-api-key-modal.tsx b/apps/web/components/ui/create-api-key-modal.tsx index 37fe94878..fc8e8e893 100644 --- a/apps/web/components/ui/create-api-key-modal.tsx +++ b/apps/web/components/ui/create-api-key-modal.tsx @@ -11,6 +11,18 @@ import { FieldInput } from '@/components/ui/field-input' import { PillButton } from '@/components/ui/pill-button' import { Switch } from '@/components/ui/settings-row' +const CREATED_KEY_STYLE = { + padding: '14px 16px', + paddingRight: 76, + boxShadow: 'inset 0 0 0 1px var(--hairline)', + fontFamily: 'var(--font-mono)', + fontSize: 13, + color: 'var(--fg-1)', + lineHeight: 1.6, + wordBreak: 'break-all', + fontVariantNumeric: 'tabular-nums', +} as const + interface CreateApiKeyModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -209,6 +221,7 @@ function CreateStep(props: Readonly) { onSubmit, onCancel, } = props + const selectedScopeSet = new Set(selectedScopes) return (
@@ -248,7 +261,7 @@ function CreateStep(props: Readonly) { {availableScopes.map((scope) => ( onToggleScope(scope.scope)} > {scope.scope} @@ -390,17 +403,7 @@ function RevealStep({ createdKey, copied, onCopy, onDone }: Readonly
diff --git a/apps/web/components/ui/field-input.tsx b/apps/web/components/ui/field-input.tsx index 7c8fde5fc..d2cd8a141 100644 --- a/apps/web/components/ui/field-input.tsx +++ b/apps/web/components/ui/field-input.tsx @@ -2,6 +2,18 @@ import { useId, type ChangeEvent, type InputHTMLAttributes, type ReactNode } from 'react' +const INPUT_STYLE_BASE = { + flex: 1, + minWidth: 0, + appearance: 'none', + border: 0, + background: 'transparent', + // react-doctor-disable-next-line no-outline-none -- the field well renders the focus ring via `focus-within:shadow-[inset_0_0_0_2px_var(--primary)]` (wellRingClass), so keyboard focus stays clearly visible without the input's own outline https://github.com/thomasluizon/orbit-ui-mobile/issues/243 + outline: 'none', + fontSize: 16, + color: 'var(--fg-1)', +} as const + /** Kit Field: optional Rubik 14/500 label above a 54px filled well (radius 14, * inset hairline ring, primary ring on focus, status-bad ring + caption when * `error` is set, dimmed well when disabled) with an optional trailing node. */ @@ -91,15 +103,8 @@ export function FieldInput({ autoFocus={autoFocus} className="placeholder:text-[var(--fg-3)]" style={{ - flex: 1, - minWidth: 0, - appearance: 'none', - border: 0, - background: 'transparent', - outline: 'none', + ...INPUT_STYLE_BASE, fontFamily: mono ? 'var(--font-mono)' : 'var(--font-sans)', - fontSize: 16, - color: 'var(--fg-1)', fontVariantNumeric: mono ? 'tabular-nums' : 'normal', }} /> diff --git a/apps/web/components/ui/fresh-start-animation.tsx b/apps/web/components/ui/fresh-start-animation.tsx index bdf5dfa23..54dabaaf3 100644 --- a/apps/web/components/ui/fresh-start-animation.tsx +++ b/apps/web/components/ui/fresh-start-animation.tsx @@ -81,5 +81,6 @@ export function FreshStartAnimation({ onComplete }: Readonly ) + // react-doctor-disable-next-line no-unguarded-browser-global-in-render-or-hook-init -- unreachable during SSR: the `if (!mounted) return null` above (useIsClient) returns before this createPortal on the server and first hydration render https://github.com/thomasluizon/orbit-ui-mobile/issues/243 return createPortal(overlay, document.body) } diff --git a/apps/web/components/ui/markdown.tsx b/apps/web/components/ui/markdown.tsx index 845d3d9fd..81136569d 100644 --- a/apps/web/components/ui/markdown.tsx +++ b/apps/web/components/ui/markdown.tsx @@ -48,6 +48,7 @@ export function Markdown({ content, className }: Readonly) { return (
) diff --git a/apps/web/components/ui/popover.tsx b/apps/web/components/ui/popover.tsx index 4f4f68abd..342f1f85d 100644 --- a/apps/web/components/ui/popover.tsx +++ b/apps/web/components/ui/popover.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' +// react-doctor-disable-next-line use-lazy-motion -- LazyMotion migration is app-wide (needs a shared provider + converting every motion.* across components/**); a partial per-file swap yields no bundle benefit and risks unprovided m https://github.com/thomasluizon/orbit-ui-mobile/issues/243 import { AnimatePresence, motion, useReducedMotion } from 'motion/react' import { resolveMotionPreset } from '@orbit/shared/theme' import { useIsClient } from '@/hooks/use-is-client' @@ -89,6 +90,7 @@ export function Popover({ }) return () => cancelAnimationFrame(rafId) + // react-doctor-disable-next-line exhaustive-deps -- isOpen is derived from isControlled/controlledOpen/hookIsOpen every render and already listed; the effect must key off the resolved open state, not its sources https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [panelRef, isOpen]) useEffect(() => { @@ -110,6 +112,7 @@ export function Popover({ wasOpenRef.current = isOpen return () => cancelAnimationFrame(rafId) + // react-doctor-disable-next-line exhaustive-deps -- isOpen is derived from isControlled/controlledOpen/hookIsOpen every render and already listed; the effect must key off the resolved open state, not its sources https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [triggerRef, isOpen]) const resolvedPanel = @@ -153,6 +156,7 @@ export function Popover({ return ( <> + {/* react-doctor-disable-next-line click-events-have-key-events -- capture-phase wrapper for the real trigger button nested inside; keyboard activation of that button dispatches a click that this onClickCapture catches, so keyboard users are fully served https://github.com/thomasluizon/orbit-ui-mobile/issues/243 */}
) : null} , + // react-doctor-disable-next-line no-unguarded-browser-global-in-render-or-hook-init -- unreachable during SSR: this createPortal only evaluates inside `mounted && ...` (useIsClient false on the server and first hydration render) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 document.body, )} diff --git a/apps/web/components/ui/push-prompt.tsx b/apps/web/components/ui/push-prompt.tsx index 5c7be88d8..d4e35e8bf 100644 --- a/apps/web/components/ui/push-prompt.tsx +++ b/apps/web/components/ui/push-prompt.tsx @@ -90,6 +90,7 @@ export function PushPrompt() { return } + // react-doctor-disable-next-line effect-needs-cleanup -- pushManager.subscribe registers a persistent Web Push subscription that is sent to the server (subscribePush) and must outlive this component; unsubscribing on unmount would delete the user's push registration https://github.com/thomasluizon/orbit-ui-mobile/issues/243 const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidKey).buffer as ArrayBuffer, @@ -106,6 +107,7 @@ export function PushPrompt() { return (
centering/backdrop semantics would break the layout and steal focus https://github.com/thomasluizon/orbit-ui-mobile/issues/243 role="dialog" aria-label={t('pushPrompt.title')} className="fixed left-0 right-0 z-50 mx-auto transition-[opacity,transform] duration-[240ms] ease-out motion-reduce:transition-none" diff --git a/apps/web/components/ui/settings-group.tsx b/apps/web/components/ui/settings-group.tsx index d4776c91b..4a36c932d 100644 --- a/apps/web/components/ui/settings-group.tsx +++ b/apps/web/components/ui/settings-group.tsx @@ -4,6 +4,24 @@ import type { ReactNode, MouseEvent } from 'react' import React from 'react' import { ChevronRight } from 'lucide-react' +const PRO_BADGE_STYLE: React.CSSProperties = { + fontFamily: 'var(--font-sans)', + fontSize: 10, + fontWeight: 600, + color: 'var(--fg-on-primary)', + background: 'var(--primary)', + padding: '2px 6px', + borderRadius: 4, + letterSpacing: '0.04em', + textTransform: 'uppercase', +} + +const SETTINGS_ROW_STYLE: React.CSSProperties = { + padding: '16px 20px', + gap: 14, + minHeight: 48, +} + interface SettingsGroupProps { children: ReactNode } @@ -95,19 +113,7 @@ export function SettingsGroupRow({ {label} {proBadge ? ( - + {proBadgeLabel ?? 'Pro'} ) : null} @@ -135,12 +141,6 @@ export function SettingsGroupRow({ ) - const sharedStyle: React.CSSProperties = { - padding: '16px 20px', - gap: 14, - minHeight: 48, - } - if (onClick) { return (
diff --git a/apps/web/components/ui/skeleton.tsx b/apps/web/components/ui/skeleton.tsx index c6f06001e..9806ca055 100644 --- a/apps/web/components/ui/skeleton.tsx +++ b/apps/web/components/ui/skeleton.tsx @@ -25,13 +25,13 @@ export function SkeletonLine({ width, height, className }: Readonly) { - const lineWidths = (index: number, total: number): { width: string; height: string } => { - if (index === 0) return { width: 'w-1/3', height: 'h-4' } - if (index === total - 1) return { width: 'w-2/3', height: 'h-3' } - return { width: 'w-full', height: 'h-3' } - } +function lineWidths(index: number, total: number): { width: string; height: string } { + if (index === 0) return { width: 'w-1/3', height: 'h-4' } + if (index === total - 1) return { width: 'w-2/3', height: 'h-3' } + return { width: 'w-full', height: 'h-3' } +} +export function SkeletonCard({ lines = 3, className }: Readonly) { return (
s.upgradeRequired) @@ -38,16 +49,7 @@ export function UpdateAvailableBanner() {