feat: add mobile FAB for new session creation

This commit is contained in:
Brian Krabach
2026-03-30 01:34:31 -07:00
parent d76042560a
commit dbb8b2bd73
6 changed files with 280 additions and 0 deletions
+10
View File
@@ -862,6 +862,10 @@ async function openSession(name, opts = {}) {
updateSessionPill(_currentSessions); updateSessionPill(_currentSessions);
} }
// Hide FAB during fullscreen session view
const fab = $('new-session-fab');
if (fab) fab.classList.add('hidden');
// Connect to session (kill old ttyd, spawn new one for this session) // Connect to session (kill old ttyd, spawn new one for this session)
try { try {
if (!opts.skipConnect) { if (!opts.skipConnect) {
@@ -903,6 +907,10 @@ function closeSession() {
const pill = $('session-pill'); const pill = $('session-pill');
if (pill) pill.classList.add('hidden'); if (pill) pill.classList.add('hidden');
// Restore FAB when returning to overview
const fab = $('new-session-fab');
if (fab) fab.classList.remove('hidden');
return Promise.resolve(); return Promise.resolve();
} }
@@ -1348,6 +1356,8 @@ function bindStaticEventListeners() {
if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); }); if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); });
var sidebarNewSessionBtn = $('sidebar-new-session-btn'); var sidebarNewSessionBtn = $('sidebar-new-session-btn');
if (sidebarNewSessionBtn) on(sidebarNewSessionBtn, 'click', function() { showNewSessionInput(sidebarNewSessionBtn); }); if (sidebarNewSessionBtn) on(sidebarNewSessionBtn, 'click', function() { showNewSessionInput(sidebarNewSessionBtn); });
var newSessionFab = $('new-session-fab');
if (newSessionFab) on(newSessionFab, 'click', function() { showNewSessionInput(newSessionFab); });
on($('sidebar-toggle-btn'), 'click', toggleSidebar); on($('sidebar-toggle-btn'), 'click', toggleSidebar);
on($('sidebar-collapse-btn'), 'click', toggleSidebar); on($('sidebar-collapse-btn'), 'click', toggleSidebar);
bindSidebarClickAway(); bindSidebarClickAway();
+3
View File
@@ -69,6 +69,9 @@
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">&#128276;</span> <span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">&#128276;</span>
</button> </button>
<!-- ── Mobile FAB (new session) ───────────────────────────────────────────── -->
<button id="new-session-fab" class="new-session-fab" aria-label="New session">+</button>
<!-- ── Toast notification ─────────────────────────────────────────────────── --> <!-- ── Toast notification ─────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div> <div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
+39
View File
@@ -1201,6 +1201,34 @@ body {
border-color: var(--text-muted); border-color: var(--text-muted);
} }
/* ============================================================
Mobile FAB — new session floating action button
============================================================ */
.new-session-fab {
display: none;
position: fixed;
bottom: 16px;
right: 16px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--accent);
color: var(--bg);
border: none;
font-size: 28px;
font-weight: 300;
line-height: 1;
cursor: pointer;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
transition: transform 150ms ease, box-shadow 150ms ease;
}
.new-session-fab:active {
transform: scale(0.95);
}
/* ============================================================ /* ============================================================
Responsive overlay sidebar at <960px Responsive overlay sidebar at <960px
============================================================ */ ============================================================ */
@@ -1228,4 +1256,15 @@ body {
.sidebar-collapse-btn { .sidebar-collapse-btn {
display: none; display: none;
} }
/* Mobile FAB: show on mobile, hide header + button */
.new-session-fab {
display: flex;
align-items: center;
justify-content: center;
}
#new-session-btn {
display: none;
}
} }
+118
View File
@@ -1488,3 +1488,121 @@ def test_css_sidebar_new_btn_hover_exists() -> None:
assert ".sidebar-new-btn:hover" in css, ( assert ".sidebar-new-btn:hover" in css, (
"Missing .sidebar-new-btn:hover rule in style.css" "Missing .sidebar-new-btn:hover rule in style.css"
) )
# ============================================================
# Mobile FAB (task-6-mobile-fab)
# ============================================================
def test_css_fab_class_exists() -> None:
""".new-session-fab CSS rule must exist."""
css = read_css()
assert ".new-session-fab" in css, "Missing .new-session-fab rule in style.css"
def test_css_fab_display_none_by_default() -> None:
""".new-session-fab must have display:none as default (hidden on desktop)."""
import re
css = read_css()
# Find the .new-session-fab block (non-media-query context)
# Match the block that is NOT inside a @media rule
# Look for .new-session-fab { ... display: none ... } outside of @media
# Simple check: the rule body should contain 'display' before the first @media containing it
match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', css)
assert match, ".new-session-fab rule not found"
body = match.group(1)
assert "display" in body and ("none" in body or "display:none" in body.replace(" ", "")), (
f".new-session-fab must have display:none by default, got body: {body!r}"
)
def test_css_fab_position_fixed() -> None:
""".new-session-fab must be position:fixed."""
import re
css = read_css()
match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', css)
assert match, ".new-session-fab rule not found"
body = match.group(1)
assert "position" in body and "fixed" in body, (
f".new-session-fab must have position:fixed, got: {body!r}"
)
def test_css_fab_size_56px() -> None:
""".new-session-fab must be 56px width and height."""
import re
css = read_css()
match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', css)
assert match, ".new-session-fab rule not found"
body = match.group(1)
assert "56px" in body, (
f".new-session-fab must have 56px size (width/height), got: {body!r}"
)
def test_css_fab_border_radius_50_percent() -> None:
""".new-session-fab must have border-radius:50% for circular shape."""
import re
css = read_css()
match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', css)
assert match, ".new-session-fab rule not found"
body = match.group(1)
assert "50%" in body, (
f".new-session-fab must have border-radius:50%, got: {body!r}"
)
def test_css_fab_mobile_media_query_shows_flex() -> None:
"""At max-width: 959px, .new-session-fab must be shown as display:flex."""
import re
css = read_css()
# Find the 959px media query and check that .new-session-fab uses display:flex
match = re.search(
r'@media\s*\([^)]*max-width\s*:\s*959px[^)]*\)\s*\{([^@]*)\}',
css,
re.DOTALL
)
assert match, "Missing @media (max-width: 959px) block"
media_body = match.group(1)
assert ".new-session-fab" in media_body, (
"@media (max-width: 959px) block must contain .new-session-fab rule"
)
# Find the .new-session-fab rule within the media query
fab_match = re.search(r'\.new-session-fab\s*\{([^}]*)\}', media_body)
assert fab_match, ".new-session-fab rule not found in 959px media query"
fab_body = fab_match.group(1)
assert "flex" in fab_body, (
f".new-session-fab must show as display:flex in 959px media query, got: {fab_body!r}"
)
def test_css_fab_mobile_media_query_hides_new_session_btn() -> None:
"""At max-width: 959px, #new-session-btn must be hidden."""
import re
css = read_css()
match = re.search(
r'@media\s*\([^)]*max-width\s*:\s*959px[^)]*\)\s*\{([^@]*)\}',
css,
re.DOTALL
)
assert match, "Missing @media (max-width: 959px) block"
media_body = match.group(1)
assert "#new-session-btn" in media_body, (
"@media (max-width: 959px) block must contain #new-session-btn rule to hide it"
)
# Find the #new-session-btn rule and verify it has display:none
btn_match = re.search(r'#new-session-btn\s*\{([^}]*)\}', media_body)
assert btn_match, "#new-session-btn rule not found in 959px media query"
btn_body = btn_match.group(1)
assert "none" in btn_body, (
f"#new-session-btn must have display:none in 959px media query, got: {btn_body!r}"
)
def test_css_fab_active_transform() -> None:
""".new-session-fab:active must have a transform rule (scale)."""
css = read_css()
assert ".new-session-fab:active" in css, (
"Missing .new-session-fab:active rule in style.css"
)
+50
View File
@@ -949,3 +949,53 @@ def test_html_sidebar_structure_complete() -> None:
assert header is not None, "Missing .sidebar-header in #session-sidebar" assert header is not None, "Missing .sidebar-header in #session-sidebar"
assert list_ is not None, "Missing #sidebar-list in #session-sidebar" assert list_ is not None, "Missing #sidebar-list in #session-sidebar"
assert footer is not None, "Missing .sidebar-footer in #session-sidebar" assert footer is not None, "Missing .sidebar-footer in #session-sidebar"
# ============================================================
# Mobile FAB (task-6-mobile-fab)
# ============================================================
def test_html_fab_exists() -> None:
"""#new-session-fab button must exist with class new-session-fab, aria-label='New session', text '+'."""
soup = _SOUP
fab = soup.find(id="new-session-fab")
assert fab is not None, "Missing #new-session-fab"
assert fab.name == "button", (
f"#new-session-fab must be a <button>, got: {fab.name}"
)
classes = fab.get("class") or []
assert "new-session-fab" in classes, (
f"#new-session-fab must have class 'new-session-fab', has: {classes}"
)
assert fab.get("aria-label") == "New session", (
f"#new-session-fab must have aria-label='New session', got: {fab.get('aria-label')!r}"
)
text = fab.get_text(strip=True)
assert text == "+", (
f"#new-session-fab text must be '+', got: {text!r}"
)
def test_html_fab_before_toast() -> None:
"""#new-session-fab must appear before #toast in the document."""
soup = _SOUP
fab = soup.find(id="new-session-fab")
toast = soup.find(id="toast")
assert fab is not None, "Missing #new-session-fab"
assert toast is not None, "Missing #toast"
# Find positions in the flat list of all elements
all_elements = list(soup.find_all(True))
fab_idx = next(
(i for i, el in enumerate(all_elements) if el.get("id") == "new-session-fab"),
None,
)
toast_idx = next(
(i for i, el in enumerate(all_elements) if el.get("id") == "toast"),
None,
)
assert fab_idx is not None, "#new-session-fab not found in element list"
assert toast_idx is not None, "#toast not found in element list"
assert fab_idx < toast_idx, (
f"#new-session-fab (idx={fab_idx}) must appear before #toast (idx={toast_idx})"
)
+60
View File
@@ -1798,3 +1798,63 @@ def test_bind_sidebar_new_session_btn_in_bind_static_event_listeners() -> None:
assert "showNewSessionInput" in body, ( assert "showNewSessionInput" in body, (
"bindStaticEventListeners must call showNewSessionInput for the sidebar new-session button" "bindStaticEventListeners must call showNewSessionInput for the sidebar new-session button"
) )
# ============================================================
# Mobile FAB (task-6-mobile-fab)
# ============================================================
def test_js_fab_bound_in_bind_static_event_listeners() -> None:
"""bindStaticEventListeners must bind #new-session-fab click to showNewSessionInput."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "new-session-fab" in body, (
"bindStaticEventListeners must reference 'new-session-fab'"
)
def test_js_open_session_hides_fab() -> None:
"""openSession must add 'hidden' class to the FAB element."""
match = re.search(
r"async function openSession\s*\(.*?\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "openSession function not found"
body = match.group(1)
assert "new-session-fab" in body, (
"openSession must reference 'new-session-fab' to hide FAB during fullscreen view"
)
assert "hidden" in body, (
"openSession must add 'hidden' class to FAB (classList.add('hidden'))"
)
def test_js_close_session_restores_fab() -> None:
"""closeSession must remove 'hidden' class from the FAB element."""
match = re.search(
r"function closeSession\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "closeSession function not found"
body = match.group(1)
assert "new-session-fab" in body, (
"closeSession must reference 'new-session-fab' to restore FAB when session closed"
)
assert "remove" in body, (
"closeSession must call classList.remove('hidden') on FAB"
)
def test_js_fab_exported() -> None:
"""new-session-fab binding functions must work - showNewSessionInput is exported."""
assert "showNewSessionInput" in _JS, (
"showNewSessionInput must exist in app.js (used by FAB click handler)"
)