feat: add mobile FAB for new session creation
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">🔔</span>
|
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">🔔</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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})"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user