feat(FN-1107): establish dashboard mobile CSS foundation
- Add mobile breakpoint tokens and a reusable .touch-target utility with 44px minimum hit area - Harden mobile styles with global 16px text-entry sizing, safe-area inset padding, and overflow guards for wide content - Keep Capacitor viewport constraints in index.html and document the shared mobile CSS conventions in the dashboard README - Add mobile CSS foundation coverage and update input font-size tests to validate the new global selectors
This commit is contained in:
@@ -69,6 +69,20 @@ The dashboard header adapts across three responsive tiers to remain usable witho
|
||||
### Mobile Task Entry
|
||||
Task entry inputs (the quick entry box in the Triage column and the New Task modal's description field) are sized to prevent browser zoom-on-focus on iOS Safari. On mobile viewports (≤768px), these inputs use a minimum 16px font size, which keeps the viewport stable when users focus the fields.
|
||||
|
||||
### Mobile CSS Foundation
|
||||
The dashboard stylesheet defines a shared mobile foundation in `app/styles.css` used by all responsive features:
|
||||
|
||||
- **Breakpoint tokens (documentation source-of-truth):**
|
||||
- `--mobile-breakpoint: 768px`
|
||||
- `--tablet-breakpoint: 1024px`
|
||||
- `--small-breakpoint: 480px`
|
||||
- `--xsmall-breakpoint: 640px`
|
||||
- **Touch target utility:** `.touch-target` enforces a minimum `44px × 44px` hit area. Apply it to compact interactive controls (icon buttons, compact links, custom menu items) that are otherwise hard to tap on mobile.
|
||||
- **Mobile interaction conventions:**
|
||||
- Interactive controls should meet the **44px minimum touch target** on mobile.
|
||||
- Text-entry controls (`input`, `select`, `textarea`) use **16px font-size** on mobile to prevent iOS Safari auto-zoom.
|
||||
- **Safe-area pattern (notched devices / Capacitor webview):** use `env(safe-area-inset-top|right|bottom|left, 0px)` for root/layout containers (for example `#root`, `.header`, `.modal`, `.board`) so content avoids status bars and home indicators.
|
||||
|
||||
### Executor Status Bar
|
||||
A persistent footer status bar at the bottom of the dashboard displays real-time executor statistics in project view. The status bar provides immediate visibility into the engine's state without opening modals or hovering over badges.
|
||||
|
||||
|
||||
@@ -35,30 +35,30 @@ describe("mobile input font size CSS", () => {
|
||||
);
|
||||
const afterMedia = css.slice(mediaStart);
|
||||
|
||||
it("contains mobile font-size override for task-entry inputs", () => {
|
||||
// Both .quick-entry-input and #new-task-description should be targeted
|
||||
expect(afterMedia).toContain(".quick-entry-input,");
|
||||
expect(afterMedia).toContain("#new-task-description");
|
||||
it("contains mobile font-size override for global text-entry controls", () => {
|
||||
expect(afterMedia).toContain('input[type="text"],');
|
||||
expect(afterMedia).toContain('input[type="search"],');
|
||||
expect(afterMedia).toContain('input[type="tel"],');
|
||||
expect(afterMedia).toContain("input:not([type]),");
|
||||
expect(afterMedia).toContain("select,");
|
||||
expect(afterMedia).toContain("textarea {");
|
||||
expect(afterMedia).toContain("font-size: 16px");
|
||||
});
|
||||
|
||||
it("task-entry font-size override is inside the mobile @media block", () => {
|
||||
it("global text-entry font-size override is inside the mobile @media block", () => {
|
||||
expect(mediaStart).toBeGreaterThanOrEqual(0);
|
||||
|
||||
|
||||
// Find the next @media after the main mobile one to scope our search
|
||||
const nextMedia = afterMedia.search(/@media/);
|
||||
const mobileBlock = nextMedia > 0 ? afterMedia.slice(0, nextMedia) : afterMedia;
|
||||
|
||||
// The override should be in the first mobile block
|
||||
expect(mobileBlock).toContain(".quick-entry-input");
|
||||
|
||||
expect(mobileBlock).toContain('input[type="text"],');
|
||||
expect(mobileBlock).toContain("font-size: 16px");
|
||||
});
|
||||
|
||||
it("only targets task-entry inputs, not all inputs globally", () => {
|
||||
// The selector should specifically target quick-entry and new-task-description
|
||||
// not a global input selector that would affect all inputs
|
||||
const globalInputPattern = /@media[^{]*max-width[^}]*\{[^}]*input\s*\{[^}]*font-size:\s*16px/s;
|
||||
expect(css).not.toMatch(globalInputPattern);
|
||||
it("applies 16px sizing globally rather than only quick-entry fields", () => {
|
||||
const globalInputPattern = /@media[^{]*max-width[^}]*\{[\s\S]*input\[type=\"text\"\][\s\S]*font-size:\s*16px/s;
|
||||
expect(css).toMatch(globalInputPattern);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,12 +42,10 @@ describe("mobile planning input font size CSS", () => {
|
||||
expect(mobileBlock).toContain("font-size: 16px");
|
||||
});
|
||||
|
||||
it("only targets planning mode textareas, not all textareas globally", () => {
|
||||
// The selector should specifically target .planning-textarea
|
||||
// not a global textarea selector that would affect all textareas
|
||||
// Match bare textarea selector (not .something-textarea)
|
||||
const globalTextareaPattern = /@media[^{]*max-width[^}]*\{[^}]*\stextarea\s*\{[^}]*font-size:\s*16px/s;
|
||||
expect(css).not.toMatch(globalTextareaPattern);
|
||||
it("applies 16px font-size globally to all text-entry controls on mobile", () => {
|
||||
// Mobile foundation now enforces iOS-safe 16px sizing for all text inputs/selects/textareas.
|
||||
const globalTextEntryPattern = /@media[^{]*max-width[^}]*\{[\s\S]*input\[type=\"text\"\][\s\S]*select,[\s\S]*textarea\s*\{[\s\S]*font-size:\s*16px/s;
|
||||
expect(css).toMatch(globalTextEntryPattern);
|
||||
});
|
||||
|
||||
it("planning-textarea font-size is within the mobile media query", () => {
|
||||
|
||||
113
packages/dashboard/app/components/__tests__/mobile-css.test.tsx
Normal file
113
packages/dashboard/app/components/__tests__/mobile-css.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const stylesPath = path.resolve(__dirname, "../../styles.css");
|
||||
const indexHtmlPath = path.resolve(__dirname, "../../index.html");
|
||||
|
||||
function getMainMobileSection(css: string): string {
|
||||
const sectionStart = css.indexOf("/* === Mobile Responsive Overrides ===");
|
||||
const sectionEnd = css.indexOf("/* === Tablet Responsive Tier", sectionStart);
|
||||
|
||||
expect(sectionStart).toBeGreaterThan(-1);
|
||||
expect(sectionEnd).toBeGreaterThan(sectionStart);
|
||||
|
||||
return css.slice(sectionStart, sectionEnd);
|
||||
}
|
||||
|
||||
function getFirstRootBlock(css: string): string {
|
||||
const match = css.match(/:root\s*\{([\s\S]*?)\n\}/);
|
||||
expect(match).toBeTruthy();
|
||||
return match![1];
|
||||
}
|
||||
|
||||
describe("mobile CSS foundation", () => {
|
||||
it("defines canonical mobile breakpoint custom properties in the first :root block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const firstRoot = getFirstRootBlock(css);
|
||||
|
||||
expect(firstRoot).toContain("--mobile-breakpoint: 768px;");
|
||||
expect(firstRoot).toContain("--tablet-breakpoint: 1024px;");
|
||||
expect(firstRoot).toContain("--small-breakpoint: 480px;");
|
||||
expect(firstRoot).toContain("--xsmall-breakpoint: 640px;");
|
||||
});
|
||||
|
||||
it("provides a touch-target utility class with 44px minimum dimensions", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const touchTargetMatch = css.match(/\.touch-target\s*\{([\s\S]*?)\}/);
|
||||
|
||||
expect(touchTargetMatch).toBeTruthy();
|
||||
expect(touchTargetMatch![1]).toContain("min-width: 44px;");
|
||||
expect(touchTargetMatch![1]).toContain("min-height: 44px;");
|
||||
});
|
||||
|
||||
it("enforces 16px font size for text inputs in the main mobile media query", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expect(mobileSection).toContain("@media (max-width: 768px)");
|
||||
expect(mobileSection).toContain('input[type="text"]');
|
||||
expect(mobileSection).toContain('input[type="search"]');
|
||||
expect(mobileSection).toContain('input[type="tel"]');
|
||||
expect(mobileSection).toContain("select,");
|
||||
expect(mobileSection).toContain("textarea {");
|
||||
expect(mobileSection).toContain("font-size: 16px;");
|
||||
});
|
||||
|
||||
it("applies safe-area inset handling in the main mobile section", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expect(mobileSection).toContain("#root {");
|
||||
expect(mobileSection).toContain("padding-top: env(safe-area-inset-top, 0px);");
|
||||
expect(mobileSection).toContain("padding-bottom: env(safe-area-inset-bottom, 0px);");
|
||||
expect(mobileSection).toContain(".header {");
|
||||
expect(mobileSection).toContain("padding-left: max(var(--space-md), env(safe-area-inset-left, 0px));");
|
||||
expect(mobileSection).toContain(".board {");
|
||||
expect(mobileSection).toContain("padding-bottom: max(var(--space-md), env(safe-area-inset-bottom, 0px));");
|
||||
expect(mobileSection).toContain(".modal,");
|
||||
expect(mobileSection).toContain("padding-bottom: env(safe-area-inset-bottom, 0px);");
|
||||
});
|
||||
|
||||
it("adds mobile overflow guards for wide content", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expect(mobileSection).toContain("* {");
|
||||
expect(mobileSection).toContain("max-width: 100vw;");
|
||||
expect(mobileSection).toContain("pre,");
|
||||
expect(mobileSection).toContain("overflow-x: auto;");
|
||||
expect(mobileSection).toContain(".code-block");
|
||||
expect(mobileSection).toContain("word-break: break-all;");
|
||||
expect(mobileSection).toContain("word-break: break-word;");
|
||||
expect(mobileSection).toContain("img,");
|
||||
expect(mobileSection).toContain("svg {");
|
||||
expect(mobileSection).toContain("max-width: 100%;");
|
||||
expect(mobileSection).toContain("table {");
|
||||
expect(mobileSection).toContain("display: block;");
|
||||
expect(mobileSection).toContain("-webkit-overflow-scrolling: touch;");
|
||||
expect(mobileSection).toContain(".workflow-step-manager-modal {");
|
||||
expect(mobileSection).toContain("max-height: 100dvh;");
|
||||
});
|
||||
|
||||
it("keeps the capacitor viewport meta tag configured", () => {
|
||||
const html = fs.readFileSync(indexHtmlPath, "utf-8");
|
||||
|
||||
expect(html).toContain("name=\"viewport\"");
|
||||
expect(html).toContain("width=device-width");
|
||||
expect(html).toContain("maximum-scale=1.0");
|
||||
expect(html).toContain("user-scalable=no");
|
||||
});
|
||||
|
||||
it("uses only approved max-width breakpoint values", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const matches = [...css.matchAll(/@media\s*\(max-width:\s*(\d+)px\)/g)];
|
||||
const foundValues = new Set(matches.map((match) => Number(match[1])));
|
||||
const allowedValues = new Set([480, 640, 768, 860]);
|
||||
|
||||
expect(foundValues.size).toBeGreaterThan(0);
|
||||
for (const value of foundValues) {
|
||||
expect(allowedValues.has(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- Viewport configured for Capacitor mobile webview: disables pinch-zoom for app-like feel -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Fusion</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
|
||||
@@ -20,6 +20,18 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* === Touch Target Utility ===
|
||||
Ensures interactive elements meet the 44px minimum touch target
|
||||
recommended by Apple HIG and WCAG 2.5.8. Apply to interactive
|
||||
elements that are too small on mobile (links, small buttons, icons). */
|
||||
.touch-target {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* === Design Tokens (Theme-Agnostic Defaults) === */
|
||||
:root {
|
||||
/* Typography */
|
||||
@@ -75,6 +87,12 @@
|
||||
/* Backward-compatible aliases */
|
||||
--radius: var(--radius-md);
|
||||
--shadow: var(--shadow-lg);
|
||||
|
||||
/* Mobile breakpoint tokens (documentation only): CSS custom properties cannot be used directly in @media query conditions. */
|
||||
--mobile-breakpoint: 768px;
|
||||
--tablet-breakpoint: 1024px;
|
||||
--small-breakpoint: 480px;
|
||||
--xsmall-breakpoint: 640px;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -5212,12 +5230,17 @@ body {
|
||||
column gets a fixed min-width and snap-scrolling for a polished swipe feel.
|
||||
The modal also goes full-screen with reduced spacing. */
|
||||
@media (max-width: 768px) {
|
||||
/* Mobile task-entry font sizing: prevent Safari zoom-on-focus by ensuring
|
||||
task entry inputs are at least 16px on mobile viewports */
|
||||
.quick-entry-input,
|
||||
#new-task-description,
|
||||
.inline-create-input,
|
||||
.card-edit-desc-textarea {
|
||||
/* Prevent iOS Safari auto-zoom on focus: all inputs must be >= 16px */
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="url"],
|
||||
input[type="tel"],
|
||||
input:not([type]),
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -5229,6 +5252,89 @@ body {
|
||||
|
||||
#root {
|
||||
overflow: hidden;
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
/* Prevent horizontal overflow from wide content */
|
||||
* {
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
.code-block {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Global touch target enforcement on mobile */
|
||||
.btn:not(.btn-icon):not(.btn-badge):not(.btn-sm):not(.btn--sm) {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="url"],
|
||||
select,
|
||||
textarea {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.detail-tab {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Dropdown items and overflow menu actions */
|
||||
.dep-dropdown-item,
|
||||
.mobile-overflow-item,
|
||||
.mobile-overflow-split-toggle,
|
||||
.list-column-dropdown-item {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Small tappable controls that are explicitly sized below 44px at desktop */
|
||||
.view-toggle-btn,
|
||||
.header-search-clear,
|
||||
.inline-create-toggle,
|
||||
.quick-entry-toggle,
|
||||
.modal-edit-btn,
|
||||
.gm-icon-btn,
|
||||
.activity-log-clear,
|
||||
.terminal-tab-close,
|
||||
.workflow-results-edit-toggle,
|
||||
.agent-tree__toggle,
|
||||
.quick-chat-panel-input button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Board: flex layout with single horizontal scroll + snap */
|
||||
@@ -5240,6 +5346,7 @@ body {
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-inline: calc(50% - 140px);
|
||||
padding: var(--space-md);
|
||||
padding-bottom: max(var(--space-md), env(safe-area-inset-bottom, 0px));
|
||||
gap: var(--space-md);
|
||||
height: 100%;
|
||||
}
|
||||
@@ -5253,6 +5360,9 @@ body {
|
||||
/* Reduce header padding to reclaim horizontal space */
|
||||
.header {
|
||||
padding: var(--space-md);
|
||||
padding-left: max(var(--space-md), env(safe-area-inset-left, 0px));
|
||||
padding-right: max(var(--space-md), env(safe-area-inset-right, 0px));
|
||||
padding-top: max(var(--space-md), env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
/* Hide project selector and back button on mobile (belt-and-suspenders with conditional rendering) */
|
||||
@@ -5297,6 +5407,15 @@ body {
|
||||
max-height: 100dvh;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.workflow-step-manager-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
|
||||
Reference in New Issue
Block a user