Show hover-revealed row controls on touch devices (#553)

## Summary

Ten controls that reveal on hover were fully transparent on touch
devices, while staying tappable. Tailwind v4 wraps `group-hover:` in
`@media (hover: hover)`, so `opacity-0 group-hover:opacity-100` hides a
control everywhere and unhides it only where hovering is possible. On
Android, tapping the right spot deleted or edited something with nothing
drawn there.

Five other controls avoided that by hiding on `sm:` and up, which
answers a capability question with window width: a narrow desktop window
showed them permanently, and a wide touch screen hid them. The app
already decides hover by capability elsewhere (`supportsHover()` in
`utils/platform.ts`). This PR puts every hover-revealed control on that
rule.

## Changes

- **One variant for the rule.** `can-hover` in `app.css` matches when
the primary input can hover and no input is coarse (`(hover: hover)`
with a nested `not all and (any-pointer: coarse)`), so touchscreen
laptops and touch Chromebooks show the controls too. It is used at every
site below and in `TimeAnchorModal`, which had the inline form.
- **Invisible on touch, now visible:**
- the tag manager's edit and delete buttons (Vault → Tags), and the
colour palette of a tag being edited, which only opened on row hover
- the remove-portrait button in the Vault character form (also in the
SillyTavern import's character editor)
- the Vault assistant's approve/reject buttons in the pending-changes
list, and "Use" / "Set as portrait" on generated images
- the SillyTavern import's per-character edit, save-to-vault and delete
- the new-story wizard's lorebook remove (step 2), supporting-cast edit
and delete (step 5), and remove-portrait buttons (step 6)
- **Keyed on width, now keyed on capability:** the branch panel's rename
and delete, the story navigation panel's row actions, the Vault
assistant's conversation actions, and the prompt pack card's actions.
The branch panel's and story navigation panel's row actions also take
their compact size under `pointer-fine:` instead of `sm:`, so touch
keeps 32px targets at any width.
- **Overlays.** The Discovery card's View/Import overlay is laid out
only where the input can hover (it was `sm:` and up), so on a wide touch
screen a tap opens the details instead of hitting an invisible Import.
The gallery's View overlay is hover-only and `pointer-events-none`, so
it no longer swallows clicks on a failed image's Retry, on any device.
- Each site reads `can-hover:opacity-0 group-hover:opacity-100`: hidden
until hover where the input can hover, visible from the start where it
cannot, at any width. An open pack-card menu keeps its trigger visible
through `aria-expanded:opacity-100`; the story navigation panel keeps
its focus reveal as plain `focus-within:`.
- Left as is: the gradient over unplaced illustrations in a story entry
(decorative, not a control).

The reveals outrank the hide on specificity, not source order:
`group-hover`, `aria-expanded` and `focus-within` are 0,2,0 against
0,1,0 for `can-hover:opacity-0`, which Tailwind 4.3.3 emits after them.
Checked by compiling the classes.

## Testing

- `npm run check` (0 errors), `npm run lint` (0 errors), `npm test`
(1933 passed). Class-only change; there is no DOM in the test
environment, so nothing here is unit-tested.
- Checked in the running app on desktop, at full width and narrowed
below `sm`, and on an Android emulator (API 36) in portrait and
landscape, for the controls in the first commit. The review follow-ups
(overlays, tag colour palette, the named variant, branch button sizing,
the coarse-pointer condition) have not been run in the app yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ivan Kolos 2026-09-28 22:14:06 +03:00 • committed by GitHub
parent 35a8e28523
commit af217618b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 49 additions and 44 deletions

View file

@ -1,6 +1,14 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
/* Hide-until-hover applies only where the primary input hovers and no input is a finger. */
@custom-variant can-hover {
@media (hover: hover) {
@media not all and (any-pointer: coarse) {
@slot;
}
}
}
@theme {
/* Radius (for rounded-*) */

View file

@ -407,21 +407,21 @@
<span class="bg-accent-500 h-2 w-2 rounded-full" title="Current branch"></span>
{/if}
<button
class="text-surface-500 hover:text-surface-200 flex min-h-[32px] min-w-[32px] items-center justify-center p-1 transition-opacity sm:min-h-0 sm:min-w-0 sm:p-0.5 sm:opacity-0 sm:group-hover:opacity-100"
class="text-surface-500 hover:text-surface-200 can-hover:opacity-0 flex min-h-[32px] min-w-[32px] items-center justify-center p-1 transition-opacity group-hover:opacity-100 pointer-fine:min-h-0 pointer-fine:min-w-0 pointer-fine:p-0.5"
onclick={(e) => {
e.stopPropagation()
startRename(branch)
}}
title="Rename"
>
<Edit2 class="h-4 w-4 sm:h-3 sm:w-3" />
<Edit2 class="h-4 w-4 pointer-fine:h-3 pointer-fine:w-3" />
</button>
{#if !isCurrent(branch.id)}
<button
class="text-surface-500 flex min-h-[32px] min-w-[32px] items-center justify-center p-1 transition-opacity sm:min-h-0 sm:min-w-0 sm:p-0.5 {children.length >
class="text-surface-500 flex min-h-[32px] min-w-[32px] items-center justify-center p-1 transition-opacity pointer-fine:min-h-0 pointer-fine:min-w-0 pointer-fine:p-0.5 {children.length >
0
? 'cursor-not-allowed opacity-30'
: 'hover:text-red-400 sm:opacity-0 sm:group-hover:opacity-100'}"
: 'can-hover:opacity-0 group-hover:opacity-100 hover:text-red-400'}"
onclick={(e) => {
e.stopPropagation()
handleDeleteBranch(branch.id)
@ -429,7 +429,7 @@
disabled={children.length > 0}
title={children.length > 0 ? 'Cannot delete: has child branches' : 'Delete'}
>
<Trash2 class="h-4 w-4 sm:h-3 sm:w-3" />
<Trash2 class="h-4 w-4 pointer-fine:h-3 pointer-fine:w-3" />
</button>
{/if}
{/if}

View file

@ -90,7 +90,7 @@
<!-- Hover Actions (Only visible when NOT imported) -->
{#if !isImported}
<div
class="absolute inset-0 hidden items-center justify-center gap-2 bg-black/60 p-4 opacity-0 transition-opacity group-hover:opacity-100 sm:flex"
class="can-hover:flex absolute inset-0 hidden items-center justify-center gap-2 bg-black/60 p-4 opacity-0 transition-opacity group-hover:opacity-100"
>
<Button
size="icon"

View file

@ -362,18 +362,18 @@
)}
{@const deleteUnavailable = deleteBlockedReason !== null}
<div
class="absolute top-1 right-1 flex transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:focus-within:opacity-100"
class="can-hover:opacity-0 absolute top-1 right-1 flex transition-opacity group-hover:opacity-100 focus-within:opacity-100"
>
<button
class="text-surface-500 hover:text-surface-200 flex min-h-[32px] min-w-[32px] items-center justify-center p-1 sm:min-h-0 sm:min-w-0 sm:p-0.5"
class="text-surface-500 hover:text-surface-200 flex min-h-[32px] min-w-[32px] items-center justify-center p-1 pointer-fine:min-h-0 pointer-fine:min-w-0 pointer-fine:p-0.5"
onclick={() => startRename(landmark.checkpointId!, landmark.label)}
title="Rename"
aria-label="Rename checkpoint"
>
<Edit2 class="h-4 w-4 sm:h-3 sm:w-3" />
<Edit2 class="h-4 w-4 pointer-fine:h-3 pointer-fine:w-3" />
</button>
<button
class="flex min-h-[32px] min-w-[32px] items-center justify-center p-1 sm:min-h-0 sm:min-w-0 sm:p-0.5 {deleteUnavailable
class="flex min-h-[32px] min-w-[32px] items-center justify-center p-1 pointer-fine:min-h-0 pointer-fine:min-w-0 pointer-fine:p-0.5 {deleteUnavailable
? 'text-surface-600 cursor-not-allowed'
: 'text-surface-500 hover:text-destructive'}"
onclick={() =>
@ -390,7 +390,7 @@
: 'Delete checkpoint'}
aria-disabled={deleteUnavailable}
>
<Trash2 class="h-4 w-4 sm:h-3 sm:w-3" />
<Trash2 class="h-4 w-4 pointer-fine:h-3 pointer-fine:w-3" />
</button>
</div>
{/if}
@ -473,18 +473,18 @@
)}
{@const orphanDeleteUnavailable = orphanBlockedReason !== null}
<div
class="absolute top-1 right-1 flex transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:focus-within:opacity-100"
class="can-hover:opacity-0 absolute top-1 right-1 flex transition-opacity group-hover:opacity-100 focus-within:opacity-100"
>
<button
class="text-surface-500 hover:text-surface-200 flex min-h-[32px] min-w-[32px] items-center justify-center p-1 sm:min-h-0 sm:min-w-0 sm:p-0.5"
class="text-surface-500 hover:text-surface-200 flex min-h-[32px] min-w-[32px] items-center justify-center p-1 pointer-fine:min-h-0 pointer-fine:min-w-0 pointer-fine:p-0.5"
onclick={() => startRename(orphan.checkpointId, orphan.label)}
title="Rename"
aria-label="Rename checkpoint"
>
<Edit2 class="h-4 w-4 sm:h-3 sm:w-3" />
<Edit2 class="h-4 w-4 pointer-fine:h-3 pointer-fine:w-3" />
</button>
<button
class="flex min-h-[32px] min-w-[32px] items-center justify-center p-1 sm:min-h-0 sm:min-w-0 sm:p-0.5 {orphanDeleteUnavailable
class="flex min-h-[32px] min-w-[32px] items-center justify-center p-1 pointer-fine:min-h-0 pointer-fine:min-w-0 pointer-fine:p-0.5 {orphanDeleteUnavailable
? 'text-surface-600 cursor-not-allowed'
: 'text-surface-500 hover:text-destructive'}"
onclick={() =>
@ -501,7 +501,7 @@
: 'Delete checkpoint'}
aria-disabled={orphanDeleteUnavailable}
>
<Trash2 class="h-4 w-4 sm:h-3 sm:w-3" />
<Trash2 class="h-4 w-4 pointer-fine:h-3 pointer-fine:w-3" />
</button>
</div>
{/if}

View file

@ -527,9 +527,9 @@
></div>
</div>
{/if}
<!-- Hover overlay - desktop only -->
<!-- Hover overlay: hover-capable devices only; clicks pass through to the image and Retry -->
<div
class="absolute inset-0 hidden items-center justify-center bg-black/40 opacity-0 transition-opacity duration-200 group-hover:opacity-100 sm:flex"
class="can-hover:flex pointer-events-none absolute inset-0 hidden items-center justify-center bg-black/40 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
<Button variant="default" size="sm">View</Button>
</div>

View file

@ -136,7 +136,7 @@
class={`h-6 w-6 rounded-full bg-${editColor} ring-muted cursor-pointer ring-2`}
></div>
<div
class="bg-background absolute top-full left-0 z-10 mt-2 hidden w-48 flex-wrap gap-1 rounded-lg border p-2 shadow-xl group-hover:flex"
class="bg-background can-hover:hidden absolute top-full left-0 z-10 mt-2 flex w-48 flex-wrap gap-1 rounded-lg border p-2 shadow-xl group-hover:flex"
>
{#each colors as color, i (color + i)}
<button
@ -181,7 +181,7 @@
</div>
<div
class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100"
class="can-hover:opacity-0 flex items-center gap-2 transition-opacity group-hover:opacity-100"
>
<Button
variant="ghost"

View file

@ -1010,7 +1010,7 @@
</div>
</button>
<button
class="text-surface-500 hover:bg-surface-600 hover:text-surface-200 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md transition-all focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
class="text-surface-500 hover:bg-surface-600 hover:text-surface-200 can-hover:opacity-0 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md transition-all group-hover:opacity-100 focus-visible:opacity-100"
onclick={(e) => {
e.stopPropagation()
startRename(conv)
@ -1020,7 +1020,7 @@
<Pencil class="h-3 w-3" />
</button>
<button
class="text-surface-500 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md transition-all hover:bg-red-500/20 hover:text-red-400 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
class="text-surface-500 can-hover:opacity-0 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md transition-all group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400 focus-visible:opacity-100"
onclick={(e) => {
e.stopPropagation()
handleDeleteConversation(conv.id)
@ -1105,7 +1105,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
class="can-hover:opacity-0 flex items-center gap-1 transition-opacity group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<button
@ -1314,7 +1314,7 @@
</button>
{#if toolCall.imageId}
<button
class="absolute right-1.5 bottom-1.5 flex items-center gap-1 rounded-md bg-black/70 px-1.5 py-1 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/90"
class="can-hover:opacity-0 absolute right-1.5 bottom-1.5 flex items-center gap-1 rounded-md bg-black/70 px-1.5 py-1 text-[10px] text-white transition-opacity group-hover:opacity-100 hover:bg-black/90"
onclick={(e) => {
e.stopPropagation()
handleReferenceImage(toolCall.imageId!)
@ -1326,7 +1326,7 @@
</button>
{#if activeCharacterEntity}
<button
class="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded-md bg-black/70 px-1.5 py-1 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/90"
class="can-hover:opacity-0 absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded-md bg-black/70 px-1.5 py-1 text-[10px] text-white transition-opacity group-hover:opacity-100 hover:bg-black/90"
onclick={(e) => {
e.stopPropagation()
handleSetPortrait(toolCall.imageId!)

View file

@ -186,7 +186,7 @@
/>
<button
type="button"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90 absolute -top-2 -right-2 rounded-full p-1 opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90 can-hover:opacity-0 absolute -top-2 -right-2 rounded-full p-1 shadow-sm transition-opacity group-hover:opacity-100"
onclick={removePortrait}
>
<X class="h-3 w-3" />

View file

@ -34,12 +34,9 @@
const canUseDirectories = supportsDirectoryTransfer()
let exportMenuOpen = $state(false)
let replaceMenuOpen = $state(false)
// The card reveals its actions on hover, so an open menu has to hold its trigger visible.
const triggerClass = (open: boolean) =>
`h-8 w-8 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 ${open ? 'sm:opacity-100' : ''}`
const triggerClass =
'h-8 w-8 transition-opacity group-hover:opacity-100 aria-expanded:opacity-100 can-hover:opacity-0'
// The built-in pack is rewritten from the app's own templates on every launch, so it is
// never a replacement target.
@ -84,14 +81,14 @@
toggle on click opens then immediately closes the menu on touch.
-->
{#if canUseDirectories}
<DropdownMenu.Root bind:open={exportMenuOpen}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class={triggerClass(exportMenuOpen)}
class={triggerClass}
title="Export pack"
>
<Upload class="h-4 w-4" />
@ -114,7 +111,7 @@
<Button
variant="ghost"
size="icon"
class={triggerClass(false)}
class={triggerClass}
onclick={() => onExport?.()}
title="Export pack"
>
@ -123,14 +120,14 @@
{/if}
{#if canReplace && canUseDirectories}
<DropdownMenu.Root bind:open={replaceMenuOpen}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class={triggerClass(replaceMenuOpen)}
class={triggerClass}
title="Replace this pack"
>
<RefreshCw class="h-4 w-4" />
@ -155,7 +152,7 @@
<Button
variant="ghost"
size="icon"
class={triggerClass(false)}
class={triggerClass}
onclick={() => onUpdateFromFile?.()}
title="Update from file"
>
@ -167,7 +164,7 @@
<Button
variant="ghost"
size="icon"
class="text-destructive h-8 w-8 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
class="text-destructive can-hover:opacity-0 h-8 w-8 transition-opacity group-hover:opacity-100"
onclick={(e: MouseEvent) => {
e.stopPropagation()
onDelete?.()

View file

@ -371,7 +371,7 @@
</div>
</div>
<div
class="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
class="can-hover:opacity-0 flex items-center gap-0.5 transition-opacity group-hover:opacity-100"
>
<Button
variant="ghost"

View file

@ -230,7 +230,7 @@
<Button
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-destructive h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100"
class="text-muted-foreground hover:text-destructive can-hover:opacity-0 h-8 w-8 transition-opacity group-hover:opacity-100"
onclick={() => onRemoveLorebook(lorebook.id)}
title="Remove"
>

View file

@ -391,7 +391,7 @@
</div>
<div
class="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
class="can-hover:opacity-0 flex items-center gap-0.5 transition-opacity group-hover:opacity-100"
>
<Button
variant="ghost"

View file

@ -118,7 +118,7 @@
<Button
size="icon"
variant="destructive"
class="absolute -top-2 -right-2 h-6 w-6 rounded-full opacity-0 shadow-md transition-opacity group-hover:opacity-100"
class="can-hover:opacity-0 absolute -top-2 -right-2 h-6 w-6 rounded-full shadow-md transition-opacity group-hover:opacity-100"
onclick={onRemoveProtagonistPortrait}
title="Remove portrait"
>
@ -250,7 +250,7 @@
<Button
size="icon"
variant="destructive"
class="absolute -top-2 -right-2 h-5 w-5 rounded-full opacity-0 shadow-md transition-opacity group-hover:opacity-100"
class="can-hover:opacity-0 absolute -top-2 -right-2 h-5 w-5 rounded-full shadow-md transition-opacity group-hover:opacity-100"
onclick={() => onRemoveSupportingPortrait(char.name)}
title="Remove portrait"
>

View file

@ -223,7 +223,7 @@
screen has no hover to reveal it, so there it is red from the start. -->
<Button
variant="outline"
class="[@media(hover:hover)]:text-foreground w-full text-red-500 hover:text-red-500 sm:w-auto [@media(hover:hover)]:hover:text-red-500"
class="can-hover:text-foreground w-full text-red-500 hover:text-red-500 sm:w-auto"
disabled={saving}
onclick={remove}
>