web: make the board usable on a phone, not just reachable
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m6s
Desktop (Tauri) / Update manifest (push) Successful in 6s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m6s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The controls a card carries were always-visible overlays on a touch device — correct as far as it went (task 2697: a finger cannot hover, and the pill is the only way to pin or archive), but they were still absolutely positioned, so they sat ON the note's own title. A card reading "thought sync tauri app" rendered as "ught sync tauri app" with the grip parked over the first three characters, and the four-icon pill covering the right half of the first line. Placement is now CSS's decision. One element each, two placements: where a pointer can hover they lift out of flow into the floating top-corner pills they have always been; where nothing can hover they stay in flow as a footer row, which cannot overlap anything by construction. Keyed on hover rather than width, for the same reason `.hover-reveal` already is — a narrow window on a laptop still hovers, a wide tablet still doesn't. The colour popover moved inside the action set so it follows it, and opens into the card from either end. The header was sharing one phone-width row between a menu button, the logo, the lens name, a search field and four icons; everything in it was truncated, the lens down to "N…" and the search box to an empty pill. It wraps now, so search takes its own line below sm, and account / settings / sign-out move into the drawer where there is room to name them rather than guess at a glyph. One input, moved by CSS — duplicating it would have meant two `searchInput` refs and a `/` shortcut that focuses the wrong one. Also closes the other half of task 2706, which was waiting on a device to look at: `viewport-fit=cover` together with the `env(safe-area-inset-*)` padding that makes it safe (sides on body, top on the sticky header, bottom on the board and the drawer), and `100dvh` behind an @supports so the app box follows the visual viewport when the keyboard opens instead of the layout viewport. Both halves in one change, as that task insisted. And the composer no longer tells a phone to "Press Enter".
This commit is contained in:
@@ -252,10 +252,18 @@ async function signOut() {
|
||||
>
|
||||
Skip to notes
|
||||
</a>
|
||||
<!-- `pt-[env(safe-area-inset-top)]`: with `viewport-fit=cover` the page draws
|
||||
under the status bar / notch, and this is the one box that sits there — a
|
||||
sticky header pinned to y=0. Resolves to 0 wherever there is no cutout, so
|
||||
it costs nothing on a desktop or a flat-topped phone. -->
|
||||
<header
|
||||
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
|
||||
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 pt-[env(safe-area-inset-top,0px)] backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<!-- Wraps, so the search field can take a line of its own on a narrow screen.
|
||||
In one row it was sharing ~360px with a menu button, the logo, the lens
|
||||
name and four icons, which left every one of them truncated — the lens read
|
||||
as "N…" and the search box as an empty pill. -->
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn sm:hidden"
|
||||
@@ -295,19 +303,24 @@ async function signOut() {
|
||||
<span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span>
|
||||
</span>
|
||||
|
||||
<div class="flex flex-1 justify-center">
|
||||
<!-- `order-last w-full` drops this onto its own line below sm (a full-width
|
||||
flex item forces the wrap); from sm it returns to the middle of the row.
|
||||
ONE input either way, moved by CSS rather than duplicated — the `/`
|
||||
shortcut focuses `searchInput`, and two of those would be one ref too
|
||||
many. -->
|
||||
<div class="order-last flex w-full justify-center sm:order-none sm:w-auto sm:flex-1">
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchText"
|
||||
type="search"
|
||||
placeholder="Search notes…"
|
||||
aria-label="Search notes"
|
||||
class="w-full max-w-md rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
|
||||
class="w-full max-w-md rounded-lg border border-neutral-300 bg-white px-3 py-2 text-base outline-none focus-visible:ring-2 focus-visible:ring-brand sm:py-1.5 sm:text-sm dark:border-neutral-700 dark:bg-neutral-900"
|
||||
@input="onSearch(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1 sm:ml-0 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-brand px-2.5 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
@@ -341,7 +354,7 @@ async function signOut() {
|
||||
<RouterLink
|
||||
v-if="!desktopApp"
|
||||
to="/account"
|
||||
class="icon-btn"
|
||||
class="icon-btn hidden sm:inline-flex"
|
||||
title="Linked devices"
|
||||
aria-label="Linked devices"
|
||||
>
|
||||
@@ -350,7 +363,7 @@ async function signOut() {
|
||||
<RouterLink
|
||||
v-if="session.user?.is_admin"
|
||||
to="/settings"
|
||||
class="icon-btn"
|
||||
class="icon-btn hidden sm:inline-flex"
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
>
|
||||
@@ -364,7 +377,7 @@ async function signOut() {
|
||||
<button
|
||||
v-if="!desktopApp"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
class="icon-btn hidden sm:inline-flex"
|
||||
title="Sign out"
|
||||
aria-label="Sign out"
|
||||
@click="signOut"
|
||||
@@ -383,7 +396,7 @@ async function signOut() {
|
||||
@click="drawer = false"
|
||||
></div>
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 dark:border-neutral-800 dark:bg-neutral-950"
|
||||
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
|
||||
:class="drawer ? 'translate-x-0' : ''"
|
||||
>
|
||||
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
|
||||
@@ -469,6 +482,34 @@ async function signOut() {
|
||||
<Icon name="download" /> Export
|
||||
</a>
|
||||
<ImportNotes />
|
||||
|
||||
<!-- Account, settings and sign-out, for the screens where they are NOT in
|
||||
the header. Four icons plus a search field never fit one phone-width
|
||||
row, and the header is the wrong place to lose: it holds the only way
|
||||
back to the board. Here they get room to be named instead of guessed
|
||||
at from a glyph.
|
||||
Hidden from sm up, where the header carries them again — so they are
|
||||
in exactly one place at any width, never both. -->
|
||||
<div
|
||||
v-if="!desktopApp"
|
||||
class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
|
||||
>
|
||||
<p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
|
||||
<RouterLink to="/account" class="nav-link" :class="route.name === 'account' ? 'nav-link-active' : ''">
|
||||
<Icon name="device" /> Linked devices
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="session.user?.is_admin"
|
||||
to="/settings"
|
||||
class="nav-link"
|
||||
:class="route.name === 'settings' ? 'nav-link-active' : ''"
|
||||
>
|
||||
<Icon name="settings" /> Settings
|
||||
</RouterLink>
|
||||
<button type="button" class="nav-link w-full text-left" @click="signOut">
|
||||
<Icon name="logout" /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -186,27 +186,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
]"
|
||||
:data-note-id="note.id"
|
||||
>
|
||||
<!-- Drag handle: reorder is gated behind this grip so a normal click or tap
|
||||
never starts a drag. Board views only (reorderable). Still a pointer-only
|
||||
affordance — there is no keyboard equivalent, hence tabindex="-1".
|
||||
`touch-none` hands the gesture to us instead of the browser's scrolling,
|
||||
and `hover-reveal` keeps the grip on screen where hovering is impossible
|
||||
(it is now reachable there, which it was not under native drag-and-drop). -->
|
||||
<button
|
||||
v-if="canDrag()"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
class="hover-reveal pointer-events-none absolute left-1.5 top-1.5 z-10 flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
|
||||
title="Drag to reorder"
|
||||
aria-label="Drag to reorder"
|
||||
@pointerdown="onGripDown"
|
||||
@pointermove="onGripMove"
|
||||
@pointerup="onGripUp"
|
||||
@pointercancel="onGripCancel"
|
||||
>
|
||||
<Icon name="grip" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
v-if="firstImage"
|
||||
:src="firstImage.url"
|
||||
@@ -342,88 +321,114 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 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.
|
||||
`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="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)">
|
||||
<Icon name="restore" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Delete forever"
|
||||
aria-label="Delete forever"
|
||||
@click="notes.deleteForever(note.id)"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Change color"
|
||||
aria-label="Change color"
|
||||
@click.stop="colorOpen = !colorOpen"
|
||||
>
|
||||
<span
|
||||
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
|
||||
:class="swatch(note.color)"
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
|
||||
:title="note.pinned ? 'Unpin' : 'Pin'"
|
||||
:aria-label="note.pinned ? 'Unpin' : 'Pin'"
|
||||
:aria-pressed="note.pinned"
|
||||
@click="notes.setPinned(note.id, !note.pinned)"
|
||||
>
|
||||
<Icon name="pin" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:title="note.archived ? 'Unarchive' : 'Archive'"
|
||||
:aria-label="note.archived ? 'Unarchive' : 'Archive'"
|
||||
@click="notes.setArchived(note.id, !note.archived)"
|
||||
>
|
||||
<Icon name="archive" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Move to trash"
|
||||
aria-label="Move to trash"
|
||||
@click="notes.trash(note.id)"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="colorOpen"
|
||||
class="absolute right-1.5 top-11 z-20 flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<!-- The card's controls: the drag grip, then the action set.
|
||||
Placement is CSS's job, not the markup's — `.note-actions` in style.css puts
|
||||
them where the device can actually use them. Where a pointer hovers they lift
|
||||
out of flow into the floating top-corner pills they have always been; where
|
||||
nothing can hover they stay here, in flow, as a footer row. The alternative
|
||||
(a permanently visible overlay) sat on top of the note's own title, which is
|
||||
what a phone was showing.
|
||||
`hover-reveal` still handles the visibility half — see style.css. -->
|
||||
<div class="note-actions">
|
||||
<!-- Reorder is gated behind this grip so a normal click or tap never starts a
|
||||
drag. Board views only (reorderable). A pointer-only affordance — there is
|
||||
no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture
|
||||
to us instead of the browser's scrolling. -->
|
||||
<button
|
||||
v-for="key in NOTE_COLOR_KEYS"
|
||||
:key="key"
|
||||
v-if="canDrag()"
|
||||
type="button"
|
||||
:title="NOTE_COLOR_LABELS[key]"
|
||||
:aria-label="NOTE_COLOR_LABELS[key]"
|
||||
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
|
||||
@click.stop="pickColor(key)"
|
||||
/>
|
||||
tabindex="-1"
|
||||
class="note-grip hover-reveal pointer-events-none flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
|
||||
title="Drag to reorder"
|
||||
aria-label="Drag to reorder"
|
||||
@pointerdown="onGripDown"
|
||||
@pointermove="onGripMove"
|
||||
@pointerup="onGripUp"
|
||||
@pointercancel="onGripCancel"
|
||||
>
|
||||
<Icon name="grip" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="note-actions-set hover-reveal pointer-events-none 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)">
|
||||
<Icon name="restore" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Delete forever"
|
||||
aria-label="Delete forever"
|
||||
@click="notes.deleteForever(note.id)"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Change color"
|
||||
aria-label="Change color"
|
||||
:aria-expanded="colorOpen"
|
||||
@click.stop="colorOpen = !colorOpen"
|
||||
>
|
||||
<span
|
||||
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
|
||||
:class="swatch(note.color)"
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
|
||||
:title="note.pinned ? 'Unpin' : 'Pin'"
|
||||
:aria-label="note.pinned ? 'Unpin' : 'Pin'"
|
||||
:aria-pressed="note.pinned"
|
||||
@click="notes.setPinned(note.id, !note.pinned)"
|
||||
>
|
||||
<Icon name="pin" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:title="note.archived ? 'Unarchive' : 'Archive'"
|
||||
:aria-label="note.archived ? 'Unarchive' : 'Archive'"
|
||||
@click="notes.setArchived(note.id, !note.archived)"
|
||||
>
|
||||
<Icon name="archive" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Move to trash"
|
||||
aria-label="Move to trash"
|
||||
@click="notes.trash(note.id)"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Inside the action set rather than beside it, so it follows the set to
|
||||
whichever corner or footer the device put it in. -->
|
||||
<div
|
||||
v-if="colorOpen"
|
||||
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<button
|
||||
v-for="key in NOTE_COLOR_KEYS"
|
||||
:key="key"
|
||||
type="button"
|
||||
:title="NOTE_COLOR_LABELS[key]"
|
||||
:aria-label="NOTE_COLOR_LABELS[key]"
|
||||
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
|
||||
@click.stop="pickColor(key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user