diff --git a/src/hooks/useScrollLock.ts b/src/hooks/useScrollLock.ts
new file mode 100644
index 00000000..c6522261
--- /dev/null
+++ b/src/hooks/useScrollLock.ts
@@ -0,0 +1,32 @@
+import { useEffect } from 'react';
+
+/**
+ * Locks document scroll when active.
+ * Restores previous scroll position on deactivation.
+ */
+// eslint-disable-next-line @typescript-eslint/no-empty-function -- cleanup function returns void
+export const useScrollLock = (active: boolean): void => {
+ useEffect(() => {
+ if (!active) return;
+
+ const scrollY = window.scrollY;
+ const body = document.body;
+ const originalOverflow = body.style.overflow;
+ const originalPosition = body.style.position;
+ const originalTop = body.style.top;
+ const originalWidth = body.style.width;
+
+ body.style.overflow = 'hidden';
+ body.style.position = 'fixed';
+ body.style.top = `-${scrollY}px`;
+ body.style.width = '100%';
+
+ return () => {
+ body.style.overflow = originalOverflow;
+ body.style.position = originalPosition;
+ body.style.top = originalTop;
+ body.style.width = originalWidth;
+ window.scrollTo(0, scrollY);
+ };
+ }, [active]);
+};
diff --git a/src/styles/components.css b/src/styles/components.css
index 8f697c12..8346b6ab 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -451,6 +451,13 @@ input:focus, select:focus {
transition: all var(--motion-fast);
}
+@media (pointer: coarse) {
+ .filter-chip {
+ min-height: 44px;
+ padding: var(--space-2) var(--space-4);
+ }
+}
+
.filter-chip:hover {
background: var(--bg-active);
border-color: var(--interactive-primary);
@@ -557,6 +564,14 @@ input[type="search"]::-webkit-search-cancel-button {
z-index: 1;
}
+@media (pointer: coarse) {
+ .input-clear-button {
+ min-width: 44px;
+ min-height: 44px;
+ padding: var(--space-2);
+ }
+}
+
.input-clear-button:hover {
color: var(--text-primary);
}
diff --git a/src/styles/features.css b/src/styles/features.css
index 56544ad6..4d7ecdb4 100644
--- a/src/styles/features.css
+++ b/src/styles/features.css
@@ -93,12 +93,14 @@
.chat-view {
display: flex;
flex-direction: column;
- height: calc(100vh - 120px);
+ height: calc(100dvh - 120px);
+ height: calc(100vh - 120px); /* fallback */
}
@media (max-width: 768px) {
.chat-view {
- height: calc(100vh - var(--header-height) - 40px);
+ height: calc(100dvh - var(--header-height) - 40px);
+ height: calc(100vh - var(--header-height) - 40px); /* fallback */
}
}
@@ -221,6 +223,14 @@
transition: color var(--motion-fast), background var(--motion-fast);
}
+@media (pointer: coarse) {
+ .source-chip-remove {
+ min-width: 44px;
+ min-height: 44px;
+ padding: var(--space-2);
+ }
+}
+
.source-chip-remove:hover {
color: #ef4444;
background: #fee2e2;
@@ -403,6 +413,13 @@
gap: 4px;
}
+@media (pointer: coarse) {
+ .layout-toggle button {
+ min-height: 44px;
+ padding: var(--space-2) var(--space-3);
+ }
+}
+
.layout-toggle button:hover {
background: var(--bg-base);
border-color: var(--interactive-primary);
@@ -493,6 +510,14 @@
border-radius: var(--radius-sm);
}
+@media (pointer: coarse) {
+ .close-button {
+ width: 44px;
+ height: 44px;
+ min-height: 44px;
+ }
+}
+
.close-button:hover {
background: var(--bg-base);
color: var(--text-primary);
diff --git a/src/styles/layout.css b/src/styles/layout.css
index abd397c8..ff89bfbe 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -10,9 +10,14 @@ body {
.layout-container {
display: flex;
flex-direction: column;
- height: 100vh;
+ height: 100dvh;
+ height: 100vh; /* fallback for older browsers */
background: var(--bg-base);
overflow: hidden;
+ padding-top: env(safe-area-inset-top);
+ padding-bottom: env(safe-area-inset-bottom);
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
}
.layout-body {
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index e9681d85..885f2a2c 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -103,6 +103,54 @@
--search-sidebar-width: 300px;
--header-height: 56px;
--content-max-width: 960px;
+
+ /* Aliases — canonical names above, these are for backward compat */
+ --border-color: var(--border-default);
+ --surface-primary: var(--bg-surface);
+ --surface-secondary: var(--bg-base);
+
+ /* Semantic Status Tokens */
+ --status-success-bg: #dcfce7;
+ --status-success-border: #10b981;
+ --status-warning-bg: #fef9c3;
+ --status-warning-border: #d97706;
+ --status-danger-bg: #fee2e2;
+ --status-danger-border: #ef4444;
+ --status-info-bg: #dbeafe;
+ --status-info-border: #2563eb;
+
+ /* Entity Type Tokens */
+ --entity-note-bg: #e0f2fe;
+ --entity-note-text: #0369a1;
+ --entity-concept-bg: #fef9c3;
+ --entity-concept-text: #a16207;
+ --entity-person-bg: #fce7f3;
+ --entity-person-text: #be185d;
+ --entity-project-bg: #dcfce7;
+ --entity-project-text: #15803d;
+
+ /* Graph Tokens */
+ --graph-node-default: #94a3b8;
+ --graph-node-selected: var(--interactive-primary);
+ --graph-edge-default: #cbd5e1;
+ --graph-edge-highlighted: var(--interactive-primary);
+
+ /* Control Heights */
+ --control-height-sm: 32px;
+ --control-height-md: 40px;
+ --control-height-lg: 48px;
+
+ /* Z-Index Scale */
+ --z-base: 0;
+ --z-dropdown: 100;
+ --z-sticky: 200;
+ --z-overlay: 300;
+ --z-modal: 400;
+ --z-popover: 500;
+ --z-toast: 600;
+
+ /* Focus Ring */
+ --focus-ring: 0 0 0 2px var(--bg-surface), 0 0 0 4px var(--border-focus);
}
/* Theme: App (Default — Professional) */
@@ -208,3 +256,15 @@ h1, h2, h3, h4, h5, h6 {
background: var(--interactive-primary);
color: var(--text-inverse);
}
+
+/* Reduced Motion Policy */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/tests/e2e/features.spec.ts b/tests/e2e/features.spec.ts
index 077b84a5..b7e7a56a 100644
--- a/tests/e2e/features.spec.ts
+++ b/tests/e2e/features.spec.ts
@@ -60,7 +60,7 @@ test.describe('Search', () => {
const btn = page.locator('.nav-button').filter({ hasText: 'Chat', visible: true }).first();
await btn.click();
- await expect(page.locator('.ask-surface')).toBeVisible({ timeout: 15000 });
+ await expect(page.locator('.chat-view')).toBeVisible({ timeout: 15000 });
await ensureNavVisible(page);
await expect(page.locator('.nav-button').filter({ hasText: 'Chat', visible: true }).first()).toHaveAttribute('aria-current', 'page', { timeout: 10000 });
@@ -69,7 +69,7 @@ test.describe('Search', () => {
await input.fill('test');
await page.keyboard.press('Enter');
- await expect(page.locator('.message-wrapper').first()).toBeVisible({ timeout: 15000 });
+ await expect(page.locator('.message').first()).toBeVisible({ timeout: 15000 });
});
});