frontend: hover-revealed controls stay put where hovering is impossible (task 2697)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m36s
Desktop (Tauri) / Update manifest (push) Canceled after 0s

A finger cannot hover, and on a note card the hover toolbar is the only way to
pin, colour or archive — so on a phone those notes could not be acted on at all.
Same for deleting a checklist item, a saved view, an attachment or a preview.

Marked rather than rewritten inline: one `.hover-reveal` class on the five
elements and a single rule that says what it is for. The `group-hover:` reveal
stays in the markup because the trigger differs per component (named groups);
only the fallback is shared. `@media (hover: none)` asks the device directly,
which is more honest than inferring from viewport width — a narrow window on a
laptop still hovers, and a large tablet still doesn't. It sits after the Tailwind
directives so it beats the opacity-0/pointer-events-none utilities on source
order without !important.

Tap targets follow the same shape: p-1.5 around an 18px icon lands near 30px,
which is fine for a cursor and too small for a thumb. Bumped to 44px on coarse
pointers only, so desktop chrome doesn't inflate.

The drag grip is deliberately NOT revealed yet. Reordering still uses HTML5
drag-and-drop, which never fires from touch, so showing the handle would only
promise something that does nothing. It comes with the pointer-events rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 16:36:58 -04:00
co-authored by Claude Opus 5
parent e7ee16c6cf
commit f5837cd985
6 changed files with 39 additions and 6 deletions
+1 -1
View File
@@ -388,7 +388,7 @@ async function signOut() {
<span class="flex-1 truncate">{{ f.name }}</span>
<button
type="button"
class="text-neutral-400 opacity-0 hover:text-red-500 group-hover/view:opacity-100"
class="hover-reveal text-neutral-400 opacity-0 hover:text-red-500 group-hover/view:opacity-100"
:aria-label="`Delete view ${f.name}`"
@click.prevent.stop="removeView(f)"
>
+1 -1
View File
@@ -36,7 +36,7 @@ defineEmits<{ (e: "remove"): void }>();
<button
v-if="removable"
type="button"
class="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/lp:opacity-100"
class="hover-reveal absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/lp:opacity-100"
aria-label="Remove preview"
@click="$emit('remove')"
>
+4 -2
View File
@@ -277,9 +277,11 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<!-- Toolbar overlays the card's top-right on hover/focus as a floating pill
(window-control style) instead of reserving a permanent row so at rest
the card is content-sized with even padding, not text pinned to the top
above an empty strip. -->
above an empty strip.
`hover-reveal`: on a device that can't hover this is the ONLY way to pin,
colour or archive a note, so there it stays visible (see style.css). -->
<div
class="pointer-events-none absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
class="hover-reveal pointer-events-none absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
>
<template v-if="note.trashed">
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="notes.restore(note.id)">
+1 -1
View File
@@ -52,7 +52,7 @@ function remove(item: ChecklistItem) {
<button
v-if="editable"
type="button"
class="text-neutral-300 opacity-0 hover:text-neutral-600 group-hover/item:opacity-100 dark:hover:text-neutral-200"
class="hover-reveal text-neutral-300 opacity-0 hover:text-neutral-600 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="remove(item)"
>
+1 -1
View File
@@ -558,7 +558,7 @@ function revPreview(rev: NoteRevision): string {
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" />
<button
type="button"
class="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
class="hover-reveal absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
aria-label="Remove attachment"
@click="notes.deleteAttachment(liveNote.id, att.id)"
>
+31
View File
@@ -39,11 +39,42 @@ body {
}
}
/* Controls that a card or row reveals on hover — the note toolbar, the drag grip,
* per-item delete buttons. Marked with this class rather than expressed inline,
* because the rule below is the whole reason they need naming.
*
* A finger cannot hover. On a device with no hover at all these are not "harder to
* reach", they are unreachable — and for a note card the hover toolbar is the ONLY
* way to pin, colour or archive. So where hovering is impossible they are simply
* always present. `@media (hover: none)` asks the device directly, which is more
* honest than inferring it from viewport width: a small window on a laptop still
* hovers, and a large tablet still doesn't.
*
* Placed after the Tailwind directives so it wins on source order against the
* `opacity-0` / `pointer-events-none` utilities it overrides, without !important.
* The `group-hover:` reveal stays in the markup — the trigger differs per component
* (named groups), only this fallback is shared. */
@media (hover: none) {
.hover-reveal {
opacity: 1;
pointer-events: auto;
}
}
@layer components {
.icon-btn {
@apply rounded-md p-1.5 text-neutral-500 transition hover:bg-black/5 focus:outline-none
focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-400 dark:hover:bg-white/10;
}
/* Finger-sized where a finger is the input. p-1.5 around an 18px icon lands near
* 30px — comfortable for a cursor, too small for a thumb (Android asks 48dp,
* WCAG 2.2 AAA asks 44px). Scoped to coarse pointers so desktop chrome doesn't
* inflate; inline-flex because a bare min-height would leave the icon off-centre. */
@media (pointer: coarse) {
.icon-btn {
@apply inline-flex min-h-[2.75rem] min-w-[2.75rem] items-center justify-center;
}
}
.nav-link {
@apply flex items-center gap-2 rounded-lg px-3 py-2 font-medium text-neutral-600 transition
hover:bg-neutral-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand