mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
The COD-39 attachments button was hard-visible in the header — first on
mobile, then (after the mobile-only hide) still on desktop. Make it a
proper opt-in App Settings → Display toggle ("Attachments Button"),
default OFF everywhere, mirroring the Response Viewer button:
- index.html: button ships with the `btn-attachments-history--hidden`
marker; new settings checkbox #appSettingsShowAttachmentsButton.
- styles.css: base `display:inline-flex !important` + a more-specific
`--hidden` rule (same pattern as the response viewer).
- settings-ui.js: load/save/getDefaultSettings(false) + a live toggle in
applyHeaderVisibilitySettings. Per-device and NON-leaking — added to
displayKeys AND stripped from the server payload, so enabling it on
desktop never makes it appear on mobile (or any other device). No
server-side render step (purely client display, like the eye button).
- mobile.css: dropped the now-redundant phone-only hide — the opt-in
marker hides it everywhere by default; the per-device toggle governs
both desktop and phone.
Tests updated: the CI static guard drops btn-attachments-history from the
phone-hidden lock (it's opt-in now, excluded from the default-visible
enumeration — the guard still gates any NEW default-visible button); the
real-browser E2E now asserts default-hidden on a desktop-class viewport
and visible after enabling the setting.
Verified on a real desktop browser: hidden by default, the settings
toggle exists, enabling it shows the button. tsc + frontend-syntax +
prettier + public-asset checks + both test suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
146 lines
6.8 KiB
TypeScript
146 lines
6.8 KiB
TypeScript
// Port: none (pure static analysis — runs in CI, no browser/server).
|
|
//
|
|
// Regression guard for "a header button leaks onto the cramped mobile header".
|
|
// This exact class of bug shipped twice (the plan-usage chip, then the COD-39
|
|
// attachments-history button): a new control was added to the header, looked
|
|
// fine on desktop, and nobody noticed it cluttering the phone header. The mobile
|
|
// Playwright suite that *would* catch it (test/mobile/**) is EXCLUDED from CI, so
|
|
// it never gated. This test is intentionally a pure parser of index.html +
|
|
// mobile.css so it runs in the normal CI sweep with zero browser dependencies.
|
|
//
|
|
// Policy: every header button that is VISIBLE BY DEFAULT on desktop must have an
|
|
// explicit decision for phones — either it's hidden via an @media (max-width:
|
|
// 430px) display:none rule in mobile.css, or it's added to MOBILE_VISIBLE_ALLOWLIST
|
|
// below with a reason. A new default-visible header button with neither fails this
|
|
// test, forcing the author to decide its mobile behavior.
|
|
//
|
|
// The real-browser counterpart (actual computed visibility on an emulated phone)
|
|
// lives in test/mobile/header-buttons.test.ts.
|
|
import { describe, it, expect } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { join } from 'node:path';
|
|
import postcss from 'postcss';
|
|
|
|
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
const PUBLIC = join(HERE, '../src/web/public');
|
|
|
|
// Canonical phone width used to decide whether a media query applies on a phone.
|
|
// Matches the device the browser-based test emulates (iPhone 14 Pro = 393px CSS).
|
|
const PHONE_WIDTH = 393;
|
|
|
|
// Header buttons intentionally kept VISIBLE in the phone header. Empty today: the
|
|
// mobile header is deliberately minimal and essential controls (settings, case)
|
|
// live in the toolbar. Add a class here ONLY with a justifying comment.
|
|
const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
|
|
|
|
// Buttons we expect to STAY hidden on phones — an explicit lock so a future edit
|
|
// that removes a hide rule fails loudly (not silently). The attachments button is
|
|
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
|
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
|
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log'];
|
|
|
|
function attrOf(openTag: string, name: string): string {
|
|
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
|
return m ? m[1] : '';
|
|
}
|
|
|
|
/** Does a media query's width range include a phone-width viewport? */
|
|
function appliesToPhone(params: string): boolean {
|
|
const max = params.match(/max-width:\s*(\d+)px/);
|
|
const min = params.match(/min-width:\s*(\d+)px/);
|
|
if (max && PHONE_WIDTH > Number(max[1])) return false; // phone is wider than the cap
|
|
if (min && PHONE_WIDTH < Number(min[1])) return false; // phone is narrower than the floor
|
|
return Boolean(max || min); // must actually be a width-bounded query
|
|
}
|
|
|
|
function loadHeaderButtons(): { classes: string[]; id: string; distinguishing: string[] }[] {
|
|
const html = readFileSync(join(PUBLIC, 'index.html'), 'utf-8');
|
|
// The header controls live in <div class="header-right" id="headerRight"> … </header>.
|
|
const region = html.match(/<div class="header-right"[^>]*>([\s\S]*?)<\/header>/);
|
|
expect(region, '#headerRight region not found in index.html — update the selector in this test').toBeTruthy();
|
|
const headerHtml = region![1];
|
|
|
|
return [...headerHtml.matchAll(/<button\b([^>]*)>/g)]
|
|
.map((m) => {
|
|
const open = m[1];
|
|
const classes = attrOf(open, 'class').split(/\s+/).filter(Boolean);
|
|
return {
|
|
classes,
|
|
id: attrOf(open, 'id'),
|
|
style: attrOf(open, 'style'),
|
|
distinguishing: classes.filter(
|
|
(c) => c.startsWith('btn-') && c !== 'btn-icon-header' && c !== 'btn-sm' && !c.endsWith('--hidden')
|
|
),
|
|
};
|
|
})
|
|
.filter((b) => b.classes.includes('btn-icon-header'))
|
|
.filter((b) => !b.classes.includes('btn-sm')) // font A-/A+ controls — separate sub-group
|
|
.filter((b) => !/display:\s*none/i.test(b.style)) // JS-gated (solo-redock, retired bell)
|
|
.filter((b) => !b.classes.some((c) => c.endsWith('--hidden'))); // opt-in, hidden by default
|
|
}
|
|
|
|
function loadPhoneHiddenClasses(): Set<string> {
|
|
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
|
|
const hidden = new Set<string>();
|
|
postcss.parse(css).walkAtRules('media', (atRule) => {
|
|
if (!appliesToPhone(atRule.params)) return;
|
|
atRule.walkRules((rule) => {
|
|
let hides = false;
|
|
rule.walkDecls('display', (decl) => {
|
|
if (decl.value.replace(/!important/i, '').trim() === 'none') hides = true;
|
|
});
|
|
if (!hides) return;
|
|
for (const token of rule.selector.match(/\.btn-[a-z0-9-]+/gi) || []) {
|
|
hidden.add(token.slice(1).toLowerCase());
|
|
}
|
|
});
|
|
});
|
|
return hidden;
|
|
}
|
|
|
|
describe('Mobile header button policy (static guard)', () => {
|
|
const buttons = loadHeaderButtons();
|
|
const phoneHidden = loadPhoneHiddenClasses();
|
|
|
|
it('finds the default-visible header buttons (sanity)', () => {
|
|
// If this drops to 0 the parser/markup drifted — fix the parser, don't delete the test.
|
|
expect(buttons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('every default-visible header button has a mobile-visibility decision', () => {
|
|
for (const btn of buttons) {
|
|
expect(
|
|
btn.distinguishing.length,
|
|
`Header button (id=${btn.id || '?'}, class="${btn.classes.join(' ')}") has no distinguishing ` +
|
|
`btn-* class to target on mobile. Give it one so its phone visibility can be controlled.`
|
|
).toBeGreaterThan(0);
|
|
|
|
const hidden = btn.distinguishing.some((c) => phoneHidden.has(c.toLowerCase()));
|
|
const allowed = btn.distinguishing.some((c) => MOBILE_VISIBLE_ALLOWLIST.has(c));
|
|
|
|
expect(
|
|
hidden || allowed,
|
|
`Header button .${btn.distinguishing.join('.')} (id=${btn.id || '?'}) is VISIBLE BY DEFAULT but has ` +
|
|
`no mobile-visibility decision.\n` +
|
|
` → To hide it on phones: add it to the @media (max-width: 430px) "display: none" block in ` +
|
|
`src/web/public/mobile.css (next to .btn-settings / .btn-lifecycle-log).\n` +
|
|
` → To keep it visible on phones: add '${btn.distinguishing[0]}' to MOBILE_VISIBLE_ALLOWLIST in ` +
|
|
`this test, with a reason.\n` +
|
|
`This guard exists because the plan-usage chip and the attachments button both leaked onto the ` +
|
|
`mobile header unnoticed.`
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('locks the known phone-hidden header buttons', () => {
|
|
for (const cls of KNOWN_PHONE_HIDDEN) {
|
|
expect(
|
|
phoneHidden.has(cls),
|
|
`${cls} must stay hidden on phones — restore its rule in the @media (max-width: 430px) ` +
|
|
`display:none block in src/web/public/mobile.css.`
|
|
).toBe(true);
|
|
}
|
|
});
|
|
});
|