Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ These apply to React/Vite client code. Universal constraints (functional program

- **No window.alert/confirm** - use inline confirmations or toast notifications
- **Form labels need `htmlFor`/`id` pairing** - when adding a settings/config form field, wire `<label htmlFor="...">` to an `id="..."` on the input — screen readers and click-to-focus both depend on the association. The visual `block`/`mb-1` styling alone doesn't establish it.
- **Mobile responsive** - all pages should be mobile responsive friendly
- **Mobile responsive** - all pages should be mobile responsive friendly. **A `grid-cols-N` with N > 3 and no `…:grid-cols-*` anywhere in the same class string is a bug**, not a style nit: at 360px a four-up stat bar gives each card ~65px of outer width, so its label wraps to five lines and the whole above-the-fold region is unreadable. Give text-bearing cards a narrow phone default and let the viewport opt into the wide layout (`grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4`); a widened *gap* (`gap-4 sm:gap-6`) is not a column breakpoint. A grid of square thumbnails or heatmap cells may stay four-plus wide on a phone, but it still needs a column breakpoint so the desktop isn't stuck at the phone count. Enforced tree-wide by `client/src/responsiveGridConventions.test.js`, which carries the allowlist for the few grids that really are frozen N-across (a seven-day month grid, the fixed five-trait Big Five bars).
- **Above the fold** - keep actionable content and info above the fold and design pages for maximum information and access without scrolling
- **No hardcoded localhost** - use `window.location.hostname` for URLs; app accessed via Tailscale remotely
- **Chrome floating over media uses `.port-media-overlay`** - a label, status bar, badge, or control panel sitting on top of an image, a video, or a 3D canvas takes its surface and ink from the theme (`.port-media-overlay`, or `.port-media-overlay-strong` for a small label directly on photography; `.port-media-overlay-item` for its buttons, `.port-media-overlay-divider` for its separators — all defined in `index.css`, which carries the mechanism and the rule for picking between this, `.always-dark`, and the OpenWorld HUD remap). **Don't write `bg-black/NN` + `text-white`/`text-gray-N00` for this** — day mode remaps the text to the theme's dark ink while the black scrim stays black, so the label renders dark-on-black. Older overlays outside the 3D/Three.js pages still carry that idiom (migration tracked in #3796), so match the new convention rather than the nearest neighbor.
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/IconPicker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default function IconPicker({ value, onChange }) {
aria-hidden="true"
/>
<div className="absolute top-full left-0 mt-1 w-full bg-port-card border border-port-border rounded-lg shadow-xl z-50 p-2 max-h-64 overflow-auto">
<div className="grid grid-cols-5 gap-1">
<div className="grid grid-cols-3 gap-1 sm:grid-cols-5">
{iconNames.map(name => (
<button
key={name}
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/agents/AgentList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,9 @@ export default function AgentList() {
</div>
</div>

<div className="flex-1 overflow-auto p-6">
<div className="flex-1 overflow-auto p-4 md:p-6">
{/* Stats Bar */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4 mb-6">
<div className="p-4 bg-port-card border border-port-border rounded-lg">
<div className="text-2xl font-bold text-white">{stats.agents}</div>
<div className="text-sm text-gray-400">Total Agents</div>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/cos/DailyTrendsChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export default function DailyTrendsChart({ days = 30, initialExpanded = true })
{expanded && (
<div className="p-4 pt-0">
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 mb-4">
<div className="text-center">
<div className="text-lg font-bold text-white">{summary.totalTasks}</div>
<div className="text-xs text-gray-500">Total Tasks</div>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/cos/tabs/LearningTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function FeedbackSummary({ feedback }) {
Durable ratings across live and archived agent runs
</p>
</div>
<div className="grid grid-cols-4 gap-4 sm:gap-6 text-center">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 sm:gap-6 text-center">
<div>
<div className={`text-lg font-bold ${satisfactionClass}`}>{satisfaction}%</div>
<div className="text-[11px] text-gray-500">helpful</div>
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/digital-twin/InterviewAnalysisCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ export default function InterviewAnalysisCard({ analysisResult }) {
{traitsUpdated.bigFive && (
<div>
<p className="text-gray-400 mb-1">Big Five:</p>
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-5 gap-1 sm:gap-2">
{Object.entries(traitsUpdated.bigFive).filter(([k]) => k !== 'notes').map(([k, v]) => (
<div key={k} className="text-center">
<div className="text-xs text-gray-500">{k}</div>
<div className="text-[10px] sm:text-xs text-gray-500">{k}</div>
<div className="text-white font-medium">{typeof v === 'number' ? v.toFixed(2) : v}</div>
</div>
))}
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/digital-twin/tabs/ImportTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,12 +435,12 @@ export default function ImportTab() {
{analysisResult.insights.personalityInferences.bigFive && (
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-400 mb-3">Big Five Traits</h4>
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-5 gap-1 sm:gap-2">
{Object.entries(analysisResult.insights.personalityInferences.bigFive).map(([trait, score]) => {
const labels = { O: 'Open', C: 'Consc', E: 'Extra', A: 'Agree', N: 'Neuro' };
return (
<div key={trait} className="text-center">
<div className="text-xs text-gray-500 mb-1">{labels[trait] || trait}</div>
<div className="text-[10px] sm:text-xs text-gray-500 mb-1">{labels[trait] || trait}</div>
<div className="h-16 bg-port-bg rounded-lg relative overflow-hidden">
<div
className="absolute bottom-0 left-0 right-0 bg-port-accent/50"
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/sync/SyncDetailDrawer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ function CollectionPreview({ collection, loading, error }) {
<p className="text-gray-500 text-xs">{items.length} item{items.length !== 1 ? 's' : ''}</p>
</div>
{imageItems.length > 0 && (
<div className="grid grid-cols-4 gap-1">
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4">
{imageItems.map((it) => (
<MediaImage
key={`${it.kind}:${it.ref}`}
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/ChiefOfStaff.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1026,7 +1026,7 @@ export default function ChiefOfStaff() {
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto overflow-x-hidden p-3 lg:p-4">
{/* Stats Bar - hidden for SVG/canvas modes (now integrated into CoS sidebar);
ascii/terminal mode keeps it because TerminalCoSPanel doesn't host the cards. */}
<div className={`grid grid-cols-5 gap-1.5 sm:gap-2 lg:gap-3 mb-3 sm:mb-4 lg:mb-6 ${avatarStyle !== 'ascii' ? 'hidden' : ''}`}>
<div className={`grid grid-cols-3 gap-1.5 sm:grid-cols-5 sm:gap-2 lg:gap-3 mb-3 sm:mb-4 lg:mb-6 ${avatarStyle !== 'ascii' ? 'hidden' : ''}`}>
<StatCard
label="Active"
value={activeAgentCount}
Expand Down
179 changes: 179 additions & 0 deletions client/src/responsiveGridConventions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Repo-wide responsive-grid convention.
*
* `client/src/AGENTS.md` requires every page to be mobile responsive, and the
* overwhelming majority of grids in the tree already carry a column breakpoint
* (`grid-cols-1 sm:grid-cols-2 xl:grid-cols-3`). The failure mode this guard
* catches is the one that keeps slipping through review: a stat/summary bar
* hard-coding four or five columns with no breakpoint prefix at all. At a 360px
* viewport that leaves each card ~65px of outer width — after its own padding,
* ~34px of content — so a label like "Platform Accounts" wraps to five lines and
* the whole above-the-fold region becomes unreadable (issue #5679).
*
* The rule: a `className` string holding a bare `grid-cols-N` for N >= 4 must
* also hold at least one *prefixed* `…:grid-cols-*` in the same string, so the
* column count is conditional on available width instead of frozen at every
* viewport. Any variant prefix counts — a media breakpoint (`sm:`), a container
* query (`@3xl:`), or a custom variant.
*
* Deliberately NOT "the unprefixed default must be <= 3 columns": a grid of
* square thumbnails or heatmap cells is legitimately four to six wide on a
* phone (`grid-cols-4 sm:grid-cols-8 lg:grid-cols-12` over sprite frames,
* `grid-cols-6 sm:grid-cols-12` over a 24-hour heatmap) because the cell holds
* no text to wrap. The defect this guard exists for is the *frozen* count — a
* grid that never learned the viewport changes — and the author who writes a
* breakpoint at all has demonstrably thought about the narrow case.
*
* Scoped to git-tracked non-test sources under `client/src` so an untracked
* scratch file can't fail the suite. Comments are masked first: a doc comment
* quoting an example class string is documentation, not markup.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { trackedSourceFiles } from './test/trackedFiles.js';

const CLIENT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');

/**
* Grids that are legitimately N-across on a phone, with the reason they are.
* Adding a row here is a deliberate design call, not a way past the guard — the
* cell has to stay readable at 360px on its own merits.
*/
const ALLOWED = new Map([
[
'src/components/calendar/MonthView.jsx',
'A month grid is inherently seven days wide; collapsing the columns would stop it being a calendar.',
],
[
'src/components/digital-twin/tabs/ImportTab.jsx',
'Big Five traits are a fixed five-across bar chart; each cell is one bar plus a five-character label, kept legible with a reduced gap and a smaller label below `sm`.',
],
[
'src/components/digital-twin/InterviewAnalysisCard.jsx',
'Same Big Five five-across readout, in the interview-analysis summary.',
],
]);

/** Blank out `//` and block comments so a quoted example class isn't scanned as markup. */
function maskComments(source) {
let out = '';
let i = 0;
while (i < source.length) {
const ch = source[i];
if (ch === '/' && source[i + 1] === '/') {
while (i < source.length && source[i] !== '\n') {
out += ' ';
i += 1;
}
continue;
}
if (ch === '/' && source[i + 1] === '*') {
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
out += source[i] === '\n' ? '\n' : ' ';
i += 1;
}
out += ' ';
i += 2;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') {
out += ch;
i += 1;
while (i < source.length && source[i] !== ch) {
if (source[i] === '\\') {
out += source.slice(i, i + 2);
i += 2;
continue;
}
out += source[i];
i += 1;
}
out += source[i] ?? '';
i += 1;
continue;
}
out += ch;
i += 1;
}
return out;
}

const STRING_LITERAL = /(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
// A column count that is the phone default: no `variant:` in front of it. Global,
// because a single class string can hold more than one bare token and the widest
// one is the one that decides whether the layout is legible.
const BARE_GRID = /(?:^|\s)grid-cols-(\d+)(?=\s|$)/g;
// Any variant prefix — `sm:`, `lg:`, `@3xl:`, `roomy-viewport:` — makes it conditional.
const PREFIXED_GRID = /[\w@[\]().\-/]+:grid-cols-/;
const MIN_WIDE_COLUMNS = 4;

function widestBareColumnCount(value) {
BARE_GRID.lastIndex = 0;
let widest = 0;
let bare;
while ((bare = BARE_GRID.exec(value))) widest = Math.max(widest, Number(bare[1]));
return widest;
}

function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}

function violationsIn(rawSource, file) {
const source = maskComments(rawSource);
const found = [];
let match;
STRING_LITERAL.lastIndex = 0;
while ((match = STRING_LITERAL.exec(source))) {
const value = match[2];
if (widestBareColumnCount(value) < MIN_WIDE_COLUMNS) continue;
if (PREFIXED_GRID.test(value)) continue;
found.push(`${file}:${lineOf(source, match.index)} — "${value.trim()}"`);
}
return found;
}

const findViolations = (file) =>
violationsIn(readFileSync(join(CLIENT_ROOT, file), 'utf8'), file);

describe('responsive grid conventions', () => {
const files = trackedSourceFiles(CLIENT_ROOT);

it('scans a populated client tree', () => {
expect(files.length).toBeGreaterThan(100);
});

// Without this the suite would still pass if the detector silently stopped
// matching anything — a green tree-wide guard proves nothing on its own.
it('flags a bare wide grid and clears every conditional form', () => {
const flagged = (markup) => violationsIn(`<div className="${markup}" />`, 'probe.jsx').length;
expect(flagged('grid grid-cols-4 gap-4 mb-6')).toBe(1);
expect(flagged('grid grid-cols-5 gap-2')).toBe(1);
// A widened gap is not a column breakpoint — it leaves four columns frozen.
expect(flagged('grid grid-cols-4 gap-4 sm:gap-6 text-center')).toBe(1);
// The widest bare token decides, not the first one seen.
expect(flagged('grid grid-cols-2 gap-2 grid-cols-6')).toBe(1);
expect(flagged('grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4')).toBe(0);
expect(flagged('grid grid-cols-4 sm:grid-cols-8 lg:grid-cols-12 gap-1.5')).toBe(0);
expect(flagged('grid grid-cols-2 gap-3 @3xl:grid-cols-4')).toBe(0);
// Narrow grids are fine unprefixed — three stat cards still fit at 360px.
expect(flagged('grid grid-cols-3 gap-2')).toBe(0);
// A doc comment quoting an example class string is not markup.
expect(violationsIn('// e.g. "grid grid-cols-4 gap-4"', 'probe.jsx')).toEqual([]);
});

it('never hard-codes four or more columns without a breakpoint', () => {
const violations = files
.filter((file) => !ALLOWED.has(file))
.flatMap((file) => findViolations(file));
expect(violations).toEqual([]);
});

it('keeps the allowlist honest — every exempt file still has the wide grid it was exempted for', () => {
const stale = [...ALLOWED.keys()].filter((file) => findViolations(file).length === 0);
expect(stale).toEqual([]);
});
});
10 changes: 6 additions & 4 deletions scripts/ci-test-plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,13 +238,15 @@ const structuralTestsFor = (changedFiles, trackedSet) => {
if (changedFiles.some((path) => /^client\/src\/.*\.jsx$/.test(path))) {
add('client/src/a11yConventions.test.js');
}
// Both `.js` and `.jsx`: the StrictMode mounted-ref bug this guards against
// Both `.js` and `.jsx`: the StrictMode mounted-ref bug the first guard covers
// reached its widest blast radius through a plain-`.js` hook (`useAsyncAction`),
// so a `.jsx`-only trigger would miss the case that matters most. Nothing else
// selects this file — it has no source sibling and imports no app module, so
// without this entry it only ever runs on a full suite.
// so a `.jsx`-only trigger would miss the case that matters most, and the
// responsive-grid guard reads class strings out of both extensions. Neither
// file has a source sibling or imports an app module, so nothing else selects
// them — without this entry they only ever run on a full suite.
if (changedFiles.some((path) => /^client\/src\/.*\.jsx?$/.test(path))) {
add('client/src/hooks/mountedRefConventions.test.js');
add('client/src/responsiveGridConventions.test.js');
}

return selected;
Expand Down
1 change: 1 addition & 0 deletions scripts/repo-scan-guards.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const STRUCTURALLY_SELECTED = new Map([
// the only way either can start failing.
['client/src/a11yConventions.test.js', 'structuralTestsFor: client/src/**.jsx'],
['client/src/hooks/mountedRefConventions.test.js', 'structuralTestsFor: client/src/**.js(x)'],
['client/src/responsiveGridConventions.test.js', 'structuralTestsFor: client/src/**.js(x)'],
// `.ps1` is not in EXECUTABLE_RE, so touching one is an "unclassified changed
// file" and forces the complete suite. The guard also rides the Windows
// contract list.
Expand Down