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 <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.11'); define('APP_VERSION', '1.9.12');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // 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", "name": "hyprcrd",
"version": "1.9.11", "version": "1.9.12",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+17
View File
@@ -29,6 +29,23 @@
flex-shrink: 0; 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 { .chat-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+8 -2
View File
@@ -3,8 +3,14 @@
<div id="chat-flyout" class="hidden"> <div id="chat-flyout" class="hidden">
<div id="chat-flyout-header"> <div id="chat-flyout-header">
<i class="ph ph-chats-circle text-t-accent text-sm"></i> <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> <!-- Caret + "chat" label are a single trigger with one tooltip.
<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> 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> <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-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> <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')); .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() { function showListView() {
hideAllViews(); hideAllViews();
$('chat-list-view').classList.remove('hidden'); $('chat-list-view').classList.remove('hidden');
$('chat-flyout-title').textContent = 'chat'; $('chat-flyout-title').textContent = 'chat';
$('chat-header-back')?.classList.add('hidden'); setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden'); $('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden'); $('chat-thread-info')?.classList.add('hidden');
currentThread = null; currentThread = null;
@@ -193,7 +200,7 @@ function showThreadView(thread) {
hideAllViews(); hideAllViews();
$('chat-thread-view').classList.remove('hidden'); $('chat-thread-view').classList.remove('hidden');
$('chat-flyout-title').textContent = 'chat'; $('chat-flyout-title').textContent = 'chat';
$('chat-header-back')?.classList.remove('hidden'); setNavState(true);
currentThread = thread; currentThread = thread;
// Room-only: info icon. 1:1-only: disposable icon. // Room-only: info icon. 1:1-only: disposable icon.
$('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room'); $('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room');
@@ -205,7 +212,7 @@ function showThreadView(thread) {
function showPickView() { function showPickView() {
hideAllViews(); hideAllViews();
$('chat-pick-view').classList.remove('hidden'); $('chat-pick-view').classList.remove('hidden');
$('chat-header-back')?.classList.add('hidden'); setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden'); $('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden'); $('chat-thread-info')?.classList.add('hidden');
} }
@@ -213,7 +220,7 @@ function showPickView() {
function showRoomInfoView() { function showRoomInfoView() {
hideAllViews(); hideAllViews();
$('chat-room-info-view').classList.remove('hidden'); $('chat-room-info-view').classList.remove('hidden');
$('chat-header-back')?.classList.add('hidden'); setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden'); $('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden'); $('chat-thread-info')?.classList.add('hidden');
} }
@@ -749,12 +756,14 @@ function init() {
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations)); $('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
$('chat-new-btn').addEventListener('click', openPickView); $('chat-new-btn').addEventListener('click', openPickView);
$('chat-empty-start')?.addEventListener('click', openPickView); $('chat-empty-start')?.addEventListener('click', openPickView);
const gotoList = () => { showListView(); loadConversations(); }; // Caret + "chat" label are one button with one tooltip. Click is a
$('chat-header-back').addEventListener('click', gotoList); // no-op when the caret is hidden (already on the list) — the CSS
// Clicking the "chat" title is the same as hitting the back caret — // .navigable class tracks whether the button looks clickable.
// a second affordance so users don't have to aim at a small icon. $('chat-header-nav')?.addEventListener('click', () => {
// No-op when already on the list (showListView is idempotent). if (!$('chat-header-nav')?.classList.contains('navigable')) return;
$('chat-flyout-title')?.addEventListener('click', gotoList); showListView();
loadConversations();
});
$('chat-pick-back').addEventListener('click', showListView); $('chat-pick-back').addEventListener('click', showListView);
$('chat-thread-form').addEventListener('submit', sendMessage); $('chat-thread-form').addEventListener('submit', sendMessage);
$('chat-thread-info')?.addEventListener('click', openRoomInfo); $('chat-thread-info')?.addEventListener('click', openRoomInfo);
+6 -6
View File
@@ -30,20 +30,20 @@ describe('Chat: flyout', () => {
expect(f.classList.contains('hidden')).toBe(true); 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(); await ensureFlyoutClosed();
click('#chat-footer-btn'); click('#chat-footer-btn');
await waitFor(() => !$('#chat-flyout').classList.contains('hidden')); await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
// Wait for loadConversations + initial view pick to settle. // Wait for loadConversations + initial view pick to settle.
await sleep(150); await sleep(150);
const back = $('#chat-header-back'); const caret = $('#chat-header-back-caret');
const threadShown = !$('#chat-thread-view').classList.contains('hidden'); const threadShown = !$('#chat-thread-view').classList.contains('hidden');
const listShown = !$('#chat-list-view').classList.contains('hidden'); const listShown = !$('#chat-list-view').classList.contains('hidden');
// Exactly one of thread / list should be active here (pick view // Exactly one of thread / list should be active here (pick view
// is only reached via the + button). // is only reached via the + button).
expect(threadShown || listShown).toBe(true); expect(threadShown || listShown).toBe(true);
if (threadShown) expect(back.classList.contains('hidden')).toBe(false); if (threadShown) expect(caret.classList.contains('hidden')).toBe(false);
if (listShown) expect(back.classList.contains('hidden')).toBe(true); if (listShown) expect(caret.classList.contains('hidden')).toBe(true);
await ensureFlyoutClosed(); await ensureFlyoutClosed();
}); });
@@ -57,9 +57,9 @@ describe('Chat: flyout', () => {
expect($('#chat-pick-view').classList.contains('hidden')).toBe(false); expect($('#chat-pick-view').classList.contains('hidden')).toBe(false);
expect($('#chat-list-view').classList.contains('hidden')).toBe(true); expect($('#chat-list-view').classList.contains('hidden')).toBe(true);
expect($('#chat-thread-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. // 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'); click('#chat-pick-back');
await sleep(50); await sleep(50);
expect($('#chat-pick-view').classList.contains('hidden')).toBe(true); expect($('#chat-pick-view').classList.contains('hidden')).toBe(true);