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:
gsxdsm
2026-04-08 01:46:07 -07:00
parent be19cb974c
commit d7f49c0dbe
6 changed files with 271 additions and 26 deletions

View File

@@ -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.

View File

@@ -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);
});
});
});

View File

@@ -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", () => {

View 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);
}
});
});

View File

@@ -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" />

View File

@@ -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 {