feat(attachments): make the header attachments button opt-in (default OFF)

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>
This commit is contained in:
Claude (Codeman maintainer)
2026-06-14 21:21:06 +02:00
parent 097433c86f
commit 787e5e2a03
6 changed files with 63 additions and 29 deletions
+8 -1
View File
@@ -114,7 +114,7 @@
</div>
</div>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-attachments-history" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
</button>
@@ -1016,6 +1016,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
<span class="settings-item-label">Attachments Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowAttachmentsButton">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
<span class="settings-item-label">Multi-monitor Button</span>
<label class="switch switch-sm">
+3 -4
View File
@@ -435,11 +435,10 @@ html.mobile-init .file-browser-panel {
}
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar.
Also hide the attachment-history button: the header is kept minimal on phones and
the drawer is a desktop-oriented panel. */
(The attachments button is opt-in / default-hidden everywhere via its own
--hidden marker, so it needs no mobile-specific rule here.) */
.btn-icon-header.btn-settings,
.btn-icon-header.btn-lifecycle-log,
.btn-icon-header.btn-attachments-history {
.btn-icon-header.btn-lifecycle-log {
display: none !important;
}
+13 -1
View File
@@ -307,6 +307,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
@@ -1362,6 +1363,7 @@ Object.assign(CodemanApp.prototype, {
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
@@ -1528,6 +1530,7 @@ Object.assign(CodemanApp.prototype, {
extendedKeyboardBar: _ekb,
skin: _skin,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
...serverSettings
} = settings;
try {
@@ -1681,6 +1684,7 @@ Object.assign(CodemanApp.prototype, {
showSubagents: false,
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
// Input
gestureControlEnabled: false,
// Feature toggles - keep tracking on even on mobile
@@ -1783,6 +1787,14 @@ Object.assign(CodemanApp.prototype, {
responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer);
}
// Hide the attachments (history) button when disabled. Opt-in, default OFF —
// marker class, base is display:inline-flex !important.
const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
const attachmentsBtn = document.getElementById('attachmentsHistoryBtn');
if (attachmentsBtn) {
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
}
// Multi-monitor button — hidden by default (App Settings → Display → "Header
// Displays"). The server renders the correct initial state on every reload;
// this handles a live toggle from a settings save (no reload). Toggle the
@@ -2018,7 +2030,7 @@ Object.assign(CodemanApp.prototype, {
'showLifecycleLog', 'showResponseViewer',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
]);
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
// can show it while mobile stays hidden. It used to sync, so an older
+10
View File
@@ -8328,6 +8328,16 @@ kbd {
display: none !important;
}
/* "Attachments" header button — opt-in (App Settings → Display), hidden by
default. Same pattern as the response viewer: a base inline-flex !important so
an inline style can't override it, and a more-specific marker rule to hide. */
.btn-attachments-history {
display: inline-flex !important;
}
.btn-attachments-history.btn-attachments-history--hidden {
display: none !important;
}
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
default via the marker class below; the server strips it at render when the
+4 -2
View File
@@ -35,8 +35,10 @@ const PHONE_WIDTH = 393;
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).
const KNOWN_PHONE_HIDDEN = ['btn-attachments-history', 'btn-settings', 'btn-lifecycle-log'];
// 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}="([^"]*)"`));
+25 -21
View File
@@ -1,10 +1,12 @@
// Port 3208 - Mobile header button visibility (real-browser E2E).
// Port 3208 - Header button visibility (real-browser E2E).
//
// Companion to the CI static guard (test/mobile-header-buttons-policy.test.ts).
// This one renders the real app in an emulated phone vs. a desktop-class tablet
// and asserts the ACTUAL computed visibility — catching CSS/layout regressions
// the static parser can't see. Regression history: the COD-39 attachments button
// (and earlier the plan-usage chip) shipped visible on the cramped phone header.
// Renders the real app and asserts ACTUAL computed visibility, catching CSS/layout
// regressions the static parser can't see. Two behaviours are covered:
// 1. The minimal phone header hides the settings gear + lifecycle log.
// 2. The attachments button (COD-39) is OPT-IN — default-hidden everywhere, shown
// only when App Settings → Display → "Attachments Button" is enabled. It first
// shipped always-visible (mobile, then desktop), which is the regression here.
import { describe, it, beforeAll, afterAll } from 'vitest';
import type { WebServer } from '../../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
@@ -16,15 +18,7 @@ import { PORTS, WAIT } from './helpers/constants.js';
const PORT = PORTS.HEADER_BUTTONS;
const BASE_URL = `http://localhost:${PORT}`;
// Secondary header buttons that must NOT clutter the minimal phone header
// (settings/case controls live in the mobile toolbar instead).
const PHONE_HIDDEN = [
'#attachmentsHistoryBtn', // COD-39 — the reported regression
'.btn-icon-header.btn-settings',
'.btn-icon-header.btn-lifecycle-log',
];
describe('Mobile header button visibility (E2E)', () => {
describe('Header button visibility (E2E)', () => {
let server: WebServer;
beforeAll(async () => {
@@ -36,19 +30,29 @@ describe('Mobile header button visibility (E2E)', () => {
await stopTestServer(server);
});
it('hides secondary header buttons on a standard phone (iPhone 14 Pro, 393px)', async () => {
it('hides the settings gear + lifecycle log on a standard phone (iPhone 14 Pro)', async () => {
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL);
await page.waitForTimeout(WAIT.PAGE_SETTLE);
for (const sel of PHONE_HIDDEN) {
await assertHidden(page, sel);
}
await assertHidden(page, '.btn-icon-header.btn-settings');
await assertHidden(page, '.btn-icon-header.btn-lifecycle-log');
});
it('keeps the attachments button visible on a desktop-class tablet', async () => {
// assertHidden also passes when an element is ABSENT, so prove the selector is
// real by asserting the same button IS visible where the phone rule doesn't apply.
it('keeps the opt-in attachments button HIDDEN by default on a desktop-class viewport', async () => {
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '#attachmentsHistoryBtn');
});
it('shows the attachments button once the setting is enabled', async () => {
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
// Desktop-class devices use the non-mobile settings blob.
await page.evaluate(() => {
const cur = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
cur.showAttachmentsButton = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(cur));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertVisible(page, '#attachmentsHistoryBtn');
});
});