chat: merge caret + title into one back trigger

Caret and "chat" label were two hit targets with two hover states and
tooltips. Collapse them into a single <button id="chat-header-nav">
with one tooltip ("back to conversations") — the caret becomes a
visibility-only hint inside the button, shown when there's somewhere
to go back to, hidden on the list itself. The .navigable class
controls whether the button reads as interactive, so clicking the
label while already on the list is a cursor-default no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-15 21:33:22 +02:00
1 parent d2f7b98e6c
commit 0022f64c50
6 files changed
+52 -20

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.11');
define('APP_VERSION', '1.9.12');
// Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "1.9.11",
"version": "1.9.12",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+17
View File
@@ -29,6 +29,23 @@
flex-shrink: 0;
}
/* Combined caret + title trigger. Navigable (cursor + accent hover)
only when there's somewhere to go back to — toggled by JS via the
`.navigable` class. On the list it reads like a static label. */
.chat-header-nav {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
padding: 0;
color: var(--t-bright);
cursor: default;
transition: color 0.12s;
}
.chat-header-nav.navigable { cursor: pointer; color: var(--t-muted); }
.chat-header-nav.navigable:hover { color: var(--t-accent); }
.chat-view {
display: flex;
flex-direction: column;
+8 -2
View File
@@ -3,8 +3,14 @@
<div id="chat-flyout" class="hidden">
<div id="chat-flyout-header">
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright cursor-pointer hover:text-t-accent transition-colors" title="back to conversations">chat</span>
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
<!-- Caret + "chat" label are a single trigger with one tooltip.
The caret shows only when there's somewhere to go back to
(thread / pick / room-info); on the list itself the click
is a no-op and the button styles itself as inert. -->
<button id="chat-header-nav" type="button" class="chat-header-nav" data-tip="back to conversations">
<i id="chat-header-back-caret" class="ph ph-caret-left text-sm hidden"></i>
<span id="chat-flyout-title" class="font-pixel text-[10px]">chat</span>
</button>
<span class="flex-1"></span>
<button id="chat-thread-disposable" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="disposable: messages drop after delivery"><i class="ph ph-fire text-sm"></i></button>
<button id="chat-thread-info" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="room info"><i class="ph ph-info text-sm"></i></button>
+19 -10
View File
@@ -179,11 +179,18 @@ function hideAllViews() {
.forEach(id => $(id)?.classList.add('hidden'));
}
// Toggle the caret + make the "chat" trigger look/feel clickable only
// when there's actually somewhere to go back to.
function setNavState(canBack) {
$('chat-header-back-caret')?.classList.toggle('hidden', !canBack);
$('chat-header-nav')?.classList.toggle('navigable', canBack);
}
function showListView() {
hideAllViews();
$('chat-list-view').classList.remove('hidden');
$('chat-flyout-title').textContent = 'chat';
$('chat-header-back')?.classList.add('hidden');
setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden');
currentThread = null;
@@ -193,7 +200,7 @@ function showThreadView(thread) {
hideAllViews();
$('chat-thread-view').classList.remove('hidden');
$('chat-flyout-title').textContent = 'chat';
$('chat-header-back')?.classList.remove('hidden');
setNavState(true);
currentThread = thread;
// Room-only: info icon. 1:1-only: disposable icon.
$('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room');
@@ -205,7 +212,7 @@ function showThreadView(thread) {
function showPickView() {
hideAllViews();
$('chat-pick-view').classList.remove('hidden');
$('chat-header-back')?.classList.add('hidden');
setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden');
}
@@ -213,7 +220,7 @@ function showPickView() {
function showRoomInfoView() {
hideAllViews();
$('chat-room-info-view').classList.remove('hidden');
$('chat-header-back')?.classList.add('hidden');
setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden');
}
@@ -749,12 +756,14 @@ function init() {
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
$('chat-new-btn').addEventListener('click', openPickView);
$('chat-empty-start')?.addEventListener('click', openPickView);
const gotoList = () => { showListView(); loadConversations(); };
$('chat-header-back').addEventListener('click', gotoList);
// Clicking the "chat" title is the same as hitting the back caret —
// a second affordance so users don't have to aim at a small icon.
// No-op when already on the list (showListView is idempotent).
$('chat-flyout-title')?.addEventListener('click', gotoList);
// Caret + "chat" label are one button with one tooltip. Click is a
// no-op when the caret is hidden (already on the list) — the CSS
// .navigable class tracks whether the button looks clickable.
$('chat-header-nav')?.addEventListener('click', () => {
if (!$('chat-header-nav')?.classList.contains('navigable')) return;
showListView();
loadConversations();
});
$('chat-pick-back').addEventListener('click', showListView);
$('chat-thread-form').addEventListener('submit', sendMessage);
$('chat-thread-info')?.addEventListener('click', openRoomInfo);
+6 -6
View File
@@ -30,20 +30,20 @@ describe('Chat: flyout', () => {
expect(f.classList.contains('hidden')).toBe(true);
});
it('header-back visibility matches active view', async () => {
it('header back-caret visibility matches active view', async () => {
await ensureFlyoutClosed();
click('#chat-footer-btn');
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
// Wait for loadConversations + initial view pick to settle.
await sleep(150);
const back = $('#chat-header-back');
const caret = $('#chat-header-back-caret');
const threadShown = !$('#chat-thread-view').classList.contains('hidden');
const listShown = !$('#chat-list-view').classList.contains('hidden');
// Exactly one of thread / list should be active here (pick view
// is only reached via the + button).
expect(threadShown || listShown).toBe(true);
if (threadShown) expect(back.classList.contains('hidden')).toBe(false);
if (listShown) expect(back.classList.contains('hidden')).toBe(true);
if (threadShown) expect(caret.classList.contains('hidden')).toBe(false);
if (listShown) expect(caret.classList.contains('hidden')).toBe(true);
await ensureFlyoutClosed();
});
@@ -57,9 +57,9 @@ describe('Chat: flyout', () => {
expect($('#chat-pick-view').classList.contains('hidden')).toBe(false);
expect($('#chat-list-view').classList.contains('hidden')).toBe(true);
expect($('#chat-thread-view').classList.contains('hidden')).toBe(true);
// Header back is only for thread view — not needed when pick has
// Back caret is only for thread view — not needed when pick has
// its own dedicated back button.
expect($('#chat-header-back').classList.contains('hidden')).toBe(true);
expect($('#chat-header-back-caret').classList.contains('hidden')).toBe(true);
click('#chat-pick-back');
await sleep(50);
expect($('#chat-pick-view').classList.contains('hidden')).toBe(true);