Files
Codeman/test/readmymind-phone-key.test.ts
T
JD c087d0ae4d fix(mobile): raise the phone breakpoint from 430px to 600px
The phone tier stopped at innerWidth < 430 and @media (max-width: 430px), so every current large phone landed in the tablet layout: the 430pt iPhone 14 Pro Max, 15 Plus, 15 Pro Max and 16 Plus, the 440pt iPhone 16 Pro Max and 17 Pro Max, Pixel 6 Pro, 7 Pro and OnePlus 12 Pro, the 448pt Pixel 8 Pro and 9 Pro XL, and the Galaxy Z Fold 5 cover screen at 460. On those devices the header icon row replaced the session pill, the toolbar kept the desktop Run Shell button instead of Enter and the mic, the keyboard accessory bar could never become visible because its .visible rule lives inside the phone block, and the toolbar jumped to the top of the page when the keyboard opened.

The new cutoff is 600, the line test/mobile/devices.ts already draws between large phones (430-599) and small tablets (600-767). No physical device sits between 480 and 600, but a phone zoomed out one or two steps in Safari does: a 440pt iPhone at 85% or 75% page zoom reports 518px or 587px and still needs the phone controls, which a 480 cutoff would have taken away. The phone block is max-width: 599px and the tablet block starts at min-width: 600px, so a 600px device is a tablet in CSS and in getDeviceType() alike instead of straddling the boundary the way 430pt phones did.

The number changes everywhere it is encoded: JS, CSS, comments, CLAUDE.md, the CI tests that pin the phone block, and the test:mobile helpers. Measurement history that names 430px stays as written.
2026-09-08 00:56:39 -04:00

109 lines
6.2 KiB
TypeScript

// Port: none (pure static analysis — runs in CI, no browser/server).
//
// Read My Mind phase 3 part 1 guards: the modal's alternates row and the phone
// keyboard-accessory 🧠 key. The mobile Playwright suite is excluded from CI,
// so like test/mobile-header-buttons-policy.test.ts this parses the frontend
// assets directly to pin the wiring that only a phone would exercise:
//
// 1. the 🧠 key ships in BOTH accessory-bar templates (setMode() swaps the
// bar's innerHTML between them, so a key present in only one layout would
// silently vanish when the user toggles `extendedKeyboardBar`) and routes
// to the shared modal;
// 2. the key is hidden unless the bar carries the `rmm-enabled` marker class
// — gating must live on the BAR element because setMode() rebuilds the
// buttons — synced at init and re-synced by settings-ui.js on every
// settings apply (a live toggle needs no reload);
// 3. the header 🧠 button STAYS off phones (the key is the phone surface);
// 4. the modal renders on phones as a small dialog, not the full-screen
// default that phone `.modal-content` rules would impose;
// 5. readmymind-ui.js keeps the no-innerHTML discipline (predictor output is
// injectable content) and renders alternates into the i18n-skipped
// container declared in index.html.
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const PUBLIC = join(HERE, '../src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf-8');
describe('read my mind phone key + alternates (static guards)', () => {
const accessory = read('keyboard-accessory.js');
const styles = read('styles.css');
const mobile = read('mobile.css');
const html = read('index.html');
const ui = read('readmymind-ui.js');
const settingsUi = read('settings-ui.js');
// Everything phone-specific lives in the max-width 599px block of mobile.css.
const phoneBlock = mobile.slice(mobile.indexOf('@media (max-width: 599px)'));
it('ships the 🧠 key in BOTH accessory bar templates and routes it to the modal', () => {
const simple = accessory.match(/_simpleButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
const extended = accessory.match(/_extendedButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
expect(simple).toMatch(/accessory-btn-rmm[^>]*data-action="readmymind"/);
expect(extended).toMatch(/accessory-btn-rmm[^>]*data-action="readmymind"/);
expect(accessory).toMatch(/case 'readmymind':/);
expect(accessory).toMatch(/openReadMyMind/);
});
it('hides the key until the bar carries rmm-enabled, synced at init and on settings apply', () => {
expect(styles).toMatch(/\.keyboard-accessory-bar \.accessory-btn-rmm \{\s*display: none;/);
expect(styles).toMatch(/\.keyboard-accessory-bar\.rmm-enabled \.accessory-btn-rmm \{\s*display: inline-flex;/);
// init() applies the gate as soon as the bar exists…
expect(accessory).toMatch(/this\.syncReadMyMind\(\);/);
// …and settings-ui re-syncs it on every settings apply (live toggle).
expect(settingsUi).toMatch(/KeyboardAccessoryBar\.syncReadMyMind/);
});
it('keeps the header 🧠 button off phones (the accessory key is the phone surface)', () => {
expect(phoneBlock).toMatch(/\.btn-icon-header\.btn-readmymind\s*\{\s*display: none !important;/);
});
it('renders the modal as a small dialog on phones, not the full-screen default', () => {
expect(phoneBlock).toMatch(/\.modal-content\.readmymind-modal\s*\{[^}]*height: auto;/);
});
it('declares the i18n-skipped alternates container and keeps the no-innerHTML discipline', () => {
expect(html).toMatch(/id="readMyMindAlternates"[^>]*data-i18n-skip/);
// Predictor output is injectable content: value/textContent only, ever.
// (`.innerHTML`: property ACCESS — the fileoverview's "never innerHTML"
// prose is allowed to say the word.)
expect(ui).not.toMatch(/\.innerHTML/);
expect(ui).toContain('readMyMindAlternates');
expect(ui).toMatch(/\.textContent = suggestion\.prompt/);
});
// Phase 3 part 2: the Rethink steer note (docs/readmymind-plan.md phase 3).
it('wires the rethink steer note end to end: field, payload, phase visibility, reset', () => {
// The field lives in the modal, capped to the schema's 2000-char limit,
// and Enter in it triggers a rethink (mirroring the prompt field's
// Enter-to-send).
expect(html).toMatch(/id="readMyMindSteer"[^>]*maxlength="2000"/);
expect(html).toMatch(/id="readMyMindSteer"[^>]*onkeydown="[^"]*rethinkReadMyMind\(\)"/);
// Predict sends the trimmed note as `steer`, bounded to the schema cap.
expect(ui).toMatch(/body\.steer = steer\.slice\(0, 2000\)/);
// The row hides ONLY during loading: Rethink is live in both the ready
// and the empty-result phases, so the note must be reachable in both.
expect(ui).toMatch(/steerRow\.style\.display = phase === 'loading' \? 'none' : ''/);
// A fresh open resets the note along with the rethink memory.
expect(ui).toMatch(/steer\.value = ''/);
});
it('styles the footer with btn-toolbar (bare "btn btn-*" matches no CSS in this codebase)', () => {
const modal = html.slice(html.indexOf('id="readMyMindModal"'), html.indexOf('id="approvalsDrawer"'));
// The unstyled classes the footer originally shipped with must not return.
expect(modal).not.toMatch(/class="btn /);
expect(modal.match(/class="btn-toolbar/g)?.length).toBe(4);
expect(modal).toMatch(/class="btn-toolbar btn-primary"[^>]*sendReadMyMind\(true\)/);
// btn-toolbar is display:flex (block-level): without the desktop footer
// row rule the four buttons would stack vertically.
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \{[^}]*display: flex/);
// The skin block's bare .btn-toolbar (0,2,1) greys out .btn-primary
// (0,2,0), so Send's accent must be re-asserted at higher specificity.
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar\.btn-primary \{[^}]*var\(--accent\)/);
// The phone block sizes the same class for finger targets.
expect(phoneBlock).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar/);
});
});