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

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:
2026-08-21 21:55:46 -04:00
parent 81695fa0c8
commit 16f86bef93
5 changed files with 279 additions and 115 deletions
+7 -1
View File
@@ -2,7 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- `viewport-fit=cover` is what makes `env(safe-area-inset-*)` resolve to
anything other than 0. It opts the page into drawing under the notch and the
gesture bar, so it is only safe alongside the padding that keeps content out
of them — see style.css (sides), the sticky header (top) and the board
(bottom). The two halves ship together, deliberately: turning this on alone
is worse than leaving it off (task 2706). -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#F5C518" />
<meta
name="description"
+50 -9
View File
@@ -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>
+106 -101
View File
@@ -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>
+101
View File
@@ -8,6 +8,35 @@ body,
height: 100%;
}
/* Track the VISUAL viewport, not the layout viewport.
*
* On a phone the two diverge the moment the on-screen keyboard opens or the URL bar
* retracts: `100%` keeps the taller layout viewport, which slides the sticky header
* off screen and can park a focused field underneath the keyboard. `dvh` is the unit
* that follows the box actually being shown.
*
* Same three selectors rather than a fourth rule, so the box model is unchanged and
* only the number moves; behind @supports so a browser without `dvh` keeps the
* `100%` above instead of falling back to nothing. */
@supports (height: 100dvh) {
html,
body,
#app {
height: 100dvh;
}
}
/* Landscape on a notched phone puts the cutout down one SIDE of the page, and with
* `viewport-fit=cover` (index.html) the page is drawing under it. Applied to body so
* every view inherits it, rather than each container remembering; the insets are 0
* on every device that has no cutout, and 0 in portrait, so this costs nothing where
* it isn't needed. Top and bottom are NOT done here — a sticky header and a scrolling
* board need them in their own boxes, not on the page. */
body {
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
body {
@apply bg-neutral-50 text-neutral-900 antialiased;
}
@@ -61,6 +90,78 @@ body {
}
}
/* A note card's own controls — the drag grip and the pin/colour/archive/trash set.
* ONE element each, in TWO placements, because "always visible" and "floating over
* the card's top corners" cannot both be true without the controls sitting on top of
* the note's own words.
*
* That is exactly what a phone got. `.hover-reveal` above made the pills permanent
* where nothing can hover (task 2697, correctly — they are the only way to pin or
* archive), but they were still absolutely positioned, so they covered the title:
* "thought sync tauri app" rendered as "ught sync tauri app" with the grip parked on
* the first three characters.
*
* So the default — no hover — is a footer row IN FLOW, which cannot overlap anything
* by construction. A pointer that can hover lifts them back out into the floating
* pills they have always been, where they cost no vertical space and appear only on
* approach. `display: contents` drops the flex row itself out of the way so each
* child positions against the card.
*
* Keyed on hover rather than width, for the reason `.hover-reveal` already gives: a
* narrow window on a laptop still hovers, and a wide tablet still doesn't. */
.note-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.125rem;
margin-top: 0.5rem;
}
.note-grip {
/* Pushes the action set to the far edge, so the two read as opposite ends of a
* footer rather than as a clump. */
margin-right: auto;
}
.note-actions-set {
position: relative; /* the colour popover anchors here in both placements */
}
@media (hover: hover) {
.note-actions {
display: contents;
}
.note-grip {
position: absolute;
left: 0.375rem;
top: 0.375rem;
z-index: 10;
margin-right: 0;
}
.note-actions-set {
position: absolute;
right: 0.375rem;
top: 0.375rem;
}
}
/* The per-card colour popover, anchored to whichever end of the card the action set
* currently occupies: it opens DOWNWARD from a floating top-corner pill, and UPWARD
* from a footer row, so in both cases it grows into the card rather than off it. */
.note-swatches {
position: absolute;
right: 0;
bottom: 100%;
margin-bottom: 0.375rem;
z-index: 20;
}
@media (hover: hover) {
.note-swatches {
top: 100%;
bottom: auto;
margin-top: 0.375rem;
margin-bottom: 0;
}
}
/* Board motion (M7). Defined once here rather than three times in BoardView's
* markup, because "how the board moves" is one idea even though the pinned, other
* and non-board grids are three TransitionGroups.
+15 -4
View File
@@ -258,16 +258,27 @@ async function onDrop(targetId: string) {
</script>
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<!-- `pb-[…safe-area-inset-bottom]`: the board is the last thing on the page, so
it is what ends up underneath the gesture bar once `viewport-fit=cover` lets
the page draw there. Added to the existing py-6 rather than replacing it. -->
<div class="mx-auto w-full max-w-6xl px-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] pt-6">
<button
v-if="isMainBoard"
type="button"
class="mx-auto mb-4 block w-full max-w-xl rounded-xl border border-dashed border-neutral-300 px-4 py-2.5 text-center text-sm text-neutral-400 transition hover:border-neutral-400 hover:text-neutral-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:border-neutral-500"
@click="ui.requestCompose()"
>
Press
<kbd class="mx-0.5 rounded border border-neutral-300 px-1 text-xs dark:border-neutral-600">Enter</kbd>
or start typing to add a note
<!-- Two ways of saying the same thing, because the keyboard half is a lie on a
phone: there is no Enter key until something is already focused, and the
box has always been tappable. `hidden sm:inline` / `sm:hidden` rather than
a JS pointer check this is presentation, and it should be right in the
first paint rather than after one. -->
<span class="hidden sm:inline">
Press
<kbd class="mx-0.5 rounded border border-neutral-300 px-1 text-xs dark:border-neutral-600">Enter</kbd>
or start typing to add a note
</span>
<span class="sm:hidden">Tap to add a note</span>
</button>
<FilterBar v-if="isMainBoard" />