mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-10-19 17:43:26 +00:00
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:
parent
35a8e28523
commit
af217618b4
14 changed files with 49 additions and 44 deletions
|
|
@ -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-*) */
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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?.()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue