feat: add mobile FAB for new session creation
This commit is contained in:
@@ -862,6 +862,10 @@ async function openSession(name, opts = {}) {
|
||||
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)
|
||||
try {
|
||||
if (!opts.skipConnect) {
|
||||
@@ -903,6 +907,10 @@ function closeSession() {
|
||||
const pill = $('session-pill');
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1348,6 +1356,8 @@ function bindStaticEventListeners() {
|
||||
if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); });
|
||||
var sidebarNewSessionBtn = $('sidebar-new-session-btn');
|
||||
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-collapse-btn'), 'click', toggleSidebar);
|
||||
bindSidebarClickAway();
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">🔔</span>
|
||||
</button>
|
||||
|
||||
<!-- ── Mobile FAB (new session) ───────────────────────────────────────────── -->
|
||||
<button id="new-session-fab" class="new-session-fab" aria-label="New session">+</button>
|
||||
|
||||
<!-- ── Toast notification ─────────────────────────────────────────────────── -->
|
||||
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
|
||||
@@ -1201,6 +1201,34 @@ body {
|
||||
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
|
||||
============================================================ */
|
||||
@@ -1228,4 +1256,15 @@ body {
|
||||
.sidebar-collapse-btn {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1488,3 +1488,121 @@ def test_css_sidebar_new_btn_hover_exists() -> None:
|
||||
assert ".sidebar-new-btn:hover" in 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"
|
||||
)
|
||||
|
||||
@@ -949,3 +949,53 @@ def test_html_sidebar_structure_complete() -> None:
|
||||
assert header is not None, "Missing .sidebar-header 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"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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})"
|
||||
)
|
||||
|
||||
@@ -1798,3 +1798,63 @@ def test_bind_sidebar_new_session_btn_in_bind_static_event_listeners() -> None:
|
||||
assert "showNewSessionInput" in body, (
|
||||
"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)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user