diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 520eb79..eab916f 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -141,6 +141,7 @@ const DISPLAY_DEFAULTS = { bellSound: false, notificationPermission: 'default', }; +const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}'; // ─── DOM helpers ────────────────────────────────────────────────────────────── function $(id) { @@ -1126,6 +1127,12 @@ function openSettings() { if (autoOpenEl) { autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true; } + + // New Session tab - populate template textarea + const templateEl = $('setting-template'); + if (templateEl) { + templateEl.value = (ss && ss.new_session_template) || NEW_SESSION_DEFAULT_TEMPLATE; + } }); } @@ -1390,6 +1397,23 @@ function bindStaticEventListeners() { console.error('Notification.requestPermission() failed:', err); }); }); + + // New Session tab — template textarea with 500ms debounce + var _templateDebounceTimer; + on($('setting-template'), 'input', function() { + clearTimeout(_templateDebounceTimer); + var val = this.value; + _templateDebounceTimer = setTimeout(function() { + patchServerSetting('new_session_template', val); + }, 500); + }); + + // New Session tab — reset button restores default template + on($('setting-template-reset'), 'click', function() { + var el = $('setting-template'); + if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE; + patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE); + }); } // ─── Test-only helpers ──────────────────────────────────────────────────────── diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 600be8e..9b28013 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -152,7 +152,14 @@ -
+ diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index ec52cb2..6f496a2 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -1091,6 +1091,34 @@ body { cursor: pointer; } +/* ============================================================ + New Session tab controls + ============================================================ */ + +.settings-textarea { + width: 100%; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-family: var(--font-mono); + font-size: 13px; + padding: 10px; + resize: vertical; + min-height: 60px; + outline: none; +} + +.settings-textarea:focus { + border-color: var(--accent); +} + +.settings-helper { + font-size: 12px; + color: var(--text-muted); + font-style: italic; +} + /* ============================================================ Notifications tab controls ============================================================ */ diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index e6b982d..8e4f7a5 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2127,4 +2127,259 @@ test('ANSI_COLORS uses xterm.js default GTK/Tango palette', () => { assert.strictEqual(app.ansi256Color(14), '#34e2e2', 'color 14 must be xterm.js default: #34e2e2'); }); +// --- New Session tab --- + +test('HTML index.html has setting-template textarea with correct attributes', () => { + const source = fs.readFileSync( + new URL('../index.html', import.meta.url), 'utf8' + ); + assert.ok(source.includes('id="setting-template"'), 'must have setting-template textarea'); + assert.ok(source.includes('settings-textarea'), 'must have settings-textarea class'); + assert.ok(source.includes('rows="3"'), 'must have rows=3'); + assert.ok(source.includes('placeholder="tmux new-session -d -s {name}"'), 'must have correct placeholder'); +}); + +test('HTML index.html has setting-template-reset button', () => { + const source = fs.readFileSync( + new URL('../index.html', import.meta.url), 'utf8' + ); + assert.ok(source.includes('id="setting-template-reset"'), 'must have setting-template-reset button'); + assert.ok(source.includes('settings-action-btn'), 'must use settings-action-btn class on reset button'); +}); + +test('HTML index.html has settings-helper text for template', () => { + const source = fs.readFileSync( + new URL('../index.html', import.meta.url), 'utf8' + ); + assert.ok(source.includes('settings-helper'), 'must have settings-helper class'); + assert.ok(source.includes('{name} is replaced with the session name'), 'must have helper text'); +}); + +test('CSS style.css has .settings-textarea class', () => { + const source = fs.readFileSync( + new URL('../style.css', import.meta.url), 'utf8' + ); + assert.ok(source.includes('.settings-textarea'), 'must have .settings-textarea CSS class'); +}); + +test('CSS style.css has .settings-helper class', () => { + const source = fs.readFileSync( + new URL('../style.css', import.meta.url), 'utf8' + ); + assert.ok(source.includes('.settings-helper'), 'must have .settings-helper CSS class'); +}); + +test('openSettings populates setting-template textarea from server settings', async () => { + // Reset _currentSessions to empty to avoid createTextNode calls in openSettings callback + app._setCurrentSessions([]); + + const elements = {}; + const origFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (url === '/api/settings') { + return { + ok: true, + json: async () => ({ new_session_template: 'my-custom-template' }), + }; + } + return { ok: true, json: async () => ({}) }; + }; + + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (!elements[id]) { + elements[id] = { + value: '', + checked: false, + innerHTML: '', + disabled: false, + appendChild: () => {}, + querySelectorAll: () => [], + classList: { add: () => {}, remove: () => {} }, + showModal: () => {}, + close: () => {}, + addEventListener: () => {}, + }; + } + return elements[id]; + }; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.querySelectorAll = () => []; + const origCreateTextNode = globalThis.document.createTextNode; + globalThis.document.createTextNode = (text) => ({ nodeType: 3, textContent: text }); + + app.openSettings(); + // Flush microtask queue so all Promises in loadServerSettings chain resolve + // before yielding to macrotask queue (where other tests could start). + // Each await Promise.resolve() flushes one microtask hop; 10 covers nested chains. + for (let i = 0; i < 10; i++) await Promise.resolve(); + + assert.strictEqual( + elements['setting-template'] && elements['setting-template'].value, + 'my-custom-template', + 'textarea should be populated with new_session_template from server settings', + ); + + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + globalThis.document.createTextNode = origCreateTextNode; +}); + +test('openSettings uses default template when new_session_template not in server settings', async () => { + // Reset _currentSessions to empty to avoid createTextNode calls in openSettings callback + app._setCurrentSessions([]); + + const elements = {}; + const origFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (url === '/api/settings') { + return { + ok: true, + json: async () => ({}), + }; + } + return { ok: true, json: async () => ({}) }; + }; + + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (!elements[id]) { + elements[id] = { + value: '', + checked: false, + innerHTML: '', + disabled: false, + appendChild: () => {}, + querySelectorAll: () => [], + classList: { add: () => {}, remove: () => {} }, + showModal: () => {}, + close: () => {}, + addEventListener: () => {}, + }; + } + return elements[id]; + }; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.querySelectorAll = () => []; + const origCreateTextNode = globalThis.document.createTextNode; + globalThis.document.createTextNode = (text) => ({ nodeType: 3, textContent: text }); + + app.openSettings(); + // Flush microtask queue so all Promises in loadServerSettings chain resolve + // before yielding to macrotask queue (where other tests could start). + for (let i = 0; i < 10; i++) await Promise.resolve(); + + assert.strictEqual( + elements['setting-template'] && elements['setting-template'].value, + 'tmux new-session -d -s {name}', + 'textarea should use default when new_session_template not set', + ); + + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + globalThis.document.createTextNode = origCreateTextNode; +}); + +test('bindStaticEventListeners binds input on setting-template', () => { + const eventsBound = {}; + const origGetById = globalThis.document.getElementById; + const origDocAddListener = globalThis.document.addEventListener; + globalThis.document.getElementById = (id) => { + const el = { + _events: {}, + addEventListener: (ev, fn) => { el._events[ev] = fn; }, + value: '', + querySelectorAll: () => [], + }; + eventsBound[id] = el; + return el; + }; + globalThis.document.addEventListener = () => {}; + + app.bindStaticEventListeners(); + + assert.ok( + eventsBound['setting-template'] && 'input' in eventsBound['setting-template']._events, + '#setting-template should have an input listener', + ); + + globalThis.document.getElementById = origGetById; + globalThis.document.addEventListener = origDocAddListener; +}); + +test('bindStaticEventListeners binds click on setting-template-reset', () => { + const eventsBound = {}; + const origGetById = globalThis.document.getElementById; + const origDocAddListener = globalThis.document.addEventListener; + globalThis.document.getElementById = (id) => { + const el = { + _events: {}, + addEventListener: (ev, fn) => { el._events[ev] = fn; }, + value: '', + querySelectorAll: () => [], + }; + eventsBound[id] = el; + return el; + }; + globalThis.document.addEventListener = () => {}; + + app.bindStaticEventListeners(); + + assert.ok( + eventsBound['setting-template-reset'] && 'click' in eventsBound['setting-template-reset']._events, + '#setting-template-reset should have a click listener', + ); + + globalThis.document.getElementById = origGetById; + globalThis.document.addEventListener = origDocAddListener; +}); + +test('setting-template-reset click resets textarea to default value', () => { + const elements = {}; + const origFetch = globalThis.fetch; + globalThis.fetch = async () => ({ ok: true, json: async () => ({}) }); + + const origGetById = globalThis.document.getElementById; + const origDocAddListener = globalThis.document.addEventListener; + globalThis.document.getElementById = (id) => { + if (!elements[id]) { + elements[id] = { + _events: {}, + addEventListener: (ev, fn) => { elements[id]._events[ev] = fn; }, + value: 'custom-value', + querySelectorAll: () => [], + }; + } + return elements[id]; + }; + globalThis.document.addEventListener = () => {}; + + app.bindStaticEventListeners(); + + // Simulate reset button click + if (elements['setting-template-reset']) { + elements['setting-template-reset']._events.click(); + } + + assert.strictEqual( + elements['setting-template'] && elements['setting-template'].value, + 'tmux new-session -d -s {name}', + 'textarea should be reset to default value on reset button click', + ); + + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.addEventListener = origDocAddListener; +}); + +test('app.js source uses 500ms debounce for template input and references new_session_template', () => { + const source = fs.readFileSync( + new URL('../app.js', import.meta.url), 'utf8' + ); + assert.ok(source.includes('500'), 'must have 500ms debounce timeout'); + assert.ok(source.includes('new_session_template'), 'must reference new_session_template setting key'); +}); + diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 576ba7b..2f71af5 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -404,12 +404,16 @@ def test_css_sidebar_item_body_pre(): assert "left: 0" in block assert "right: 0" in block assert "font-size: 10px" in block - assert "line-height: 1.0" in block, "sidebar-item-body pre must use line-height: 1.0 (xterm.js default)" + assert "line-height: 1.0" in block, ( + "sidebar-item-body pre must use line-height: 1.0 (xterm.js default)" + ) assert "#c9d1d9" in block, "sidebar-item-body pre must match xterm.js foreground" assert "white-space: pre" in block assert "padding: 0 8px 6px" in block # Explicit xterm.js font family (not design token variable) - assert "'SF Mono'" in block or "SF Mono" in block, "sidebar-item-body pre must use explicit xterm.js font family" + assert "'SF Mono'" in block or "SF Mono" in block, ( + "sidebar-item-body pre must use explicit xterm.js font family" + ) def test_css_sidebar_empty(): @@ -649,8 +653,12 @@ def test_preview_popover_pre_matches_xterm_typography(): """.preview-popover pre must match xterm.js terminal: 14px, line-height 1.0, explicit font stack.""" css = read_css() block = _extract_rule_block(css, ".preview-popover pre {") - assert "font-size: 14px" in block, ".preview-popover pre must use 14px (xterm.js default)" - assert "line-height: 1.0" in block, ".preview-popover pre must use line-height: 1.0 (xterm.js default)" + assert "font-size: 14px" in block, ( + ".preview-popover pre must use 14px (xterm.js default)" + ) + assert "line-height: 1.0" in block, ( + ".preview-popover pre must use line-height: 1.0 (xterm.js default)" + ) assert "'SF Mono'" in block or "SF Mono" in block, ( ".preview-popover pre must use explicit xterm.js font family" ) @@ -669,7 +677,9 @@ def test_tile_body_pre_has_xterm_line_height(): """.tile-body pre must use line-height: 1.0 to match xterm.js terminal.""" css = read_css() block = _extract_rule_block(css, ".tile-body pre {") - assert "line-height: 1.0" in block, ".tile-body pre must use line-height: 1.0 (xterm.js default)" + assert "line-height: 1.0" in block, ( + ".tile-body pre must use line-height: 1.0 (xterm.js default)" + ) def test_tile_body_pre_has_explicit_font_family(): @@ -694,7 +704,9 @@ def test_sidebar_item_body_pre_has_xterm_line_height(): """.sidebar-item-body pre must use line-height: 1.0 to match xterm.js terminal.""" css = read_css() block = _extract_rule_block(css, ".sidebar-item-body pre {") - assert "line-height: 1.0" in block, ".sidebar-item-body pre must use line-height: 1.0 (xterm.js default)" + assert "line-height: 1.0" in block, ( + ".sidebar-item-body pre must use line-height: 1.0 (xterm.js default)" + ) def test_sidebar_item_body_pre_has_explicit_font_family(): @@ -710,7 +722,9 @@ def test_no_gradient_fade_on_previews(): """Gradient fade overlays must be removed — they obscure ANSI colors at the bottom.""" css = read_css() assert ".tile-body::before" not in css, "tile-body::before gradient must be removed" - assert ".sidebar-item-body::before" not in css, "sidebar-item-body::before gradient must be removed" + assert ".sidebar-item-body::before" not in css, ( + "sidebar-item-body::before gradient must be removed" + ) # ============================================================ @@ -750,7 +764,9 @@ def test_css_settings_backdrop_exists(): assert ".settings-backdrop" in css, "Missing .settings-backdrop CSS rule" block = _extract_rule_block(css, ".settings-backdrop {") assert "position: fixed" in block, ".settings-backdrop must use position: fixed" - assert "blur" in block or "backdrop-filter" in block, ".settings-backdrop must use blur" + assert "blur" in block or "backdrop-filter" in block, ( + ".settings-backdrop must use blur" + ) def test_css_settings_dialog_exists(): @@ -767,7 +783,9 @@ def test_css_settings_dialog_exists(): def test_css_settings_dialog_backdrop_transparent(): """.settings-dialog::backdrop must be transparent.""" css = read_css() - assert ".settings-dialog::backdrop" in css, "Missing .settings-dialog::backdrop CSS rule" + assert ".settings-dialog::backdrop" in css, ( + "Missing .settings-dialog::backdrop CSS rule" + ) block = _extract_rule_block(css, ".settings-dialog::backdrop {") assert "transparent" in block, ".settings-dialog::backdrop must be transparent" @@ -810,7 +828,9 @@ def test_css_settings_content_exists(): css = read_css() assert ".settings-content" in css, "Missing .settings-content CSS rule" block = _extract_rule_block(css, ".settings-content {") - assert "overflow-y: auto" in block, ".settings-content must be scrollable (overflow-y: auto)" + assert "overflow-y: auto" in block, ( + ".settings-content must be scrollable (overflow-y: auto)" + ) def test_css_settings_field_exists(): @@ -819,8 +839,12 @@ def test_css_settings_field_exists(): assert ".settings-field" in css, "Missing .settings-field CSS rule" block = _extract_rule_block(css, ".settings-field {") assert "display: flex" in block, ".settings-field must use display: flex" - assert "flex-direction: row" in block, ".settings-field must use flex-direction: row" - assert "justify-content: space-between" in block, ".settings-field must use justify-content: space-between" + assert "flex-direction: row" in block, ( + ".settings-field must use flex-direction: row" + ) + assert "justify-content: space-between" in block, ( + ".settings-field must use justify-content: space-between" + ) def test_css_settings_select_exists(): @@ -833,24 +857,36 @@ def test_css_settings_select_exists(): def test_css_settings_mobile_media_query(): """@media (max-width: 599px) block must exist for settings dialog mobile styles.""" css = read_css() - assert "@media (max-width: 599px)" in css, "Missing @media (max-width: 599px) for settings mobile" + assert "@media (max-width: 599px)" in css, ( + "Missing @media (max-width: 599px) for settings mobile" + ) media_block = _extract_media_block(css, "@media (max-width: 599px)") - assert ".settings-dialog" in media_block, ".settings-dialog mobile styles must be in 599px media block" + assert ".settings-dialog" in media_block, ( + ".settings-dialog mobile styles must be in 599px media block" + ) # Bottom sheet: 100% width block = _extract_rule_block(media_block, ".settings-dialog {") assert "width: 100%" in block, ".settings-dialog must be 100% wide on mobile" assert "85vh" in block, ".settings-dialog must be 85vh tall on mobile" - assert "bottom: 0" in block or "bottom:0" in block, ".settings-dialog must be bottom-anchored on mobile" + assert "bottom: 0" in block or "bottom:0" in block, ( + ".settings-dialog must be bottom-anchored on mobile" + ) def test_css_settings_mobile_tabs_horizontal(): """Inside mobile media query, settings tabs must become horizontal scrolling row.""" css = read_css() media_block = _extract_media_block(css, "@media (max-width: 599px)") - assert ".settings-tabs" in media_block, ".settings-tabs must have mobile styles in 599px media block" + assert ".settings-tabs" in media_block, ( + ".settings-tabs must have mobile styles in 599px media block" + ) tabs_block = _extract_rule_block(media_block, ".settings-tabs {") - assert "flex-direction: row" in tabs_block, ".settings-tabs must become horizontal on mobile" - assert "overflow-x: auto" in tabs_block, ".settings-tabs must scroll horizontally on mobile" + assert "flex-direction: row" in tabs_block, ( + ".settings-tabs must become horizontal on mobile" + ) + assert "overflow-x: auto" in tabs_block, ( + ".settings-tabs must scroll horizontally on mobile" + ) # ─── Sessions tab CSS (task-1-sessions-tab) ─────────────────────────────────── @@ -864,6 +900,7 @@ def test_css_settings_field_column() -> None: ) # Find the rule and check flex-direction import re + match = re.search( r"\.settings-field--column\s*\{([^}]*)\}", css, @@ -908,6 +945,7 @@ def test_css_settings_checkbox() -> None: def test_css_settings_notification_status_exists() -> None: """.settings-notification-status must exist with flex column, align-items flex-end.""" import re + css = read_css() assert ".settings-notification-status" in css, ( ".settings-notification-status class must be defined in style.css" @@ -930,6 +968,7 @@ def test_css_settings_notification_status_exists() -> None: def test_css_settings_status_text_exists() -> None: """.settings-status-text must exist with 12px font-size and text-muted color.""" import re + css = read_css() assert ".settings-status-text" in css, ( ".settings-status-text class must be defined in style.css" @@ -953,6 +992,7 @@ def test_css_settings_status_text_exists() -> None: def test_css_settings_action_btn_exists() -> None: """.settings-action-btn must exist with background, border, 12px font-size.""" import re + css = read_css() assert ".settings-action-btn" in css, ( ".settings-action-btn class must be defined in style.css" @@ -967,17 +1007,14 @@ def test_css_settings_action_btn_exists() -> None: assert "font-size: 12px" in body or "font-size:12px" in body, ( ".settings-action-btn must set font-size: 12px" ) - assert "border" in body, ( - ".settings-action-btn must have border property" - ) - assert "background" in body, ( - ".settings-action-btn must have background property" - ) + assert "border" in body, ".settings-action-btn must have border property" + assert "background" in body, ".settings-action-btn must have background property" def test_css_settings_action_btn_hover_exists() -> None: """.settings-action-btn:hover must exist with border-color accent.""" import re + css = read_css() assert ".settings-action-btn:hover" in css, ( ".settings-action-btn:hover must be defined in style.css" @@ -997,6 +1034,7 @@ def test_css_settings_action_btn_hover_exists() -> None: def test_css_settings_action_btn_disabled_opacity() -> None: """.settings-action-btn:disabled must have opacity 0.5.""" import re + css = read_css() assert ".settings-action-btn:disabled" in css, ( ".settings-action-btn:disabled must be defined in style.css" @@ -1011,3 +1049,206 @@ def test_css_settings_action_btn_disabled_opacity() -> None: assert "opacity: 0.5" in body or "opacity:0.5" in body, ( ".settings-action-btn:disabled must set opacity: 0.5" ) + + +# ============================================================ +# New Session tab CSS (task-3-new-session-tab) +# ============================================================ + + +def test_css_settings_textarea_exists() -> None: + """.settings-textarea must be defined in style.css.""" + css = read_css() + assert ".settings-textarea" in css, ( + ".settings-textarea class must be defined in style.css" + ) + + +def test_css_settings_textarea_full_width() -> None: + """.settings-textarea must have width: 100%.""" + import re + + css = read_css() + match = re.search( + r"\.settings-textarea\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-textarea rule not found" + body = match.group(1) + assert "width: 100%" in body or "width:100%" in body, ( + ".settings-textarea must set width: 100%" + ) + + +def test_css_settings_textarea_background() -> None: + """.settings-textarea must use var(--bg-secondary) background.""" + import re + + css = read_css() + match = re.search( + r"\.settings-textarea\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-textarea rule not found" + body = match.group(1) + assert "var(--bg-secondary)" in body, ( + ".settings-textarea must use var(--bg-secondary) background" + ) + + +def test_css_settings_textarea_border_and_radius() -> None: + """.settings-textarea must have border and border-radius: 4px.""" + import re + + css = read_css() + match = re.search( + r"\.settings-textarea\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-textarea rule not found" + body = match.group(1) + assert "border:" in body or "border :" in body, ( + ".settings-textarea must have a border property" + ) + assert "border-radius: 4px" in body or "border-radius:4px" in body, ( + ".settings-textarea must have border-radius: 4px" + ) + + +def test_css_settings_textarea_font_mono_13px() -> None: + """.settings-textarea must use font-family var(--font-mono) and font-size 13px.""" + import re + + css = read_css() + match = re.search( + r"\.settings-textarea\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-textarea rule not found" + body = match.group(1) + assert "var(--font-mono)" in body, ( + ".settings-textarea must use var(--font-mono) font-family" + ) + assert "font-size: 13px" in body or "font-size:13px" in body, ( + ".settings-textarea must use font-size: 13px" + ) + + +def test_css_settings_textarea_padding_and_resize() -> None: + """.settings-textarea must have padding: 10px and resize: vertical.""" + import re + + css = read_css() + match = re.search( + r"\.settings-textarea\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-textarea rule not found" + body = match.group(1) + assert "padding: 10px" in body or "padding:10px" in body, ( + ".settings-textarea must have padding: 10px" + ) + assert "resize: vertical" in body or "resize:vertical" in body, ( + ".settings-textarea must have resize: vertical" + ) + + +def test_css_settings_textarea_min_height() -> None: + """.settings-textarea must have min-height: 60px.""" + import re + + css = read_css() + match = re.search( + r"\.settings-textarea\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-textarea rule not found" + body = match.group(1) + assert "min-height: 60px" in body or "min-height:60px" in body, ( + ".settings-textarea must have min-height: 60px" + ) + + +def test_css_settings_textarea_focus_accent_border() -> None: + """.settings-textarea:focus must use border-color: var(--accent).""" + import re + + css = read_css() + assert ".settings-textarea:focus" in css, ( + ".settings-textarea:focus rule must be defined in style.css" + ) + match = re.search( + r"\.settings-textarea:focus\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-textarea:focus rule not found" + body = match.group(1) + assert "var(--accent)" in body, ( + ".settings-textarea:focus must use var(--accent) border-color" + ) + + +def test_css_settings_helper_exists() -> None: + """.settings-helper must be defined in style.css.""" + css = read_css() + assert ".settings-helper" in css, ( + ".settings-helper class must be defined in style.css" + ) + + +def test_css_settings_helper_font_size() -> None: + """.settings-helper must have font-size: 12px.""" + import re + + css = read_css() + match = re.search( + r"\.settings-helper\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-helper rule not found" + body = match.group(1) + assert "font-size: 12px" in body or "font-size:12px" in body, ( + ".settings-helper must have font-size: 12px" + ) + + +def test_css_settings_helper_text_muted_color() -> None: + """.settings-helper must use var(--text-muted) color.""" + import re + + css = read_css() + match = re.search( + r"\.settings-helper\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-helper rule not found" + body = match.group(1) + assert "var(--text-muted)" in body, ( + ".settings-helper must use var(--text-muted) color" + ) + + +def test_css_settings_helper_italic() -> None: + """.settings-helper must have font-style: italic.""" + import re + + css = read_css() + match = re.search( + r"\.settings-helper\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-helper rule not found" + body = match.group(1) + assert "font-style: italic" in body or "font-style:italic" in body, ( + ".settings-helper must have font-style: italic" + ) diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index 1499df3..fdf5f22 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -589,7 +589,9 @@ def test_html_sessions_panel_has_default_session_select() -> None: soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" - sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"}) + sessions_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "sessions"} + ) assert sessions_panel is not None, "Missing sessions settings-panel" el = sessions_panel.find(id="setting-default-session") assert el is not None, "Missing #setting-default-session inside sessions panel" @@ -603,7 +605,9 @@ def test_html_sessions_panel_has_sort_order_select() -> None: soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" - sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"}) + sessions_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "sessions"} + ) assert sessions_panel is not None, "Missing sessions settings-panel" el = sessions_panel.find(id="setting-sort-order") assert el is not None, "Missing #setting-sort-order inside sessions panel" @@ -621,7 +625,9 @@ def test_html_sessions_panel_has_hidden_sessions_container() -> None: soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" - sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"}) + sessions_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "sessions"} + ) assert sessions_panel is not None, "Missing sessions settings-panel" el = sessions_panel.find(id="setting-hidden-sessions") assert el is not None, "Missing #setting-hidden-sessions inside sessions panel" @@ -632,7 +638,9 @@ def test_html_sessions_panel_has_window_size_largest_checkbox() -> None: soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" - sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"}) + sessions_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "sessions"} + ) assert sessions_panel is not None, "Missing sessions settings-panel" el = sessions_panel.find(id="setting-window-size-largest") assert el is not None, "Missing #setting-window-size-largest inside sessions panel" @@ -649,13 +657,13 @@ def test_html_sessions_panel_has_auto_open_checkbox_default_checked() -> None: soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" - sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"}) + sessions_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "sessions"} + ) assert sessions_panel is not None, "Missing sessions settings-panel" el = sessions_panel.find(id="setting-auto-open") assert el is not None, "Missing #setting-auto-open inside sessions panel" - assert el.name == "input", ( - f"#setting-auto-open must be an , got: {el.name}" - ) + assert el.name == "input", f"#setting-auto-open must be an , got: {el.name}" assert el.get("type") == "checkbox", ( f"#setting-auto-open must be type='checkbox', got: {el.get('type')}" ) @@ -674,13 +682,13 @@ def test_html_notifications_panel_has_bell_sound_checkbox() -> None: soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" - notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"}) + notif_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "notifications"} + ) assert notif_panel is not None, "Missing notifications settings-panel" el = notif_panel.find(id="setting-bell-sound") assert el is not None, "Missing #setting-bell-sound inside notifications panel" - assert el.name == "input", ( - f"#setting-bell-sound must be an , got: {el.name}" - ) + assert el.name == "input", f"#setting-bell-sound must be an , got: {el.name}" assert el.get("type") == "checkbox", ( f"#setting-bell-sound must be type='checkbox', got: {el.get('type')}" ) @@ -695,10 +703,14 @@ def test_html_notifications_panel_has_notification_status_text() -> None: soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" - notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"}) + notif_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "notifications"} + ) assert notif_panel is not None, "Missing notifications settings-panel" el = notif_panel.find(id="notification-status-text") - assert el is not None, "Missing #notification-status-text inside notifications panel" + assert el is not None, ( + "Missing #notification-status-text inside notifications panel" + ) classes = el.get("class") or [] assert "settings-status-text" in classes, ( f"#notification-status-text must have class 'settings-status-text', has: {classes}" @@ -710,11 +722,141 @@ def test_html_notifications_panel_has_request_btn() -> None: soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" - notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"}) + notif_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "notifications"} + ) assert notif_panel is not None, "Missing notifications settings-panel" el = notif_panel.find(id="notification-request-btn") - assert el is not None, "Missing #notification-request-btn inside notifications panel" + assert el is not None, ( + "Missing #notification-request-btn inside notifications panel" + ) classes = el.get("class") or [] assert "settings-action-btn" in classes, ( f"#notification-request-btn must have class 'settings-action-btn', has: {classes}" ) + + +# ============================================================ +# New Session tab (task-3-new-session-tab) +# ============================================================ + + +def test_html_new_session_panel_exists() -> None: + """New Session settings panel must exist with data-tab='new-session'.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + new_session_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "new-session"} + ) + assert new_session_panel is not None, "Missing new-session settings-panel" + + +def test_html_new_session_panel_has_settings_field_column() -> None: + """New Session panel must contain a .settings-field--column div.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + new_session_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "new-session"} + ) + assert new_session_panel is not None, "Missing new-session settings-panel" + field = new_session_panel.find(class_="settings-field--column") + assert field is not None, "Missing .settings-field--column inside new-session panel" + + +def test_html_new_session_panel_has_template_label() -> None: + """New Session panel must have a label for #setting-template with text 'Command template'.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + new_session_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "new-session"} + ) + assert new_session_panel is not None, "Missing new-session settings-panel" + label = new_session_panel.find("label", attrs={"for": "setting-template"}) + assert label is not None, ( + "Missing