From 1f0aed90d5736a271be4cb29ec99eb8651d4eef9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:29:35 +0800 Subject: [PATCH 01/17] fix(viewport): implement mobile keyboard and touch target audit fixes --- package.json | 2 +- src/app/globals.css | 3 + src/app/layout.tsx | 6 +- .../master-search-header.tsx | 20 ++++- src/components/ui-primitives.tsx | 2 +- src/components/use-mobile-keyboard.tsx | 74 +++++++++++++++++++ 6 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 src/components/use-mobile-keyboard.tsx diff --git a/package.json b/package.json index 9335c003a..c7795924f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "lint": "node scripts/run-heavy.mjs --npm-script lint:internal", "lint:internal": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --max-warnings 0 --no-error-on-unmatched-pattern --cache --cache-location node_modules/.cache/eslint/", "typecheck": "node scripts/run-heavy.mjs --npm-script typecheck:internal", - "typecheck:internal": "node ./node_modules/typescript/bin/tsc --noEmit", + "typecheck:internal": "npx rimraf .next/types && node ./node_modules/typescript/bin/tsc --noEmit", "test": "node scripts/run-vitest.mjs run --reporter=dot", "test:focused": "node scripts/test-focused.mjs", "test:live": "node scripts/run-live-tests.mjs", diff --git a/src/app/globals.css b/src/app/globals.css index 7e560bc47..d0a15a55f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -263,6 +263,7 @@ --shadow-lux: inset 0 1px 0 rgb(255 255 255 / 64%), 0 12px 34px rgb(8 16 24 / 7%); --shadow-inset: inset 0 1px 0 rgb(255 255 255 / 58%); --shadow-card: var(--shadow-tight); + --shadow-focus: 0 0 0 3px color-mix(in srgb, var(--focus) 25%, transparent), var(--shadow-soft); /* Active left-rail marker (nav/list "current" indicator). One token so the accent rail is a consistent 2px everywhere instead of hand-inlined at 2px/3px. */ --shadow-rail-active: inset 2px 0 0 var(--clinical-accent); @@ -424,6 +425,7 @@ --shadow-elevated: 0 2px 8px rgb(0 0 0 / 46%), 0 12px 30px rgb(0 0 0 / 48%), 0 28px 58px rgb(0 0 0 / 50%); --shadow-lux: inset 0 1px 0 rgb(255 255 255 / 4%), 0 18px 44px rgb(0 0 0 / 64%); --shadow-inset: inset 0 1px 0 rgb(255 255 255 / 4%); + --shadow-focus: 0 0 0 3px color-mix(in srgb, var(--focus) 25%, transparent), var(--shadow-soft); color-scheme: dark; } @@ -1156,6 +1158,7 @@ summary::-webkit-details-marker { .chat-composer-shell-delta:focus-within { border-color: var(--clinical-accent); + box-shadow: var(--shadow-focus); } .chat-composer-input { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 809791d96..f755f94ab 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,6 +7,7 @@ import { PwaLifecycle } from "@/components/pwa-lifecycle"; import { WebVitalsReporter } from "@/components/web-vitals-reporter"; import { resolveMetadataBase } from "@/lib/metadata-base"; import { APP_THEME_COLORS, THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme"; +import { MobileKeyboardProvider } from "@/components/use-mobile-keyboard"; import "./globals.css"; const geistSans = Geist({ @@ -58,6 +59,7 @@ export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover", + interactiveWidget: "resizes-content", colorScheme: "light dark", themeColor: [ { media: "(prefers-color-scheme: light)", color: APP_THEME_COLORS.light }, @@ -108,7 +110,9 @@ export default async function RootLayout({ - {children} + + {children} + diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 1487bd43a..f397759e9 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -389,6 +389,7 @@ export function MasterSearchHeader({ const scopePopoverRef = useRef(null); const actionMenuTriggerRef = useRef(null); const scopeFilterInputRef = useRef(null); + const touchStartY = useRef(null); const selectedDocumentIdSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]); const documentById = useMemo(() => new Map(documents.map((document) => [document.id, document])), [documents]); const selectedDocuments = useMemo( @@ -1378,6 +1379,20 @@ export function MasterSearchHeader({ return (
{ + touchStartY.current = e.touches[0].clientY; + }} + onTouchMove={(e) => { + if (touchStartY.current === null) return; + const currentY = e.touches[0].clientY; + const diff = currentY - touchStartY.current; + if (diff > 50) { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + touchStartY.current = null; + } + }} data-footer-variant={usesPhoneFooterDock ? (usesCompactMobileBottomStyle ? "compact" : "default") : undefined} data-footer-addon={usesPhoneFooterDock && mobileBottomSearchAddonSlotId ? "differentials-compare" : undefined} data-command-open={ @@ -1517,6 +1532,9 @@ export function MasterSearchHeader({ ref={bindQueryInputRef} data-testid="global-search-input" autoFocus={queryInputAutoFocus} + onFocus={(e) => { + e.target.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }} value={query} enterKeyHint="search" inputMode="search" @@ -1541,7 +1559,7 @@ export function MasterSearchHeader({