Web client comments describe the code to a stranger (#6222)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 1m40s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m36s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m21s
Desktop (Tauri) / Update manifest (push) Successful in 3s

Milestone 546 "Bring the repo to the comment standard (rule 32) and
compose defaults (rule 198)", step #6222 "Comment standard sweep: web
client (frontend/)".

Comments, docstrings and template notes across frontend/ now say what
the code does and the non-obvious why as present-tense facts. Issue,
task, note, milestone and family-idea references are gone, along with
narration of what used to happen: the titles index's wiki-link origin
and its once-per-session load (#5165), the card-controls overlap a
phone showed (task 2697), the M7 and M10.9 labels, and the card
unification behind the reminders lens (task 1913). Hazards stay as
facts, among them LEAVE_MS matching the editor's leave duration, the
Android client twin that must not be widened, and keeping leavers in
flow in the multi-column masonry.

62 files, comments only: a comment-stripping diff of every changed file
against HEAD is identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:17:13 -04:00
co-authored by Claude Opus 5.5
parent a51a563fe7
commit 715b648b95
62 changed files with 411 additions and 773 deletions
+3 -6
View File
@@ -2,12 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- `viewport-fit=cover` is what makes `env(safe-area-inset-*)` resolve to <!-- `viewport-fit=cover` makes `env(safe-area-inset-*)` non-zero and draws the page
anything other than 0. It opts the page into drawing under the notch and the under the notch and gesture bar. Only safe with the padding that keeps content
gesture bar, so it is only safe alongside the padding that keeps content out out of them: style.css (sides), the sticky header (top) and the board (bottom). -->
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="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#F5C518" /> <meta name="theme-color" content="#F5C518" />
<meta <meta
+4 -9
View File
@@ -1,12 +1,7 @@
// Inkwell service worker — installable shell, deliberately NOT offline-first. // Inkwell service worker: makes the web app installable and shows a page when a
// // navigation has no network. Deliberately not offline-first (the desktop and Android
// This exists so the web app satisfies the PWA install criteria ("Add to Home // apps are): it caches only the offline page, so an installed copy is always the live
// Screen") and shows a friendly page when a navigation happens with no network. // web app.
// The offline clients are the desktop and Android apps; to stay always-fresh
// and avoid deploy staleness, this SW does NOT cache the app shell, hashed build
// assets, or any /api response — everything but the offline fallback goes
// straight to the network. So an installed copy is the live web app, never an
// older one (#1919).
const CACHE = "inkwell-shell-v1"; const CACHE = "inkwell-shell-v1";
const OFFLINE_URL = "/offline.html"; const OFFLINE_URL = "/offline.html";
+6 -9
View File
@@ -1,12 +1,9 @@
// Offline desktop implementation of the repository seam: maps each operation to a // The desktop implementation of the repository seam: each operation is a Tauri
// Tauri command backed by the on-device SQLite store (desktop/src-tauri/src/local). // command over the on-device SQLite store (desktop/src-tauri/src/local).
// Selected by index.ts when running inside the desktop shell with no server.
// //
// Argument keys are camelCase; Tauri converts them to the Rust commands' snake_case // Argument keys are camelCase; Tauri converts them to the Rust commands' snake_case
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning // parameters (labelIds -> label_ids). Operations with no offline meaning (account
// yet (account auth, device linking) — those reject with a clear message // auth, device linking) reject with a clear message.
// rather than silently failing; the board, editor, attachments, capture, filters,
// labels, checklists, reminders, import and export all work fully offline.
import { invoke } from "../desktop/bridge"; import { invoke } from "../desktop/bridge";
import type { Note, NoteRevision } from "../stores/notes"; import type { Note, NoteRevision } from "../stores/notes";
@@ -58,7 +55,7 @@ export const local: Repo = {
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }), snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
setLabels: (id, labelIds) => invoke<Note>("notes_set_labels", { id, labelIds }), setLabels: (id, labelIds) => invoke<Note>("notes_set_labels", { id, labelIds }),
updateItem: (id, itemId, changes) => invoke<Note>("notes_update_item", { id, itemId, changes }), updateItem: (id, itemId, changes) => invoke<Note>("notes_update_item", { id, itemId, changes }),
// Kept on this device and uploaded by the next sync once linked (#5168). The bytes // Kept on this device and uploaded by the next sync once linked. The bytes
// go as the raw IPC body; the name is percent-encoded because a header carries // go as the raw IPC body; the name is percent-encoded because a header carries
// only ASCII. // only ASCII.
uploadAttachment: async (id, file) => uploadAttachment: async (id, file) =>
@@ -91,7 +88,7 @@ export const local: Repo = {
sendTestEmail: () => Promise.reject<{ to: string }>(new Error(NEEDS_SERVER)), sendTestEmail: () => Promise.reject<{ to: string }>(new Error(NEEDS_SERVER)),
}, },
// Sharing is between accounts on a server, so these go to the linked one (#5175). // Sharing is between accounts on a server, so these go to the linked one.
// Unlinked, each rejects with the core's explanation, which the dialog shows. // Unlinked, each rejects with the core's explanation, which the dialog shows.
shares: { shares: {
directory: () => invoke<Directory>("shares_directory"), directory: () => invoke<Directory>("shares_directory"),
+7 -11
View File
@@ -1,13 +1,9 @@
// The data-source seam. Stores/views talk to this typed repository interface // The data-source seam. Stores and views talk to this interface, so the same UI runs
// instead of reaching for the HTTP client directly, so the SAME UI can run // against the REST backend (`rest.ts`) or the desktop's on-device store (`local.ts`,
// against the REST backend (web + a server-connected desktop) or a fully-local // over Tauri `invoke`); `index.ts` picks one.
// on-device source (offline desktop). `rest.ts` implements it over `api/client`;
// `local.ts` (M10.5) implements it over Tauri `invoke`; `index.ts` picks one.
// //
// Keep this interface a thin, semantic mirror of the current calls: every method // Each method maps 1:1 to a backend operation. Client-side logic (reconciliation,
// maps 1:1 to a backend operation and returns the same shape the stores already // optimistic updates, toasts) stays in the stores; the repo is data access only.
// consume. Client-side logic (list reconciliation, optimistic updates, toasts)
// stays in the stores — the repo is data access only.
import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes"; import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels"; import type { Label } from "../stores/labels";
@@ -45,7 +41,7 @@ export interface ChecklistItemChanges {
} }
// ---- sharing (#5174) --------------------------------------------------------- // ---- sharing -----------------------------------------------------------------
/** A person on this instance, as the member directory shows them. */ /** A person on this instance, as the member directory shows them. */
export interface Member { export interface Member {
@@ -54,7 +50,7 @@ export interface Member {
email: string; email: string;
} }
/** A group the admin made, as the directory shows it (#5177). */ /** A group the admin made, as the directory shows it. */
export interface ShareGroup { export interface ShareGroup {
id: string; id: string;
name: string; name: string;
+4 -8
View File
@@ -1,8 +1,6 @@
// REST implementation of the repository seam: maps each semantic operation to the // The REST implementation of the repository seam: each operation is an HTTP endpoint
// existing HTTP endpoint via `api/client`. This is the ONLY place that knows about // via `api/client`. The only place that knows URL paths, query strings and multipart
// URL paths, query strings, and multipart bodies. Behaviour here must stay a // bodies.
// verbatim mirror of the calls the stores/views made before the seam existed — the
// web app is unchanged; the offline `local.ts` source (M10.5) is the alternative.
import { api } from "../api/client"; import { api } from "../api/client";
import type { Note, NoteRevision } from "../stores/notes"; import type { Note, NoteRevision } from "../stores/notes";
@@ -26,9 +24,7 @@ import type {
StorageUse, StorageUse,
} from "./repo"; } from "./repo";
// Render a board query to the GET /api/notes query string. Mirrors the param // Render a board query to the GET /api/notes query string.
// building that used to live in notes.store.load()/TimelineView (order-preserving;
// query-param order is irrelevant to the server, but kept close for review).
function notesQuery(q: NoteListQuery): string { function notesQuery(q: NoteListQuery): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("filter", q.view); params.set("filter", q.view);
+3 -3
View File
@@ -6,9 +6,9 @@ import { appLink } from "../router/links";
import OneTimeSecret from "./OneTimeSecret.vue"; import OneTimeSecret from "./OneTimeSecret.vue";
import { useRowAction, useLoad } from "../composables/useAction"; import { useRowAction, useLoad } from "../composables/useAction";
// Admin: the accounts on this instance, and a password reset link for any of them // Admin: the accounts on this instance, and a password reset link for any of them.
// (#5173). The admin hands the link over; a person can also mail one to themselves // The admin hands the link over; a person can also mail one to themselves when the
// when the server can send email (#5266). // server can send email.
const session = useSessionStore(); const session = useSessionStore();
+1 -1
View File
@@ -6,7 +6,7 @@ import { formatShortDateTime } from "../notes/datetime";
import { useLoad } from "../composables/useAction"; import { useLoad } from "../composables/useAction";
import ScrollSentinel from "./ScrollSentinel.vue"; import ScrollSentinel from "./ScrollSentinel.vue";
// Admin: what has happened to accounts on this instance, newest first (#2939 §5). // Admin: what has happened to accounts on this instance, newest first.
// Kept for `audit_retention_days` (Settings → Security). // Kept for `audit_retention_days` (Settings → Security).
interface AuditEvent { interface AuditEvent {
+37 -88
View File
@@ -36,18 +36,11 @@ const ui = useUiStore();
// Sync is a desktop-app concern: the web build already IS the server's UI. // Sync is a desktop-app concern: the web build already IS the server's UI.
const desktopApp = isDesktop(); const desktopApp = isDesktop();
// The build, for the dim line at the foot of the rail (#3181). // The build, for the dim line at the foot of the rail. Never blank: "unknown" when
// missing, since this is the only answer to "which build is this?".
// //
// NEVER BLANK. "unknown" is the honest answer when the value is missing, and an // In the browser it is the server's version; in the desktop shell `config_get`
// empty space is a bug that reads as a design choice. Note 3127 §5: with version // returns the desktop build's own. A linked server's version is shown on Sync.
// tags gone this is the only answer to "which build is this?", so it has to be
// either right or visibly absent.
//
// One slot, two artifacts, and that is deliberate rather than sloppy. In the
// browser `repo` is `rest`, so this is the SERVER's version; in the desktop shell
// `repo` is `local` and `config_get` returns the desktop build's own. Each surface
// names the thing the person is actually looking at. A linked server's version is
// a different question and Sync answers it separately.
const buildVersion = computed(() => config.version || "unknown"); const buildVersion = computed(() => config.version || "unknown");
const buildLabel = computed( const buildLabel = computed(
() => `Inkwell ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`, () => `Inkwell ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
@@ -55,7 +48,7 @@ const buildLabel = computed(
const managing = ref(false); const managing = ref(false);
// The rail lists the few tags in use, not all of them (#5931); "All tags" opens the // The rail lists the few tags in use, not all of them; "All tags" opens the
// rest in place, below everything else, so nothing is pushed out of view. // rest in place, below everything else, so nothing is pushed out of view.
const viewer = computed(() => session.user?.id ?? "local"); const viewer = computed(() => session.user?.id ?? "local");
const tagViews = ref<TagViews>({}); const tagViews = ref<TagViews>({});
@@ -209,16 +202,8 @@ onBeforeUnmount(() => {
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]); const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);
/** /**
* Search is a FACET, not a destination. * Search is a facet, not a destination: it writes `?q=` into the board's URL beside
* * any tags already there, so search and tag filters combine in either order.
* It used to navigate to a `/search` view backed by a different endpoint with no
* facets at all — so the one screen you landed on when you searched was the one
* screen where you could not also narrow by tag, which is precisely what tags are
* for (note 2930). Now it writes `?q=` into the board's URL, beside any labels
* already there, and the same AND-ed query serves both.
*
* Existing facets are preserved, so "filter by #grocery, then search" and the reverse
* both work.
*/ */
function onSearch(value: string) { function onSearch(value: string) {
searchText.value = value; searchText.value = value;
@@ -233,9 +218,8 @@ function onSearch(value: string) {
}, 250); }, 250);
} }
// The URL is the filter state (see notes/facets.ts), so the box READS from it rather // The URL is the filter state (notes/facets.ts), so the box reads from it rather than
// than holding its own copy — which is also what keeps it in step with the Filters // holding a copy, and the Filters panel's Clear button clears it too.
// panel's Clear button.
watch( watch(
() => route.query.q, () => route.query.q,
(q) => { (q) => {
@@ -260,26 +244,21 @@ async function signOut() {
<template> <template>
<div class="flex min-h-full flex-col"> <div class="flex min-h-full flex-col">
<!-- First tab stop on every page. The header and sidebar are a dozen-odd tab <!-- First tab stop on every page, so a keyboard user can skip the header and
stops that repeat on every navigation; without this a keyboard user walks sidebar. Hidden until focused. -->
all of them again to reach their own notes. Hidden until focused. -->
<a <a
href="#main" href="#main"
class="sr-only focus:not-sr-only focus:absolute focus:left-3 focus:top-3 focus:z-50 focus:rounded-md focus:bg-white focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-brand dark:focus:bg-neutral-900" class="sr-only focus:not-sr-only focus:absolute focus:left-3 focus:top-3 focus:z-50 focus:rounded-md focus:bg-white focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-brand dark:focus:bg-neutral-900"
> >
Skip to notes Skip to notes
</a> </a>
<!-- `pt-[env(safe-area-inset-top)]`: with `viewport-fit=cover` the page draws <!-- `pt-[env(safe-area-inset-top)]`: the page draws under the status bar, and this
under the status bar / notch, and this is the one box that sits there — a sticky header is the box that sits there. 0 where there is no cutout. -->
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 <header
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" 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"
> >
<!-- Wraps, so the search field can take a line of its own on a narrow screen. <!-- Wraps, so the search field takes a line of its own on a narrow screen
In one row it was sharing ~360px with a menu button, the logo, the lens instead of truncating everything in one row. -->
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"> <div class="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
<button <button
type="button" type="button"
@@ -306,25 +285,17 @@ async function signOut() {
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span> <span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
</RouterLink> </RouterLink>
<!-- The active lens, named in the persistent chrome rather than inside each <!-- The active lens, named in the bar that never moves, beside the app name,
view. Which lens you're looking at is a property of the SPACE, not of a so it stays put while everything beneath re-filters. -->
page you navigated to — so it sits in the bar that never moves, beside
the app name, and stays in one place while everything beneath it
re-filters. Replaces the per-view <h1>s, which sat in a different spot
in each view and were absent entirely on the board. -->
<span aria-live="polite" class="flex min-w-0 shrink items-center gap-2 text-sm text-neutral-400"> <span aria-live="polite" class="flex min-w-0 shrink items-center gap-2 text-sm text-neutral-400">
<!-- The separator only makes sense next to the app name, which is itself <!-- The separator goes with the app name, which is hidden on narrow screens. -->
hidden on narrow screens. There, the lens name simply takes the space
the app name vacated — you already know which app you're in. -->
<span aria-hidden="true" class="hidden sm:inline">/</span> <span aria-hidden="true" class="hidden sm:inline">/</span>
<span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span> <span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span>
</span> </span>
<!-- `order-last w-full` drops this onto its own line below sm (a full-width <!-- `order-last w-full` puts this on its own line below sm; from sm it returns
flex item forces the wrap); from sm it returns to the middle of the row. to the middle of the row. One input, moved by CSS, because the `/`
ONE input either way, moved by CSS rather than duplicated — the `/` shortcut focuses `searchInput`. -->
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"> <div class="order-last flex w-full justify-center sm:order-none sm:w-auto sm:flex-1">
<input <input
ref="searchInput" ref="searchInput"
@@ -346,10 +317,8 @@ async function signOut() {
> >
<Icon name="plus" /> <span class="hidden sm:inline">New</span> <Icon name="plus" /> <span class="hidden sm:inline">New</span>
</button> </button>
<!-- Whose account this is only means something when there IS an account. <!-- Not on the desktop, which signs in as a synthetic local user only so the
The desktop signs in as a synthetic local user so the shared router's router's auth guard resolves. -->
auth guard resolves; naming it would invent a login the app doesn't
have. -->
<span <span
v-if="!desktopApp" v-if="!desktopApp"
class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400" class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400"
@@ -364,10 +333,8 @@ async function signOut() {
> >
<Icon name="sync" /> <Icon name="sync" />
</RouterLink> </RouterLink>
<!-- Server-side concept: it lists the tokens a server has issued to native <!-- The tokens a server has issued to native clients. The desktop is one, so
clients. The desktop IS one of those clients, so here the list is always it has none; its own link to a server is under /sync. -->
empty and issuing a token rejects — its own relationship with a server
lives under /sync instead. -->
<RouterLink <RouterLink
v-if="!desktopApp" v-if="!desktopApp"
to="/account" to="/account"
@@ -386,11 +353,8 @@ async function signOut() {
> >
<Icon name="settings" /> <Icon name="settings" />
</RouterLink> </RouterLink>
<!-- Hidden on the desktop, where it was a trap rather than an action: <!-- Hidden on the desktop: there is no account to sign out of, and the
logout nulls the synthetic local user and redirects to /login, but the offline adapter rejects every sign-in, so there would be no way back. -->
offline adapter rejects every sign-in ("there's no account to sign in
to"), leaving no way back in short of restarting the app. There is
nothing to sign out OF — the notes are on this machine either way. -->
<button <button
v-if="!desktopApp" v-if="!desktopApp"
type="button" type="button"
@@ -444,9 +408,8 @@ async function signOut() {
<ExportNotes /> <ExportNotes />
<ImportNotes /> <ImportNotes />
<!-- Tags last: it is the one list here that grows without limit, so <!-- Tags last: the one list that grows, so everything fixed sits above it. It
everything with a fixed place sits above it and stays put. And it shows the few tags in use, the rest one press away. -->
shows only the few tags in use (#5931), the rest one press away. -->
<div class="mt-3 flex items-center justify-between px-3 pb-1"> <div class="mt-3 flex items-center justify-between px-3 pb-1">
<span class="section-label">Tags</span> <span class="section-label">Tags</span>
<button <button
@@ -483,13 +446,8 @@ async function signOut() {
{{ allTags ? "Fewer tags" : `All tags (${labels.items.length})` }} {{ allTags ? "Fewer tags" : `All tags (${labels.items.length})` }}
</button> </button>
<!-- Account, settings and sign-out, for the screens where they are NOT in <!-- Account, settings and sign-out on phone widths, where they don't fit the
the header. Four icons plus a search field never fit one phone-width header. Hidden from sm up, where the header carries them. -->
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 <div
v-if="!desktopApp" 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" class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
@@ -512,10 +470,8 @@ async function signOut() {
</div> </div>
</nav> </nav>
<!-- The build. `mt-auto` puts it at the foot of the rail when the nav is <!-- The build, at the foot of the rail (`mt-auto`). `select-all` because it
short and lets it simply follow when the nav has scrolled. gets copied into bug reports. -->
`select-all` because the one thing anybody does with this is copy it
into a bug report. -->
<p <p
class="mt-auto select-all px-3 pt-6 text-[11px] text-neutral-400 dark:text-neutral-500" class="mt-auto select-all px-3 pt-6 text-[11px] text-neutral-400 dark:text-neutral-500"
:title="buildLabel" :title="buildLabel"
@@ -524,17 +480,10 @@ async function signOut() {
</p> </p>
</aside> </aside>
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just <!-- tabindex="-1" so the skip link moves focus here, not just the viewport. -->
the viewport — several browsers scroll to a plain anchor without focusing <!-- Cross-fade between views. Unkeyed: board, archive, trash and tag views all
it, which leaves the next Tab back at the top of the page. --> render BoardView, and keying on the route would remount and refetch it.
<!-- Cross-fade between lenses so switching reads as the same space Between those lenses NoteGrid animates an in-place reflow. -->
re-framing rather than a page swap.
Deliberately UNKEYED: board / archive / trash / label all render the same
BoardView, so keying on the route would remount it — blanking the board
and refetching, which is precisely the page-change feeling this is meant
to remove. Unkeyed, Vue only transitions when the component TYPE changes
(board ↔ timeline ↔ reminders), and moving between the board's own
lenses stays an in-place reflow that NoteGrid animates. -->
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none"> <main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<Transition name="lens" mode="out-in"> <Transition name="lens" mode="out-in">
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
// The loading / error(+retry) wrapper every data view hand-rolled. While `loading` // The loading / error(+retry) wrapper for data views. While `loading`
// shows a spinner line; on `error` shows the message + a Retry button (emits // shows a spinner line; on `error` shows the message + a Retry button (emits
// `retry`); otherwise renders the default slot (the caller decides empty vs content). // `retry`); otherwise renders the default slot (the caller decides empty vs content).
defineProps<{ loading: boolean; error?: string; errorTitle?: string }>(); defineProps<{ loading: boolean; error?: string; errorTitle?: string }>();
+1 -1
View File
@@ -7,7 +7,7 @@ import { roughSize } from "../size";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
// Settings → Backups, above the fields: whether backups are happening, what is wrong // Settings → Backups, above the fields: whether backups are happening, what is wrong
// when they aren't, the two buttons, and the recent runs (#5920). Verdict first. // when they aren't, the two buttons, and the recent runs. Verdict first.
interface Run { interface Run {
started_at: string; started_at: string;
+2 -5
View File
@@ -11,11 +11,8 @@ withDefaults(
</script> </script>
<template> <template>
<!-- The look comes from `.btn` + a variant in style.css, NOT from here. A <!-- The look is `.btn` + a variant in style.css, so a download <a> can wear it too.
download has to be an <a> (only an anchor can carry an href and hand the The disabled: variants stay here, since an anchor has no :disabled. -->
transfer to the browser), so the shape has to live somewhere both elements
can wear it. The disabled: variants stay local — an anchor has no
:disabled, so they are not shared and never were. -->
<button <button
:type="type" :type="type"
:disabled="disabled || loading" :disabled="disabled || loading"
+10 -15
View File
@@ -5,13 +5,11 @@
// the panel via `panelClass` and fills it through the default slot; `title` adds the // the panel via `panelClass` and fills it through the default slot; `title` adds the
// heading row with a close button that a titled dialog wants. // heading row with a close button that a titled dialog wants.
// //
// Owns the dialog's focus contract on behalf of all four callers: focus moves in on // Owns the dialog's focus: it moves in on open, Tab is trapped inside, and focus
// open, Tab is trapped inside, and focus returns to the opener on close. Doing it // returns to the opener on close.
// here rather than per-modal is why CommandPalette's own input focus is the only
// bespoke focus handling left.
// //
// NoteEditor deliberately does NOT use this — its backdrop mousedown is drag-guarded and // NoteEditor doesn't use this: its backdrop mousedown is drag-guarded and its
// its Escape/⌘-Enter handling is bespoke (unsaved-edit safety), so it keeps its own shell. // Escape/⌘-Enter handling protects unsaved edits.
import { onBeforeUnmount, onMounted, ref } from "vue"; import { onBeforeUnmount, onMounted, ref } from "vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
@@ -54,9 +52,8 @@ function focusable(): HTMLElement[] {
/** /**
* Keep Tab inside the panel. * Keep Tab inside the panel.
* *
* `aria-modal="true"` tells assistive tech the rest of the page is inert, but it * `aria-modal="true"` tells assistive tech the rest of the page is inert but doesn't
* doesn't enforce anything — without this, Tab walks straight out into content the * enforce it.
* user has just been told isn't there.
*/ */
function onTab(e: KeyboardEvent): void { function onTab(e: KeyboardEvent): void {
const items = focusable(); const items = focusable();
@@ -80,9 +77,8 @@ function onTab(e: KeyboardEvent): void {
onMounted(() => { onMounted(() => {
restoreTo = document.activeElement as HTMLElement | null; restoreTo = document.activeElement as HTMLElement | null;
// The fix for more than tab order: Escape is handled ON the panel, so until // Escape is handled on the panel, so focus has to be inside it for Escape to work
// focus is actually inside it, Escape did nothing at all in every modal that // in modals that don't focus their own input.
// doesn't focus its own input (LabelsModal, the integration prompt, shortcuts).
const target = focusable()[0] ?? panel.value; const target = focusable()[0] ?? panel.value;
target?.focus(); target?.focus();
}); });
@@ -98,9 +94,8 @@ onBeforeUnmount(() => restoreTo?.focus?.());
:class="align === 'center' ? 'items-center' : 'items-start pt-[12vh]'" :class="align === 'center' ? 'items-center' : 'items-start pt-[12vh]'"
@mousedown.self="emit('close')" @mousedown.self="emit('close')"
> >
<!-- tabindex="-1" so the panel itself can hold focus when it contains nothing <!-- tabindex="-1" so the panel can hold focus when it contains nothing focusable;
focusable — without it, focus would stay behind the overlay and Escape, Escape is handled here. -->
which is handled here, would never fire. -->
<div <div
ref="panel" ref="panel"
class="rounded-xl border border-neutral-200 bg-white focus:outline-none dark:border-neutral-700 dark:bg-neutral-900" class="rounded-xl border border-neutral-200 bg-white focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
+15 -33
View File
@@ -1,10 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
// Every client this server holds, with the one that fits the visitor on top. // Every client this server holds, with the one that fits the visitor on top.
// //
// Five artifacts is where a downloads page turns into a table of filenames and // Leads with the download that fits the machine asking and keeps the rest visible
// stops being a product. So this LEADS with the download that fits the machine // but quiet, so a wrong guess costs nothing.
// asking and keeps the rest quiet but visible — nothing is behind a disclosure,
// because a wrong guess must cost a person nothing.
import { computed } from "vue"; import { computed } from "vue";
import { useConfigStore, type ClientRelease } from "../stores/config"; import { useConfigStore, type ClientRelease } from "../stores/config";
import { roughSize } from "../size"; import { roughSize } from "../size";
@@ -16,13 +14,10 @@ type Family = "android" | "windows" | "linux" | "mac" | "ios" | "other";
/** /**
* Which OS is asking, from the user agent. * Which OS is asking, from the user agent.
* *
* ORDER IS THE WHOLE ALGORITHM. Android's UA contains "Linux", an iPad's contains * Order matters: Android's UA contains "Linux", an iPad's "Mac OS X" and a
* "Mac OS X", and a Chromebook's contains "X11" — so each narrow test has to run * Chromebook's "X11", so each narrow test runs before the broad one.
* before the broad one that would otherwise swallow it.
* *
* `navigator.userAgent` rather than `userAgentData`: the reduced UA Chrome now * `navigator.userAgent`, since Chrome's reduced UA still carries the platform token.
* sends still carries the platform token, which is the only thing being asked
* for, and one code path beats two for a guess that is allowed to be wrong.
*/ */
function detectFamily(ua: string): Family { function detectFamily(ua: string): Family {
if (/Android/i.test(ua)) return "android"; if (/Android/i.test(ua)) return "android";
@@ -37,12 +32,8 @@ function detectFamily(ua: string): Family {
// What to lead with per family, in the order someone on it should see them. // What to lead with per family, in the order someone on it should see them.
// //
// Linux gets all three because the UA says "Linux" and nothing about dpkg or // Linux gets all three: the UA doesn't say which distro. macOS and iOS lead with
// pacman — there is no more specific answer to be had, so the three are named for // nothing, as there is no build for either (see `missingPlatform`).
// the DISTRO a person knows rather than the package format they may not.
//
// macOS and iOS lead with nothing. There is no build for either, and an empty
// lead is the honest way to say so — see `missingPlatform` below.
const LEAD: Record<Family, string[]> = { const LEAD: Record<Family, string[]> = {
android: ["android"], android: ["android"],
windows: ["windows"], windows: ["windows"],
@@ -102,10 +93,9 @@ const missingPlatform = computed(() =>
!lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "", !lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "",
); );
// The one-command Linux install (milestone 325 step 5), shown wherever the server // The one-command Linux install, shown when the server holds a Linux client. Uses the
// holds a Linux client to install. Against the address this page was reached on, // address this page was reached on; the script itself carries Settings → Public
// which is the one the person can evidently reach; the script the server sends // address when that is set.
// back carries Settings → Public address when that is set.
const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-"))); const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-")));
const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`; const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`;
@@ -134,25 +124,17 @@ const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`;
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm text-neutral-800 dark:text-neutral-100">{{ client.label }}</p> <p class="text-sm text-neutral-800 dark:text-neutral-100">{{ client.label }}</p>
<p class="mt-0.5 text-xs text-neutral-400"> <p class="mt-0.5 text-xs text-neutral-400">
<!-- `unknown` rather than a blank or a plausible default: with no <!-- `unknown` rather than a blank or a plausible default; the download
second source to contradict it, a wrong version here is a wrong is offered either way. -->
answer nothing can catch. Not knowing which build it is, is also
not a reason to withhold the download. -->
Version {{ client.version || "unknown" }} · {{ roughSize(client.size, "MB") }}<span Version {{ client.version || "unknown" }} · {{ roughSize(client.size, "MB") }}<span
v-if="client.platform === 'linux-appimage'" v-if="client.platform === 'linux-appimage'"
>, and the only one that updates itself in place</span >, and the only one that updates itself in place</span
> >
</p> </p>
</div> </div>
<!-- An anchor, never BaseButton and never a fetch: these are 3–95 MB and <!-- An anchor, not a fetch: the browser's download manager handles 3–95 MB
the browser's own download manager handles the transfer better than better than a blob. It wears `.btn`, like BaseButton. `download` has no
anything this app would do with a blob. It wears `.btn` — the same filename because the server's Content-Disposition names the file. -->
definition BaseButton wears, so the two cannot drift.
`download` carries no filename because the server already names the
file in its Content-Disposition, which browsers prefer over this
attribute anyway — a value here would be inert and read as if it
weren't. -->
<a <a
:href="client.url" :href="client.url"
download download
+1 -4
View File
@@ -6,10 +6,7 @@ import { useUiStore } from "../stores/ui";
import { useAction } from "../composables/useAction"; import { useAction } from "../composables/useAction";
// The applications-menu entry for a Linux AppImage: add it, or take it away again. // The applications-menu entry for a Linux AppImage: add it, or take it away again.
// Renders nothing anywhere else — the web, Windows, or an AppImage-less Linux build. // Renders nothing anywhere else: the web, Windows, or an AppImage-less Linux build.
//
// It lived on the Account page, which the desktop app never shows: a toggle the
// first-run prompt promised ("undo any time") with no way to reach it.
const ui = useUiStore(); const ui = useUiStore();
const status = ref<IntegrationStatus | null>(null); const status = ref<IntegrationStatus | null>(null);
const { busy, act } = useAction(); const { busy, act } = useAction();
+1 -1
View File
@@ -9,7 +9,7 @@ import Icon from "./Icon.vue";
import { useRowAction, useLoad } from "../composables/useAction"; import { useRowAction, useLoad } from "../composables/useAction";
import { counted } from "../counted"; import { counted } from "../counted";
// Admin: groups of people a note can be shared with in one go (#5177). A group is // Admin: groups of people a note can be shared with in one go. A group is
// the instance's; anyone can share with it, and a note shared with it reaches whoever // the instance's; anyone can share with it, and a note shared with it reaches whoever
// is in it at the time, so adding or removing someone here changes what they see. // is in it at the time, so adding or removing someone here changes what they see.
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
// Tiny inline icon set (lucide paths) so we don't pull an icon dependency in M1. // A small inline icon set (lucide paths), with no icon dependency.
defineProps<{ name: string }>(); defineProps<{ name: string }>();
const paths: Record<string, string> = { const paths: Record<string, string> = {
+2 -2
View File
@@ -9,7 +9,7 @@ import OneTimeSecret from "./OneTimeSecret.vue";
import { toastOnFailure, useLoad } from "../composables/useAction"; import { toastOnFailure, useLoad } from "../composables/useAction";
import { useConfigStore } from "../stores/config"; import { useConfigStore } from "../stores/config";
// Admin: invite one person to register while registration stays closed (#5172). // Admin: invite one person to register while registration stays closed.
// The server keeps only a hash of each invite's token, so the link is shown once, // The server keeps only a hash of each invite's token, so the link is shown once,
// right after it is made — the same contract as a device token on Linked devices. // right after it is made — the same contract as a device token on Linked devices.
@@ -41,7 +41,7 @@ const creating = ref(false);
const freshLink = ref(""); const freshLink = ref("");
const freshNote = ref(""); const freshNote = ref("");
// Emailing the link (#5917) needs email set up and an address to send it to. The // Emailing the link needs email set up and an address to send it to. The
// server says whether email is on in the same flag the sign-in page reads. // server says whether email is on in the same flag the sign-in page reads.
const config = useConfigStore(); const config = useConfigStore();
const sendEmail = ref(true); const sendEmail = ref(true);
+4 -7
View File
@@ -2,14 +2,11 @@
import type { InlineToken } from "../notes/markdown"; import type { InlineToken } from "../notes/markdown";
import { tagTextClasses } from "../notes/colors"; import { tagTextClasses } from "../notes/colors";
// Emphasis, code, and `#tags`. `[[wiki-links]]` were the one token type that needed a // Emphasis, code, and `#tags`.
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
// of that.
// //
// `tagColors` maps a lowercased tag name to the colour stored on that label. Threaded // `tagColors` maps a lowercased tag name to its label's colour, passed down from the
// down from the card rather than looked up here, because this component renders text // card (this component doesn't know which note it renders), so an inline tag matches
// and has no idea which note the text belongs to — and a tag the operator recoloured // its chip.
// must read the same here as it does on a chip.
defineProps<{ tokens: InlineToken[]; tagColors?: Record<string, string> }>(); defineProps<{ tokens: InlineToken[]; tagColors?: Record<string, string> }>();
</script> </script>
+2 -4
View File
@@ -10,10 +10,8 @@ const props = defineProps<{
toggleable?: boolean; toggleable?: boolean;
tagColors?: Record<string, string>; tagColors?: Record<string, string>;
}>(); }>();
// Ticking a box rewrites a line of the note's body, which is a thing only the owner // Ticking a box rewrites a line of the body, so this emits the intent rather than
// of that note can do — so this renders the checkbox and hands the intent up rather // touching the store. The card wires it; without `toggleable` the boxes are inert.
// than reaching for the store itself. The card wires it; a read-only render does not
// pass `toggleable` and the boxes are inert.
const emit = defineEmits<{ toggle: [index: number, checked: boolean] }>(); const emit = defineEmits<{ toggle: [index: number, checked: boolean] }>();
const blocks = computed(() => parseMarkdown(props.text)); const blocks = computed(() => parseMarkdown(props.text));
</script> </script>
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Note } from "../stores/notes";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
// A note's own actions, as the card and the editor both offer them. Pin and archive // A note's own actions, as the card and the editor both offer them. Pin and archive
// are everyone's own (#5176); share and trash are the owner's. A note shared with // are everyone's own; share and trash are the owner's. A note shared with
// you is never in your Trash, so restore and delete forever are the owner's too. // you is never in your Trash, so restore and delete forever are the owner's too.
// The default slot sits after Share, for the editor's history toggle. // The default slot sits after Share, for the editor's history toggle.
const props = defineProps<{ note: Note; own: boolean; canShare: boolean }>(); const props = defineProps<{ note: Note; own: boolean; canShare: boolean }>();
+46 -113
View File
@@ -38,10 +38,9 @@ const emit = defineEmits<{
const notes = useNotesStore(); const notes = useNotesStore();
const config = useConfigStore(); const config = useConfigStore();
// --- Sharing (#5174). Someone else's note shows who shared it and offers none of // --- Sharing. Someone else's note shows who shared it and offers none of the
// the owner's controls; at `view` its checkboxes are inert too. The desktop offers // owner's controls; at `view` its checkboxes are inert too. On an unlinked desktop
// Share as well: linked it goes through the server, unlinked the dialog says sharing // the Share dialog says sharing needs a server. ---
// needs one (#5175). ---
const own = computed(() => isOwnNote(props.note)); const own = computed(() => isOwnNote(props.note));
const editable = computed(() => canEditText(props.note)); const editable = computed(() => canEditText(props.note));
const canShare = own; const canShare = own;
@@ -53,9 +52,8 @@ const sharedLine = computed(() => {
return props.note.shared ? "Shared" : ""; return props.note.shared ? "Shared" : "";
}); });
// --- Retention countdown. A note in Trash is on a clock, and the card is the only // --- Retention countdown, so someone browsing Trash can restore a note in time.
// place someone browsing Trash would ever find that out in time to restore it. // Null when not trashed or retention is off. ---
// Null whenever nothing is going to happen: not trashed, or retention turned off. ---
const trashDays = computed(() => const trashDays = computed(() =>
props.note.trashed ? trashDaysLeft(props.note.deleted_at, config.trashRetentionDays) : null, props.note.trashed ? trashDaysLeft(props.note.deleted_at, config.trashRetentionDays) : null,
); );
@@ -67,26 +65,18 @@ const trashUrgent = computed(() => trashDays.value !== null && trashDays.value <
const firstImage = computed(() => props.note.attachments.find((a) => rendersInline(a.mime))); const firstImage = computed(() => props.note.attachments.find((a) => rendersInline(a.mime)));
const otherAttachments = computed(() => props.note.attachments.filter((a) => !rendersInline(a.mime))); const otherAttachments = computed(() => props.note.attachments.filter((a) => !rendersInline(a.mime)));
// How much of a note the CARD shows. Android has always clamped to 8 // How much of a note the card shows: 8 lines, as on Android (`MAX_PREVIEW_LINES`),
// (`MAX_PREVIEW_LINES`); the web rendered the whole body, so one long note could // so one long note can't push the board off the screen.
// produce a card taller than the screen and push everything else off the board.
// //
// It matters more now that the title is gone (M13 step 4). The first line used to be // Clamped in the string rather than with CSS `line-clamp`, which behaves unreliably
// the thing your eye caught; with one weight throughout, an unbounded card is just a // around the block elements MarkdownText emits, and skips parsing what isn't shown.
// wall, and the note next to it is the one you were looking for.
//
// Clamped in the STRING rather than with CSS `line-clamp`, which needs a
// `-webkit-box` and behaves unreliably around the block elements MarkdownText emits
// (lists, quotes, fenced code). This is deterministic, matches Android's semantics
// exactly, and skips parsing a body the card was never going to show.
const PREVIEW_LINES = 8; const PREVIEW_LINES = 8;
// --- Links ------------------------------------------------------------------ // --- Links ------------------------------------------------------------------
// //
// A note that is NOTHING but a URL is a link, and its preview is the whole card — // A note that is nothing but a URL shows its preview as the whole card, without the
// showing the raw URL underneath a card that already says where it goes is saying the // raw URL. A URL inside a note gets a compact strip at the bottom. What counts as
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and // lone is notes/links.ts.
// gets a compact strip at the bottom instead. What counts as lone is notes/links.ts.
const isLoneUrl = computed(() => loneUrl(props.note.body) !== null && !props.note.items.length); const isLoneUrl = computed(() => loneUrl(props.note.body) !== null && !props.note.items.length);
/** The preview for a lone-URL note — null while it is still being fetched, or if it /** The preview for a lone-URL note — null while it is still being fetched, or if it
@@ -106,8 +96,8 @@ const bodyPreview = computed(() => {
/** Tick a box without opening the note — the common gesture on a board. /** Tick a box without opening the note — the common gesture on a board.
* *
* The index is the item's ordinal in the WHOLE body, which survives the preview * The index is the item's ordinal in the WHOLE body, which survives the preview
* clamp above because that only ever drops lines from the end. `updateItem` takes * clamp above because that only drops lines from the end. `updateItem` takes it as
* it as the item id, which is exactly what an id is now (M304). */ * the item id. */
function toggleTask(index: number, checked: boolean) { function toggleTask(index: number, checked: boolean) {
void notes.updateItem(props.note.id, String(index), { checked }); void notes.updateItem(props.note.id, String(index), { checked });
} }
@@ -118,11 +108,9 @@ const root = ref<HTMLElement | null>(null);
// plain tap/click/select never starts a drag by accident. `dragging` dims the // plain tap/click/select never starts a drag by accident. `dragging` dims the
// source card; `dragOver` shows where the drop will land. // source card; `dragOver` shows where the drop will land.
// //
// Pointer rather than native HTML5 drag-and-drop because that API never fires from // Pointer events rather than HTML5 drag-and-drop, which never fires from touch.
// touch — on a phone this did nothing at all. One code path now covers mouse, touch // composables/useCardDrag.ts says why the state is shared. ---
// and stylus. See composables/useCardDrag.ts for why the state is shared. --- // A shared note moves on the recipient's board only, so anyone may drag it.
// Board order is the owner's; a shared note's place is the recipient's from #5176.
// A shared note moves on the recipient's board only (#5176), so anyone may drag it.
const canDrag = () => !!props.reorderable && !props.note.trashed; const canDrag = () => !!props.reorderable && !props.note.trashed;
const dragging = computed(() => draggingId.value === props.note.id); const dragging = computed(() => draggingId.value === props.note.id);
const dragOver = computed(() => overId.value === props.note.id); const dragOver = computed(() => overId.value === props.note.id);
@@ -140,9 +128,8 @@ function onGripDown(e: PointerEvent) {
startX = e.clientX; startX = e.clientX;
startY = e.clientY; startY = e.clientY;
moved = false; moved = false;
// Capture so the gesture keeps reporting to this grip even once the finger has // Capture, so the gesture keeps reporting to this grip once the finger is over
// travelled onto another card — without it the stream stops at the first // another card.
// boundary crossing.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Claims the gesture from the browser's own scroll/text-selection handling. // Claims the gesture from the browser's own scroll/text-selection handling.
e.preventDefault(); e.preventDefault();
@@ -193,15 +180,12 @@ watch(
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by // Only the tags the body isn't showing: `via_tag` means "backed by text in the note",
// text still in the note" since M311, so a chip for one printed the same tag twice — // and that tag is tinted where it sits (MarkdownInline). The chips are tags lifted off
// once where it was typed, once in this row — and the loud copy was the duplicate. A // their own line and labels added through the picker.
// tag left in prose is tinted where it sits instead (MarkdownInline). What survives
// here is what the body cannot say: a tag lifted off its own line, and a label added
// through the picker.
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag)); const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
// The colour the operator stored for each of this note's tags, keyed by folded // The colour stored for each of this note's tags, keyed by folded
// name — what MarkdownInline needs to tint a `#tag` the same as its chip would be. // name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
// Folded because `#Todo` and `#todo` are one tag. // Folded because `#Todo` and `#todo` are one tag.
const tagColors = computed<Record<string, string>>(() => { const tagColors = computed<Record<string, string>>(() => {
@@ -226,52 +210,16 @@ const tagColors = computed<Record<string, string>>(() => {
]" ]"
:data-note-id="note.id" :data-note-id="note.id"
> >
<!-- THE EDGE IS THE CARD'S BOUNDARY, and since M315 it is the ONLY thing that <!-- The edge is the card's boundary: the fill is one neutral (NOTE_CARD_SURFACE),
varies from the board: the fill is one neutral (NOTE_CARD_SURFACE) and no so this line is what separates a card from the board. #b8b8b8 on white is
longer says anything about the note. That makes this line load-bearing rather 1.98:1 and #404040 on #171717 is 1.73:1. An opaque grey, not a translucent
than decorative — it is what a card IS. edge, so Android can state the same hex. `shadow-sm` is only depth. -->
<!-- Tags first: a board is scanned, and tags say what a note is about. Above the
It was already neutral before the fill was. The version that came from the body rather than beside it, so they don't compete with its first line, the
palette was a `{hue}-900` border and failed twice over: the line was the note's name. -->
loudest element on the card (1.56-2.09 against its own fill, where the fill
managed 1.03-1.05 against the board) AND carried the same information the fill
did, so a board of them read as a grid of outlines. A neutral line carries no
information at all, which is exactly what lets it be structure. The fill is
the same argument one size up, made two milestones later.
MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #b8b8b8 on
white is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these
values already shipped at across twenty fills (light 1.57-1.98, dark
1.58-1.73), but at the top of them rather than the ~1.6-1.7 the pair was
originally matched on. Softening the light edge to re-match would weaken the
only boundary a white card on a #fafafa board has, and the complaint that
started M315 was about fill, never about edge weight. If an operator pass
disagrees it is one constant, in two files.
NOT a translucent black/white edge, which is the tidier-looking way to do this
and was measured and rejected: a border composites over what is under it, so
`border-white/20` came out #56396d on a purple card and #a3c9c1 on a teal one.
With one fill that argument no longer bites — but an opaque grey is what the
Android side must also write, and two surfaces stating the same hex is how
they stay the same card.
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the
shadow is only depth. -->
<!-- TAGS FIRST. They used to sit under everything else, which on a tall note put
the one thing that says what a note IS below the fold of a glance. A board is
scanned, not read, and the answer to "which of these is about the thing I am
looking for" should be the first thing the eye lands on rather than the last.
Above the image and the body rather than beside them, because the body's first
line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
compete with the thing that identifies the note. -->
<div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1"> <div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
<!-- Every chip carries the `#`, not just the ones derived from body text. That <!-- Every chip carries the `#`, so a lifted chip reads as the `#todo` somebody
branch used to distinguish a `#tag` from a picker label; it cannot any more, typed, as on Android. -->
because a tag whose text is still in the body no longer reaches this row at
all. What is left is all the same thing to the eye and to the vocabulary —
and the hash is what keeps a lifted chip reading as the `#todo` somebody
typed. Android's row says the same, which it did not before. -->
<span <span
v-for="lb in chipLabels" v-for="lb in chipLabels"
:key="lb.id" :key="lb.id"
@@ -302,10 +250,8 @@ const tagColors = computed<Record<string, string>>(() => {
</div> </div>
<!-- One render path: every note is a body plus, maybe, checkable items. <!-- A focusable div rather than a <button>, because a checklist nests interactive
A focusable div rather than a <button>, because a checklist nests interactive controls, which can't live inside a button. -->
controls and those cannot live inside a button — and the card is the same
shape whether or not it happens to carry items today. -->
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
@@ -313,9 +259,8 @@ const tagColors = computed<Record<string, string>>(() => {
@click="emit('open', note)" @click="emit('open', note)"
@keydown.enter="emit('open', note)" @keydown.enter="emit('open', note)"
> >
<!-- A lone URL renders as its preview and nothing else. Until the fetch lands <!-- A lone URL renders as its preview. Until the fetch lands, or if it never
— or if it never does — the URL itself stands in, so the card is never does, the URL itself stands in. -->
blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" /> <LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300"> <div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText <MarkdownText
@@ -332,17 +277,13 @@ const tagColors = computed<Record<string, string>>(() => {
Empty note Empty note
</p> </p>
</div> </div>
<!-- Inline links: a compact strip at the FOOT of the card, under the note's own <!-- Inline links: a compact strip at the foot of the card, under the note's own
words rather than stacked on top of them. They were above the body until words. -->
M13 — which put a stranger's headline where the note's first line should be. -->
<div v-if="!isLoneUrl && note.previews.length" class="mt-2 flex flex-col gap-1"> <div v-if="!isLoneUrl && note.previews.length" class="mt-2 flex flex-col gap-1">
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact /> <LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
</div> </div>
<!-- No separate checklist block any more. A checklist is lines of the body (M304), <!-- A checklist is lines of the body, drawn in place by MarkdownText above. -->
so MarkdownText above draws it in place — which is what lets a list sit between
two paragraphs instead of always after them. Rendering both would have shown
every list twice. -->
<div v-if="sharedLine" class="mt-2"> <div v-if="sharedLine" class="mt-2">
<span <span
@@ -376,9 +317,7 @@ const tagColors = computed<Record<string, string>>(() => {
<polyline points="12 6 12 12 16 14" /> <polyline points="12 6 12 12 16 14" />
</svg> </svg>
{{ formatShortDateTime(note.remind_at) }} {{ formatShortDateTime(note.remind_at) }}
<!-- Recurrence was only ever shown in the reminders list; carried onto the <!-- Recurrence, so a repeating note reads as repeating on the board. -->
card so unifying them didn't quietly drop it, and so a repeating note
reads as repeating on the board too. -->
<span v-if="note.recurrence" :title="`Repeats ${note.recurrence}`">· ↻ {{ note.recurrence }}</span> <span v-if="note.recurrence" :title="`Repeats ${note.recurrence}`">· ↻ {{ note.recurrence }}</span>
</span> </span>
@@ -411,19 +350,13 @@ const tagColors = computed<Record<string, string>>(() => {
</span> </span>
</div> </div>
<!-- The card's controls: the drag grip, then the action set. <!-- The card's controls: the drag grip, then the action set. `.note-actions` in
Placement is CSS's job, not the markup's — `.note-actions` in style.css puts style.css places them: floating top-corner pills where a pointer hovers, a
them where the device can actually use them. Where a pointer hovers they lift footer row where nothing can. `hover-reveal` handles visibility. -->
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"> <div class="note-actions">
<!-- Reorder is gated behind this grip so a normal click or tap never starts a <!-- Reorder is gated behind this grip so a click or tap never starts a drag.
drag. Board views only (reorderable). A pointer-only affordance — there is Board views only. Pointer-only, hence tabindex="-1". `touch-none` takes the
no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture gesture from the browser's scrolling. -->
to us instead of the browser's scrolling. -->
<button <button
v-if="canDrag()" v-if="canDrag()"
type="button" type="button"
+36 -68
View File
@@ -49,11 +49,9 @@ const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void { function setBody(text: string): void {
blocks.value = splitBlocks(text); blocks.value = splitBlocks(text);
} }
// The tag row. Refreshed from every note the server hands back, not only taken when // The tag row, refreshed from every note the server hands back: a `#tag` typed into
// the editor opens: a `#tag` typed into the body becomes a tag on the SERVER's side of // the body becomes a tag on the server's side of a save. Not the store's copy, which
// a save, so the copy taken at mount never learns of it (#5913). The store's own copy // drops a note that leaves the current lens on save.
// can't stand in for this, because a note that leaves the current lens on save (a tag
// removed while looking at that tag) is dropped from the store.
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []); const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
function takeLabels(saved: Note): void { function takeLabels(saved: Note): void {
labelList.value = [...saved.labels]; labelList.value = [...saved.labels];
@@ -124,16 +122,16 @@ const liveNote = computed<Note>(() =>
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value) ? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value, : draftNote.value,
); );
// The one hint that `#` does anything, short of typing one (Scribe #2949). // The one hint that `#` does anything, short of typing one.
const bodyPlaceholder = "Take a note… # to tag it"; const bodyPlaceholder = "Take a note… # to tag it";
// Sharing (#5174). Someone else's note: at `edit` its text and checklist are theirs to // Sharing. Someone else's note: at `edit` its text and checklist are theirs to
// change and nothing else is; at `view` it is read-only. The server enforces both — // change and nothing else is; at `view` it is read-only. The server enforces both —
// this only stops the editor offering what would be refused. // this only stops the editor offering what would be refused.
const own = computed(() => isOwnNote(liveNote.value)); const own = computed(() => isOwnNote(liveNote.value));
const editable = computed(() => canEditText(liveNote.value)); const editable = computed(() => canEditText(liveNote.value));
// On the desktop too: linked, it shares through the server; unlinked, the dialog // On the desktop too: linked, it shares through the server; unlinked, the dialog
// says sharing needs one (#5175). // says sharing needs one.
const canShare = computed(() => own.value && !isCreate.value); const canShare = computed(() => own.value && !isCreate.value);
const sharedLine = computed(() => { const sharedLine = computed(() => {
if (own.value) return ""; if (own.value) return "";
@@ -198,15 +196,9 @@ async function flush(): Promise<void> {
// ---- idle autosave ---- // ---- idle autosave ----
// //
// This editor used to write ONLY on close, and the reason was cost: a body write // Writes on an idle pause, so a tab closed mid-paragraph keeps the paragraph. Both
// snapshotted a revision, so saving often meant a version history of thirty // engines keep one revision per editing session (`revisions.py`, `store.rs`'s
// snapshots of one paragraph being typed. The price was durability — a tab closed // `should_snapshot`), so frequent writes don't flood the history.
// mid-paragraph lost the paragraph, which is the one thing a notes app must not do.
//
// That trade is gone. Both engines now coalesce snapshots to one per editing
// session (`revisions.py` and `store.rs`'s `should_snapshot`, Scribe #2971), so a
// write costs a write. Writing on an idle pause is what collects the refund; the
// Android editor already does the same.
const AUTOSAVE_MS = 1000; const AUTOSAVE_MS = 1000;
let autosaveTimer: ReturnType<typeof setTimeout> | null = null; let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
@@ -218,9 +210,8 @@ function cancelAutosave(): void {
} }
async function autosave(): Promise<void> { async function autosave(): Promise<void> {
// `flush` returns without writing while a save is in flight, which would silently // `flush` doesn't write while a save is in flight, so re-arm rather than skip, or
// drop everything typed since that save began. Re-arming rather than skipping is // everything typed since that save began would be lost.
// what keeps that from being a lost paragraph.
if (saving.value) { if (saving.value) {
scheduleAutosave(); scheduleAutosave();
return; return;
@@ -228,9 +219,8 @@ async function autosave(): Promise<void> {
try { try {
await flush(); await flush();
} catch { } catch {
// Swallowed on purpose. An autosave that interrupts typing with an error is // Swallowed: an autosave error shouldn't interrupt typing, and `close` still
// worse than one that waits for the next pause, and `close` still surfaces a // surfaces a real failure.
// real failure at the moment the person is looking at the editor.
} }
} }
@@ -242,11 +232,8 @@ function scheduleAutosave(): void {
}, AUTOSAVE_MS); }, AUTOSAVE_MS);
} }
// EDIT mode only, deliberately. In compose, `dismiss` discards a note that was // Edit mode only. In compose, `dismiss` discards a note that was never persisted, so
// never persisted, so that an accidental keystroke or a type-to-compose never // a stray keystroke never litters the board; an autosave would create the row.
// litters the board — and an autosave that created the row would take that away
// without anyone asking for it. Materialising a compose on first keystroke is a
// separate decision (Scribe #2967), not a side effect of this one.
watch(body, () => { watch(body, () => {
if (!isCreate.value) scheduleAutosave(); if (!isCreate.value) scheduleAutosave();
}); });
@@ -272,14 +259,12 @@ async function commitAndContinue(): Promise<void> {
const first = blocks.value[0]; const first = blocks.value[0];
await focusBlock(first ? first.id : null); await focusBlock(first ? first.id : null);
} }
// ---- open/close animation (M7) ---- // ---- open/close animation ----
// //
// Owned here rather than by each of the five views that render this component: the // Owned here, not by the hosting views: the leave plays before the host unmounts us,
// leave has to play BEFORE the host unmounts us, so the component has to control // so the component controls its own visibility and tells the host afterwards.
// its own visibility and tell the host afterwards. // Starts true, with `appear` driving the entry; the panel lives inside this v-if, so
// Starts TRUE, with `appear` driving the entry animation. Starting false and // it wouldn't exist yet to measure on mount.
// flipping it on mount would be the obvious shape, but the panel lives inside this
// v-if — it wouldn't exist yet to measure.
const visible = ref(true); const visible = ref(true);
const panel = ref<HTMLElement | null>(null); const panel = ref<HTMLElement | null>(null);
/** Matches .editor-leave-active in style.css. */ /** Matches .editor-leave-active in style.css. */
@@ -287,15 +272,12 @@ const LEAVE_MS = 140;
onMounted(() => { onMounted(() => {
const from = takeMorphOrigin(); const from = takeMorphOrigin();
// Grow from the card that was clicked. Set as a transform-origin rather than // Grow from the card that was clicked, as a transform-origin (see useEditorMorph).
// animating between two rects — see useEditorMorph for why. No origin (compose, // With no origin it grows from its own centre.
// or a card that scrolled out of view) simply grows from its own centre.
if (!from || !panel.value) return; if (!from || !panel.value) return;
// offsetLeft/offsetTop, NOT getBoundingClientRect: the enter-from class has // offsetLeft/offsetTop, not getBoundingClientRect, which would measure the panel
// already applied scale(0.94) by now, so the bounding rect is of the SHRUNKEN // already scaled to 0.94. The offset parent is the inset-0 backdrop, so these are
// panel and the origin would land a few pixels off. Offsets are layout geometry // viewport coordinates, like the captured point.
// and ignore transforms. The offset parent is the inset-0 backdrop, so these are
// effectively viewport coordinates — which is what the captured point is in.
panel.value.style.transformOrigin = panel.value.style.transformOrigin =
`${from.x - panel.value.offsetLeft}px ${from.y - panel.value.offsetTop}px`; `${from.x - panel.value.offsetLeft}px ${from.y - panel.value.offsetTop}px`;
}); });
@@ -313,9 +295,8 @@ async function finish(): Promise<void> {
// Persist (create in compose, save in edit) and close the editor. // Persist (create in compose, save in edit) and close the editor.
async function close(): Promise<void> { async function close(): Promise<void> {
// Cancelled first: a timer that fires during the leave animation would write // Cancelled first, or a timer firing during the leave would write again after
// through a component on its way out, after `flush` has already saved the same // `flush` saved.
// text.
cancelAutosave(); cancelAutosave();
await flush(); await flush();
await finish(); await finish();
@@ -557,13 +538,9 @@ async function removeLabel(id: string) {
} }
// ---- add a checklist ---- // ---- add a checklist ----
// //
// Appends an empty item and puts the caret in it. Unlike every other toolbar button // Appends an empty item and puts the caret in it. Needs no persisted note, since a
// this one needs NO persisted note to hang anything off — a checklist is part of the // checklist is part of the body. Appends rather than inserting at the caret, because
// body (M304), so it works on an empty compose box the moment it opens. // a block editor has no single caret.
//
// Appends rather than inserting at the caret because a block editor has no single
// caret to insert at: the field that had focus may not be the one being looked at by
// the time this runs.
function addChecklist(): void { function addChecklist(): void {
const next = plusTask(blocks.value); const next = plusTask(blocks.value);
blocks.value = next.blocks; blocks.value = next.blocks;
@@ -660,9 +637,8 @@ function revPreview(rev: NoteRevision): string {
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]" class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
@mousedown.self="onBackdropMousedown" @mousedown.self="onBackdropMousedown"
> >
<!-- One editor card for BOTH compose (empty note) and edit — a single surface. <!-- One editor card for compose and edit. `editor-panel` is the animation's
`editor-panel` is the animation's handle: the backdrop only fades, while handle: the backdrop fades while this scales from the card that opened it. -->
this scales from the card that opened it (see useEditorMorph). -->
<div <div
ref="panel" ref="panel"
class="editor-panel w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900" class="editor-panel w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
@@ -751,10 +727,8 @@ function revPreview(rev: NoteRevision): string {
device until you remove it. device until you remove it.
</p> </p>
<!-- The server unfurls a note's URLs in the background after each save <!-- The server unfurls a note's URLs after each save and the preview arrives
(`unfurl_queue.py`) and the preview arrives on a later read. Removing one on a later read. Removing one happens only here, not on the card. -->
happens here and nowhere else: the editor has room to offer it, and the
card deliberately doesn't. -->
<div v-if="liveNote.previews.length" class="flex flex-col gap-2"> <div v-if="liveNote.previews.length" class="flex flex-col gap-2">
<LinkPreview <LinkPreview
v-for="p in liveNote.previews" v-for="p in liveNote.previews"
@@ -851,8 +825,6 @@ function revPreview(rev: NoteRevision): string {
</li> </li>
</ul> </ul>
</div> </div>
<!-- No checklist component. The items are blocks above, each one line of the
body, which is what lets a list sit between two paragraphs (M304). -->
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1"> <div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span <span
@@ -861,9 +833,8 @@ function revPreview(rev: NoteRevision): string {
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="labelChipClasses(lb)" :class="labelChipClasses(lb)"
> >
<!-- `#` on every chip, matching the card. This row still lists the tags <!-- `#` on every chip, matching the card. Lists the body's tags too;
the BODY owns too — it is the control surface, and `via_tag` is what `via_tag` decides whether a chip has a cross. -->
decides whether there is a cross to remove one with. -->
#{{ lb.name }} #{{ lb.name }}
<button <button
v-if="!lb.via_tag" v-if="!lb.via_tag"
@@ -934,9 +905,6 @@ function revPreview(rev: NoteRevision): string {
</div> </div>
</div> </div>
<!-- `justify-end`, not `justify-between`: the colour picker sat on the left of
this row until M315 and the row was balanced around it. With one child left,
`between` would push the actions to the far left of a full-width bar. -->
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800"> <div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button <button
+5 -14
View File
@@ -3,18 +3,11 @@ import NoteCard from "./NoteCard.vue";
import type { Note } from "../stores/notes"; import type { Note } from "../stores/notes";
/** /**
* The masonry of note cards — the one place the board's LAYOUT and its MOTION are * The masonry of note cards: the one place the board's layout and motion are
* defined. * defined, so every lens is the same board re-filtered.
*
* It exists because "the same board, re-filtered" has to be literally true to read
* as true. The column classes were copy-pasted into five places (the board's pinned
* and other sections, the non-board views, search, timeline), so a lens could drift
* from home by a single edit — and one already had: the FLIP reflow landed on the
* board's three grids and left search and timeline popping.
* *
* `activeId` rather than an index, because the board splits its notes across two * `activeId` rather than an index, because the board splits its notes across two
* grids and index-based focus made every call site do offset arithmetic against a * grids.
* list it didn't own.
*/ */
defineProps<{ defineProps<{
notes: Note[]; notes: Note[];
@@ -36,10 +29,8 @@ defineEmits<{
<template> <template>
<!-- `board` is the transition name defined in style.css; `appear` covers first <!-- `board` is the transition name defined in style.css; `appear` covers first
paint so a lens arrives eased rather than snapping in. paint so a lens arrives eased rather than snapping in.
Two columns even on a phone, as the Android board has (Scribe #2950): one Two columns even on a phone, as on Android, with a tighter gap there.
column made a phone's board a list. The gap tightens to match there, since NoteCard's bottom margin is the vertical half of this gap. -->
two columns at 16px on a 390px screen leave ~170px cards. NoteCard's
bottom margin is the vertical half of this gap and moves with it. -->
<TransitionGroup <TransitionGroup
tag="div" tag="div"
name="board" name="board"
+1 -1
View File
@@ -3,7 +3,7 @@ import { useUiStore } from "../stores/ui";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
// A secret the server keeps only a hash of, so this is the one time it can be shown: // A secret the server keeps only a hash of, so this is the one time it can be shown:
// invite links (#5172), password reset links (#5173) and device tokens. // invite links, password reset links and device tokens.
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
+1 -1
View File
@@ -2,7 +2,7 @@
import { onBeforeUnmount, ref, watch } from "vue"; import { onBeforeUnmount, ref, watch } from "vue";
// The end of a cursor-paged list: asks for the next page as the reader nears it, // The end of a cursor-paged list: asks for the next page as the reader nears it,
// in place of a "Show older" button (preference 172). The parent keeps its cursor, // in place of a "Show older" button. The parent keeps its cursor,
// its in-flight flag and its fetch; this owns the observing. // its in-flight flag and its fetch; this owns the observing.
// //
// <ScrollSentinel :cursor="cursor" :loading="loadingMore" :error="moreError" @more="loadMore" /> // <ScrollSentinel :cursor="cursor" :loading="loadingMore" :error="moreError" @more="loadMore" />
+2 -2
View File
@@ -8,8 +8,8 @@ import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import { counted } from "../counted"; import { counted } from "../counted";
// Share a note with other people on this instance (#5174). The owner picks a member, // Share a note with other people on this instance. The owner picks a member,
// or a group the admin made (#5177), and says whether they may only read it or also // or a group the admin made, and says whether they may only read it or also
// change its text. Only the owner opens this; the server refuses everyone else // change its text. Only the owner opens this; the server refuses everyone else
// regardless. // regardless.
+5 -14
View File
@@ -3,16 +3,9 @@ import { ref } from "vue";
/** /**
* The card reorder gesture, shared across every NoteCard on screen. * The card reorder gesture, shared across every NoteCard on screen.
* *
* Module-level refs on purpose: with native HTML5 drag-and-drop the browser routed * Module-level refs: a captured pointer sends every move to the card being dragged,
* `dragover`/`drop` to whatever element was under the cursor, so each card learned * so it hit-tests for itself and publishes the result here for the other cards.
* on its own that it was the drop target. Pointer Events don't work that way — once * Pointer Events rather than HTML5 drag-and-drop, which never fires from touch.
* a pointer is captured, every move goes to the element that captured it. The card
* being dragged therefore has to hit-test for itself and publish the result
* somewhere the other cards can see, which is what these are.
*
* Why replace native DnD at all: its events never fire from touch. It predates
* touch and was never wired to it, so on a phone reordering did nothing whatsoever.
* Pointer Events cover mouse, touch and stylus on one code path.
*/ */
/** The card currently being dragged, or null. */ /** The card currently being dragged, or null. */
@@ -32,10 +25,8 @@ export function endCardDrag(): void {
/** /**
* The id of the card under this point, ignoring `self`. * The id of the card under this point, ignoring `self`.
* *
* Reads the DOM rather than tracking geometry because the board is a CSS masonry * Reads the DOM rather than tracking geometry: in a CSS masonry (`columns-*`) the
* (`columns-*`), where the visual order isn't derivable from the model order and * visual order isn't derivable from the model order.
* cards reflow as the column count changes. Asking the browser what is actually
* under the finger is the only answer that stays true.
*/ */
export function cardIdAt(x: number, y: number, self: string): string | null { export function cardIdAt(x: number, y: number, self: string): string | null {
const el = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-note-id]"); const el = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-note-id]");
+6 -16
View File
@@ -1,20 +1,12 @@
/** /**
* Where the editor should appear to grow from. * Where the editor should appear to grow from.
* *
* The brief is continuity of the same object: opening a note shouldn't read as a * Opening a note should read as that card becoming the editor, so the panel scales
* modal cutting in over the board, it should read as THAT card becoming the editor. * from the point the card occupies. Not a true shared-element morph: scaling by the
* The cheap, robust way to say that is to scale the panel from the point the card * real card→panel ratio distorts the text and reads as a zoom.
* occupies rather than from its own centre — the eye reads the origin and infers
* the rest.
* *
* Deliberately NOT a true shared-element morph. Scaling the panel by the real * A viewport point, not a rect, which survives the card being filtered away or
* card→panel ratio distorts the text inside it on the way, and a card is often a * reflowed while the editor is open.
* third of the modal's size, so an honest ratio reads as a zoom rather than as a
* transition. The task sanctions "a good-enough scale/position tween"; this is that.
*
* A viewport POINT, not a rect, for the same reason — nothing downstream needs the
* card's dimensions, and a point survives the card being filtered away or reflowed
* while the editor is open.
*/ */
let origin: { x: number; y: number } | null = null; let origin: { x: number; y: number } | null = null;
@@ -32,9 +24,7 @@ export function captureMorphOrigin(noteId: string): void {
/** /**
* Read the origin and clear it. * Read the origin and clear it.
* *
* Consume-on-read so a compose (which has no card, and so captures nothing) can't * Consume-on-read, so a compose (no card) doesn't inherit the previous note's origin.
* inherit the origin of whatever note was edited before it and grow from a
* seemingly arbitrary corner.
*/ */
export function takeMorphOrigin(): { x: number; y: number } | null { export function takeMorphOrigin(): { x: number; y: number } | null {
const taken = origin; const taken = origin;
+2 -7
View File
@@ -5,13 +5,8 @@ import { useLabelsStore } from "../stores/labels";
/** /**
* What to call the lens currently in view. * What to call the lens currently in view.
* *
* Keyed off the route name rather than each view declaring its own title, so the * Keyed off the route name, so the shell's header and the browser tab read the same
* label sits in one place and can't go missing (the board never had one) * name. A tag lens is named by the tag itself.
* or drift in styling (timeline and reminders each had their own h1). The shell's
* header and the browser tab both read it, so the two can't disagree either.
*
* A label lens is named by the label itself — "Groceries" is what the user came
* looking for; "Label" would tell them nothing they didn't already know.
*/ */
export function useLensName(): ComputedRef<string> { export function useLensName(): ComputedRef<string> {
const route = useRoute(); const route = useRoute();
+3 -6
View File
@@ -2,9 +2,7 @@ import { ref } from "vue";
import { useNotesStore, type Note } from "../stores/notes"; import { useNotesStore, type Note } from "../stores/notes";
import { captureMorphOrigin } from "./useEditorMorph"; import { captureMorphOrigin } from "./useEditorMorph";
// Shared controller for hosting the NoteEditor modal. Every view that opens the // Shared controller for hosting the NoteEditor modal.
// editor used to re-declare the same editing/open/close/navigate glue; this is the
// single implementation.
// //
// - `onClose` lets a host clean up its own state (e.g. a board's compose flag). // - `onClose` lets a host clean up its own state (e.g. a board's compose flag).
// - `list` is the host's local note array, tried first when resolving a navigated // - `list` is the host's local note array, tried first when resolving a navigated
@@ -16,9 +14,8 @@ export function useNoteEditor(options: { onClose?: () => void; list?: () => Note
const editing = ref<Note | null>(null); const editing = ref<Note | null>(null);
function open(note: Note): void { function open(note: Note): void {
// Every view opens the editor through here, so this is the one place that knows // Every view opens the editor through here, so this is where the grow-from-the-
// which card the user actually clicked — and therefore the only place the // card animation learns which card was clicked.
// grow-from-the-card animation can be told where to start (M7).
captureMorphOrigin(note.id); captureMorphOrigin(note.id);
editing.value = note; editing.value = note;
} }
+3 -3
View File
@@ -2,9 +2,9 @@ import { ref, type Ref } from "vue";
import type { Note } from "../stores/notes"; import type { Note } from "../stores/notes";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
// The load-a-list-of-notes scaffold every recall view (Timeline, Reminders) // Loading a list of notes for a recall view (Timeline, Reminders): an items ref,
// hand-rolled: an items ref + loading/error flags + a load() that fills them and never // loading/error flags, and a load() that never leaves a half-state. The view supplies
// leaves a half-state. The view supplies just the fetcher; wire the returned refs into // the fetcher; wire the returned refs into
// <AsyncState> and pass `items` to <EmptyState>/cards. // <AsyncState> and pass `items` to <EmptyState>/cards.
// //
// `fetcher` returns the notes to show. A thrown // `fetcher` returns the notes to show. A thrown
+2 -5
View File
@@ -6,11 +6,8 @@ import { useLensName } from "./useLensName";
/** /**
* Keep the browser tab titled "<page> · <site name>". * Keep the browser tab titled "<page> · <site name>".
* *
* index.html's static <title> only names the app, and nothing updated it after * Mounted once in App.vue. A route outside the shell names itself with `meta.title`;
* that, so every tab read the same — or, where the browser preferred the URL, no * a board lens takes the name the shell's header shows.
* name at all. One writer, mounted once in App.vue: a route outside the shell names
* itself with `meta.title`, and a board lens takes the name the shell's header
* already shows.
*/ */
export function usePageTitle(): void { export function usePageTitle(): void {
const route = useRoute(); const route = useRoute();
+4 -8
View File
@@ -5,15 +5,11 @@ const QUERY = "(prefers-reduced-motion: reduce)";
/** /**
* Whether the user has asked their system to reduce motion. * Whether the user has asked their system to reduce motion.
* *
* The CSS half of this lives in style.css and neutralises every declarative * style.css neutralises declarative transitions. This is for motion driven from
* transition on its own. This is for the half CSS can't reach: motion driven from * JavaScript (the FLIP reflow, the card↔editor morph), which jumps straight to the
* JavaScript — the FLIP reflow and the card↔editor morph — where the honest * final state instead.
* response isn't a faster animation but no animation at all, jumping straight to
* the final state.
* *
* Reactive rather than a one-shot read: the setting can change while the app is * Reactive, since the setting can change while the app is open.
* open (a system toggle, or a "reduce motion during battery saver" rule), and a
* board that keeps sliding until relaunch would be ignoring an answer already given.
*/ */
export function useReducedMotion(): Readonly<Ref<boolean>> { export function useReducedMotion(): Readonly<Ref<boolean>> {
const reduced = ref(prefersReducedMotion()); const reduced = ref(prefersReducedMotion());
+11 -16
View File
@@ -11,10 +11,8 @@ interface TauriGlobal {
options?: { headers?: Record<string, string> }, options?: { headers?: Record<string, string> },
) => Promise<T>; ) => Promise<T>;
}; };
// Also from `withGlobalTauri`. Needed because quick capture puts the app in TWO // Also from `withGlobalTauri`. Quick capture runs a second window with its own
// windows, each with its own Pinia stores — a note saved in one is invisible to // Pinia stores, and an event is how a note saved in one reaches the other.
// the other until something says so, and an event is the only channel between
// them that does not involve polling SQLite.
event?: { event?: {
listen: <T>(event: string, handler: (e: { payload: T }) => void) => Promise<() => void>; listen: <T>(event: string, handler: (e: { payload: T }) => void) => Promise<() => void>;
}; };
@@ -78,7 +76,7 @@ export const desktop = {
unintegrate: () => invoke<IntegrationStatus>("unintegrate_desktop"), unintegrate: () => invoke<IntegrationStatus>("unintegrate_desktop"),
}; };
// --- opt-in server sync (M10.7) ---------------------------------------------- // --- opt-in server sync ------------------------------------------------------
// The desktop app is local-first: none of this runs unless the user links a server, // The desktop app is local-first: none of this runs unless the user links a server,
// and the app is fully usable having never done so. // and the app is fully usable having never done so.
@@ -99,7 +97,7 @@ export interface ServerInfo {
} }
/** /**
* The M10.6 handshake verdict. `incompatible` carries `client_must_update` so the * The sync-protocol handshake verdict. `incompatible` carries `client_must_update` so the
* message can name which side has to change rather than just saying "incompatible". * message can name which side has to change rather than just saying "incompatible".
*/ */
export type Compatibility = export type Compatibility =
@@ -231,7 +229,7 @@ export const sync = {
hasPending: () => invoke<boolean>("sync_has_pending"), hasPending: () => invoke<boolean>("sync_has_pending"),
}; };
// --- In-app updates (M10.9) -------------------------------------------------- // --- In-app updates ----------------------------------------------------------
/** `stable` follows tagged releases; `dev` follows every green build. */ /** `stable` follows tagged releases; `dev` follows every green build. */
export type UpdateChannel = "stable" | "dev"; export type UpdateChannel = "stable" | "dev";
@@ -264,17 +262,14 @@ export const updates = {
}; };
// --- Quick capture (#1899) --------------------------------------------------- // --- Quick capture -----------------------------------------------------------
/** /**
* The stored hotkey and whether the OS actually accepted it. * The stored hotkey and whether the OS actually accepted it.
* *
* They disagree more often than you would like: a combination can be saved and * A saved combination can fail to register when another app holds it, or when a
* refuse to register because a window manager or another app already holds it, * Wayland compositor refuses global grabs. `registered: false` with a non-empty
* and on Wayland a compositor may refuse global grabs entirely. `registered: * `shortcut` is that case, and the UI says so.
* false` alongside a non-empty `shortcut` is precisely that case, and the UI has
* to say so — a hotkey that silently does nothing is worse than none, because
* there is nothing to look at and nothing to fix.
*/ */
export interface CaptureShortcut { export interface CaptureShortcut {
/** The stored combination, or "" when quick capture is off. */ /** The stored combination, or "" when quick capture is off. */
@@ -300,8 +295,8 @@ export const capture = {
* Run `handler` whenever a note is captured in the other window. * Run `handler` whenever a note is captured in the other window.
* *
* Returns an unlisten function, or a no-op on the web build and on any desktop * Returns an unlisten function, or a no-op on the web build and on any desktop
* runtime that does not expose the event API — the board simply keeps showing * runtime that does not expose the event API; the board then updates on its next
* what it has until its next load, which is a stale list rather than a broken one. * load.
*/ */
export async function onCaptured(handler: () => void): Promise<() => void> { export async function onCaptured(handler: () => void): Promise<() => void> {
return listen(CAPTURED_EVENT, () => handler()); return listen(CAPTURED_EVENT, () => handler());
+2 -2
View File
@@ -4,8 +4,8 @@
* *
* Every `image/` type except SVG. An SVG is a document that can carry its own * Every `image/` type except SVG. An SVG is a document that can carry its own
* script, so neither surface serves one to render: the server sends it as a download * script, so neither surface serves one to render: the server sends it as a download
* (#1981) and the desktop's blob scheme as opaque bytes, where an `<img>` of it would * and the desktop's blob scheme as opaque bytes, where an `<img>` of it would show
* only ever show broken. */ * broken. */
export function rendersInline(mime: string): boolean { export function rendersInline(mime: string): boolean {
return mime.startsWith("image/") && mime !== "image/svg+xml"; return mime.startsWith("image/") && mime !== "image/svg+xml";
} }
+14 -25
View File
@@ -1,22 +1,14 @@
// The editor's shape for a note body: a list of blocks rather than one string. // The editor's shape for a note body: a list of blocks rather than one string.
// //
// The note is still one markdown body underneath (M304) — this is a rendering and // The note is still one markdown body; nothing below the editor sees blocks.
// input shape, and nothing below the editor can tell it exists. `joinBlocks` puts the // `joinBlocks` rebuilds the string on every edit, and for a canonical body returns
// string back together on every edit, and for a body already in canonical form it // exactly what `splitBlocks` was handed.
// returns exactly what `splitBlocks` was handed.
// //
// Why blocks at all: a checklist item has to be a real `<input type="checkbox">`, and // Blocks because a checklist item is a real `<input type="checkbox">`, which can't
// a widget cannot live inside a `<textarea>`. Only a `contenteditable` could hold one, // live inside a `<textarea>`. A run of prose lines is one block, so typing a
// and that is a different editor with a different set of problems. // paragraph feels like typing a paragraph.
// //
// A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel // Mirrors android/.../ui/EditorBlock.kt, so the two editors behave the same.
// like typing a paragraph, and a separate field under every sentence would break the
// caret mid-sentence. Only a checklist item earns a block, because only a checklist
// item needs a widget.
//
// The mirror of android/.../ui/EditorBlock.kt, deliberately: the two editors should
// behave the same, and the cheapest way to keep them that way is for the shapes to
// read alike.
import { parseTaskLine, renderTaskLine } from "./markdown"; import { parseTaskLine, renderTaskLine } from "./markdown";
@@ -71,12 +63,10 @@ export function nextId(blocks: EditorBlock[]): number {
/** /**
* What Enter does on a checklist item, and which block should hold the caret after. * What Enter does on a checklist item, and which block should hold the caret after.
* *
* On an item with words in it, a new empty item below. On an EMPTY one, the item * On an item with words in it, a new empty item below. On an empty one, the item
* becomes prose — which is how a list ENDS, and the only way to get a paragraph after * becomes prose, which is how a list ends.
* one. Without that half a list is impossible to get out of.
* *
* Appends rather than splitting at the caret: splitting an item in two is a rarity, * Appends rather than splitting at the caret, which is at the end in ordinary use.
* and the caret is at the end for every ordinary use of that key.
*/ */
export function afterEnter(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number } { export function afterEnter(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number } {
const block = blocks[index]; const block = blocks[index];
@@ -94,7 +84,7 @@ export function afterEnter(blocks: EditorBlock[], index: number): { blocks: Edit
* Drop a block, leaving at least one field to type into. * Drop a block, leaving at least one field to type into.
* *
* Focus goes to the block above — or, when the first one was removed, to whichever * Focus goes to the block above — or, when the first one was removed, to whichever
* takes its place. `index - 1` alone is -1 there, which would leave nothing focused. * takes its place.
*/ */
export function withoutIndex(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number | null } { export function withoutIndex(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number | null } {
const kept = blocks.filter((_, i) => i !== index); const kept = blocks.filter((_, i) => i !== index);
@@ -114,11 +104,10 @@ export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus:
* typed in the editor becomes an item without closing and reopening the note. * typed in the editor becomes an item without closing and reopening the note.
* *
* On blur, and only the block being left: re-splitting on a keystroke would move the * On blur, and only the block being left: re-splitting on a keystroke would move the
* caret, and converting the moment `- [ ]` is complete would catch an item with no * caret.
* text yet.
* *
* Returns THE SAME ARRAY when there was nothing to promote; the caller relies on that * Returns the same array when there was nothing to promote, so the caller can leave
* to leave the ref alone, so a blur that changed nothing doesn't re-key every field. * the ref alone and not re-key every field.
* *
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on * Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
* reopen. * reopen.
+28 -64
View File
@@ -1,6 +1,5 @@
// Note color palette. Keys match the backend's NOTE_COLORS; the actual tints live // The label colour palette. Keys match the backend's NOTE_COLORS; the tints live
// here (frontend concern). Class strings are full literals so Tailwind's content // here. Class strings are full literals so Tailwind's content scanner keeps them.
// scanner (src/**/*.ts) keeps them in the build.
import { foldTag } from "./tags"; import { foldTag } from "./tags";
@@ -19,9 +18,8 @@ export const NOTE_COLOR_KEYS = [
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number]; export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
/** Membership test for a colour key arriving from the server, which may be newer /** Membership test for a label's colour key arriving from the server, which may be
* than this client. Once a lookup in a card-fill table; since M315 there is no such * newer than this client. */
* table, and this guards a LABEL's stored colour on its way into the palette. */
const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS); const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = { export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
@@ -84,10 +82,9 @@ export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
/** /**
* The classes for one `#tag` in a note's own words. * The classes for one `#tag` in a note's own words.
* *
* `picked` maps a folded tag name (`foldTag`) to the colour stored on that label, so a tag the * `picked` maps a folded tag name (`foldTag`) to its label's stored colour, so a
* operator has recoloured reads the same inline as it does on a chip. A tag the note * recoloured tag reads the same inline as on a chip. A tag just typed isn't in the
* does not carry as a label yet — just typed, not yet derived — is not in the map, and * map, and `resolveLabelColor` derives one from the name as the chip would.
* `resolveLabelColor` derives one from the name exactly as the chip would have.
*/ */
export function tagTextClasses(name: string, picked?: Record<string, string>): string { export function tagTextClasses(name: string, picked?: Record<string, string>): string {
return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[foldTag(name)] })]; return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[foldTag(name)] })];
@@ -96,13 +93,8 @@ export function tagTextClasses(name: string, picked?: Record<string, string>): s
/** /**
* The whole class list for a label CHIP: the shell and the ink, composed. * The whole class list for a label CHIP: the shell and the ink, composed.
* *
* One function rather than the composition written out at each call site, because the * One function, so the board's chip and the editor's are the same pill. Takes the
* board's chip and the editor's chip have to be the same pill — the same tag changing * label, not its colour: an uncoloured tag derives one from its name.
* colour on opening a note would be worse than both being grey. That was a comment
* asking two files to stay in step; it is one call now.
*
* Takes the LABEL, not its colour: a tag nobody has coloured derives one from its name,
* so chips carry the tag's identity rather than all being the same grey.
*/ */
export function labelChipClasses(label: { name: string; color?: string | null }): string { export function labelChipClasses(label: { name: string; color?: string | null }): string {
const color = resolveLabelColor(label); const color = resolveLabelColor(label);
@@ -131,25 +123,12 @@ export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Derived colour — the hue a LABEL wears when nobody picked one for it. // Derived colour — the hue a LABEL wears when nobody picked one for it.
// //
// Every `#tag` is born colourless, so without this a board of tags is a board of // Every `#tag` is born colourless, so its colour is hashed from its name:
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every // deterministic, the same on every surface, with no column to store.
// surface, costs no column and no migration, and a tag keeps its colour for life.
// //
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is // `android/.../ui/DerivedTint.kt` computes the same hash over the same key order, and
// one neutral and only its tags carry hue. The hash survived that removal because the // the two must agree. Both are pinned to `core/testdata/grammar.json` (`tint`), read
// job it still does — give a name a stable colour — was never the job that failed. // by `notes/grammar.test.ts`, and to DerivedTintTest.kt.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
//
// THIS IS HALF A MIRRORED PAIR. `android/.../ui/DerivedTint.kt` computes the same
// hash over the same key order, and the two must agree exactly or a tag is one colour
// on the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
//
// Both sides are pinned to the same values: `core/testdata/grammar.json` (`tint`),
// which `notes/grammar.test.ts` reads, and DerivedTintTest.kt, which writes the same
// numbers out by hand. Change the hash or the key order and both go red.
export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter( export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
(key) => key !== "default", (key) => key !== "default",
); );
@@ -157,14 +136,11 @@ export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
/** /**
* FNV-1a over the id's bytes, 32-bit. * FNV-1a over the id's bytes, 32-bit.
* *
* Chosen because both languages compute it identically in ten lines with no * Both languages compute it identically in a few lines with no library; JS has no
* library. Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has * `String.hashCode()`.
* no equivalent, and reimplementing Java's from memory in TypeScript is exactly
* how a mirror drifts.
* *
* `& 0xff` is a no-op for the ASCII of a UUID, and is kept because it states the * `& 0xff` is a no-op for a UUID's ASCII and states that this hashes bytes, like the
* intent — this hashes BYTES, so the Kotlin side reading `id[i].code and 0xFF` * Kotlin side's `id[i].code and 0xFF`.
* is the same function rather than a coincidence.
*/ */
export function tintHash(id: string): number { export function tintHash(id: string): number {
let hash = 0x811c9dc5; let hash = 0x811c9dc5;
@@ -186,25 +162,14 @@ export function derivedTint(id: string): NoteColor {
/** /**
* The colour to paint a LABEL — its chip, and its `#tag` where it sits in the prose. * The colour to paint a LABEL — its chip, and its `#tag` where it sits in the prose.
* *
* Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag` * A typed `#tag` is created with the `default` colour, so when nobody picked one the
* ever typed is `default`: `notes/tags.py` mints one as `Label(owner_id=…, name=name)` * colour is derived from the name rather than stored. Derived, not written when the
* with no colour, so it takes the column default. Without deriving, a board of tags * tag is created, so no creation path can miss it; renaming a tag recolours it.
* would be a board of identical grey chips.
* *
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965. * A picked colour is stored and wins.
* That plan wanted a hashed colour written at each of the four places a label can be
* born — and named the risk itself: `find_or_create_label` is "easy to miss, and it
* is the common one", because most tags are born from typing `#grocery`, not from a
* management screen. Deriving has no mint points to miss, needs no backfill for the
* tags that already exist, and reuses a hash that is already written twice. The cost is
* that renaming a tag recolours it, which is defensible: the name IS the tag.
* *
* An explicitly-picked colour is still stored and still wins, so tag colours stay * The name is folded, so `#Todo` and `#todo` share a colour; `foldTag` here and on
* editable exactly as asked. * Android are both locale-independent lowercasing.
*
* Folded because `#Todo` renamed to `#todo` is the same tag and should not change
* colour. `foldTag` here and Android's are both locale-independent lowercasing, so
* the mirror holds.
*/ */
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor { export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
const picked = label.color as NoteColor | undefined | null; const picked = label.color as NoteColor | undefined | null;
@@ -216,9 +181,8 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
// The fixture (four UUID hashes and seven tag names) lives in // The fixture (four UUID hashes and seven tag names) lives in
// core/testdata/grammar.json under `tint`. // core/testdata/grammar.json under `tint`.
// //
// Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable // `work`/`ideas` and `home`/`reading` collide, which nine keys make unavoidable; the
// and it is not a bug: colour hints that two tags are distinct, it never claims two // chip's text says which tag it is.
// chips of one colour are the same tag. The chip's text is what says which tag it is.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// THE CARD SURFACE — one neutral, the same for every note. Colour lives only on tags // THE CARD SURFACE — one neutral, the same for every note. Colour lives only on tags
@@ -232,6 +196,6 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
// body vs card light #171717 on #ffffff 17.93 (needs 4.5) // body vs card light #171717 on #ffffff 17.93 (needs 4.5)
// dark #fafafa on #171717 17.17 // dark #fafafa on #171717 17.17
// //
// The fill is deliberately the weakest number: the edge and the shadow separate a card // The fill is deliberately the weakest number: the edge and shadow separate a card from
// from the board, and a fill strong enough to do it alone would make every card a panel. // the board.
export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900"; export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900";
+3 -6
View File
@@ -53,18 +53,15 @@ export function addLocalDays(d: Date, days: number): Date {
} }
// --- Trash retention. The server permanently deletes a trashed note once it's older // --- Trash retention. The server permanently deletes a trashed note once it's older
// than `trash_retention_days` (0 = keep forever). Counting down from the note's own // than `trash_retention_days` (0 = keep forever), so a card in Trash says how long it
// deleted_at is what turns that from a surprise into a policy: a card in Trash can // has left. ---
// say how long it has left while there's still time to restore it. ---
const MS_PER_DAY = 24 * 60 * 60 * 1000; const MS_PER_DAY = 24 * 60 * 60 * 1000;
// Whole days a trashed note has left, or null when nothing will happen to it // Whole days a trashed note has left, or null when nothing will happen to it
// (retention off, or the note isn't trashed). // (retention off, or the note isn't trashed).
// //
// Rounds DOWN deliberately. Rounding up would report "1 day left" for a note with // Rounds down: overstating the time left is the error that costs someone a note.
// ten minutes on the clock — overstating the time remaining is the one error here
// that actually costs someone a note.
export function trashDaysLeft( export function trashDaysLeft(
deletedAt: string | null | undefined, deletedAt: string | null | undefined,
retentionDays: number, retentionDays: number,
+2 -2
View File
@@ -10,8 +10,8 @@ describe("the board's filter URL", () => {
expect(facetCount(facets)).toBe(5); expect(facetCount(facets)).toBe(5);
}); });
// Removed in #5180: reminders have their own lens and dates are Timeline's. A link // Reminders have their own lens and dates are Timeline's. A link carrying those
// saved before then must open the plain board, not one filtered with no chip to say so. // opens the plain board, not one filtered with no chip to say so.
it("ignores the reminder and date keys an old link may carry", () => { it("ignores the reminder and date keys an old link may carry", () => {
const facets = facetsFromQuery({ const facets = facetsFromQuery({
has_reminder: "true", has_reminder: "true",
+1 -3
View File
@@ -1,9 +1,7 @@
// The web's half of the shared grammar fixture (core/testdata/grammar.json). // The web's half of the shared grammar fixture (core/testdata/grammar.json).
// //
// The same cases run against the core (derive.rs) and the server (checklist.py, // The same cases run against the core (derive.rs) and the server (checklist.py,
// tags.py). Until this file the web copy had no mechanical guard at all — the // tags.py), so the browser can't drift on a checklist or a tag colour.
// frontend's only CI lane was `vue-tsc --noEmit` — so a drift here showed up as a
// checklist or a tag colour that differed in the browser and nowhere else.
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import fixture from "../../../core/testdata/grammar.json"; import fixture from "../../../core/testdata/grammar.json";
import { DERIVED_TINT_KEYS, derivedTint, resolveLabelColor, tintHash } from "./colors"; import { DERIVED_TINT_KEYS, derivedTint, resolveLabelColor, tintHash } from "./colors";
+1 -1
View File
@@ -2,7 +2,7 @@
// //
// The characters a link runs over, and the punctuation trimmed off its end, are the // The characters a link runs over, and the punctuation trimmed off its end, are the
// server's (unfurl_queue.detect_urls): the server files the preview under the trimmed // server's (unfurl_queue.detect_urls): the server files the preview under the trimmed
// link, so the card has to look it up under the same one (#5399). Android's // link, so the card has to look it up under the same one. Android's
// LinkPreviewRow holds the same rule, and all three run the `urls` cases in // LinkPreviewRow holds the same rule, and all three run the `urls` cases in
// core/testdata/grammar.json. // core/testdata/grammar.json.
// //
+10 -21
View File
@@ -1,10 +1,7 @@
// A tiny, dependency-free Markdown parser for the READ view (note cards). Capture // A small, dependency-free Markdown parser for the read view (note cards). Rendered
// stays plain text; this only formats what's shown. We render the parsed tree as // as Vue vnodes, never v-html, so there is no HTML-injection surface. Headings
// Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a // (#..###), lists, blockquote, fenced code, and inline **bold** / *italic* / _italic_
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced // / `code`. A heading needs a space after `#`, so a #tag is never one.
// code, and inline **bold** / *italic* / _italic_ / `code`. Note: headings need a
// space after `#`, so a #tag (no space) is left as plain text and never mistaken for
// a heading.
export interface InlineToken { export interface InlineToken {
/** `tag` carries the NAME, without the leading `#` — it is both what gets looked up /** `tag` carries the NAME, without the leading `#` — it is both what gets looked up
@@ -34,16 +31,10 @@ export interface TaskMeta {
} }
// Order matters: code is matched before emphasis so its contents aren't re-parsed; // Order matters: code is matched before emphasis so its contents aren't re-parsed;
// bold (**) before italic (*). Emphasis does not nest (v1). // bold (**) before italic (*). Emphasis does not nest.
// //
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is // `#tag` is last: a `#tag` inside backticks is claimed by `code` first and stays
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now // literal, as in the core, and a tag must not start inside `**bold #x**`.
// renders as the literal characters someone typed, which is what it always was.
// `#tag` is LAST in the alternation and that is load-bearing twice over. JS tries
// alternatives left to right, so a `#tag` inside backticks is claimed by `code` first
// and stays literal — matching the core, where a fenced block's contents are code.
// And a tag is the one token here that is not delimiter-based, so it must not get a
// chance to start inside `**bold #x**`.
// //
// The grammar MIRRORS `line_tags` in core/src/local/derive.rs, which is the definition: // The grammar MIRRORS `line_tags` in core/src/local/derive.rs, which is the definition:
// a `#` at the start of the text or after whitespace (so `a#b`, `##x`, `(#x)` and the // a `#` at the start of the text or after whitespace (so `a#b`, `##x`, `(#x)` and the
@@ -77,12 +68,10 @@ export function parseInline(text: string): InlineToken[] {
// A checklist item: the third implementation of one grammar, alongside // A checklist item: the third implementation of one grammar, alongside
// core/src/local/derive.rs and src/inkwell/notes/checklist.py. A difference // core/src/local/derive.rs and src/inkwell/notes/checklist.py. A difference
// between any two of them is a checklist that changes shape when it syncs (M304). // between any two of them is a checklist that changes shape when it syncs.
// //
// `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`. // `-` and `*` only, though the `ul` matcher below also takes `+`: the other two
// The other two implementations do not take `+`, and one grammar in three places has // implementations don't, so `+ [ ] x` is an ordinary bullet everywhere.
// to be one grammar; a `+ [ ] x` line renders as an ordinary bullet everywhere,
// which is at least consistent.
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/; const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
export interface TaskLine { export interface TaskLine {
+1 -1
View File
@@ -1,5 +1,5 @@
// The rail keeps only the few tags you are using, so a long tag list never pushes // The rail keeps only the few tags you are using, so a long tag list never pushes
// anything out of view (#5931). A tag is as recent as the later of: // anything out of view. A tag is as recent as the later of:
// - its last use: the newest note carrying it, from the listing (`last_used`), and // - its last use: the newest note carrying it, from the listing (`last_used`), and
// from the notes already loaded, so a tag applied a moment ago counts before the // from the notes already loaded, so a tag applied a moment ago counts before the
// listing is next fetched; // listing is next fetched;
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Note } from "../stores/notes"; import type { Note } from "../stores/notes";
// How the signed-in person holds a note (#5174). The owner does everything; someone it // How the signed-in person holds a note. The owner does everything; someone it
// is shared with at `edit` changes the body and checklist; at `view`, nothing. A note // is shared with at `edit` changes the body and checklist; at `view`, nothing. A note
// with no permission came from a source without sharing (the offline desktop): it is // with no permission came from a source without sharing (the offline desktop): it is
// the person's own. // the person's own.
+1 -1
View File
@@ -1,6 +1,6 @@
// How two tag names compare: equal once every letter is lowercased, accented ones // How two tag names compare: equal once every letter is lowercased, accented ones
// included, so `#Café` and `#CAFÉ` are one tag. The core's `derive::fold`, the // included, so `#Café` and `#CAFÉ` are one tag. The core's `derive::fold`, the
// server's `labeling.named` and Android's `foldTag` fold the same way (#5385). // server's `labeling.named` and Android's `foldTag` fold the same way.
export function foldTag(name: string): string { export function foldTag(name: string): string {
return name.toLowerCase(); return name.toLowerCase();
} }
+13 -17
View File
@@ -25,9 +25,8 @@ const router = createRouter({
], ],
}, },
{ {
// The quick-capture window (#1899). Its own route because it is its own // The quick-capture window: no shell, no nav, one field. Desktop only, since a
// WINDOW — no shell, no nav, one field. Desktop only: there is no global // browser tab has no global hotkey.
// hotkey in a browser tab and nothing to summon it.
path: "/capture", path: "/capture",
name: "capture", name: "capture",
component: () => import("../views/CaptureView.vue"), component: () => import("../views/CaptureView.vue"),
@@ -51,11 +50,9 @@ const router = createRouter({
// Per-user account: linked devices (native-client sync tokens), the password, // Per-user account: linked devices (native-client sync tokens), the password,
// and signing out everywhere else. Any user. // and signing out everywhere else. Any user.
// //
// The mirror of `requiresDesktop` above: this one needs a SERVER. The desktop // Needs a server: the desktop is itself one of the devices listed, and its link
// is itself one of the devices this page lists, so offline the list is always // to a server lives at /sync. Guarded here, not just hidden in the shell, so a
// empty and issuing a token rejects — its server relationship lives at /sync. // typed URL can't land on a dead end.
// Guarded in the router, not just hidden in the shell, so a typed URL or a
// restored history entry can't land on a dead end either.
path: "/account", path: "/account",
name: "account", name: "account",
component: () => import("../views/AccountView.vue"), component: () => import("../views/AccountView.vue"),
@@ -74,7 +71,7 @@ const router = createRouter({
meta: { title: "Create account", guestOnly: true }, meta: { title: "Create account", guestOnly: true },
}, },
{ {
// Ask for a reset link by email (#5266). Only linked from sign-in when the // Ask for a reset link by email. Only linked from sign-in when the
// server can send mail; reached otherwise, the server says it can't. // server can send mail; reached otherwise, the server says it can't.
path: "/forgot-password", path: "/forgot-password",
name: "forgot-password", name: "forgot-password",
@@ -82,10 +79,9 @@ const router = createRouter({
meta: { title: "Forgot password", guestOnly: true }, meta: { title: "Forgot password", guestOnly: true },
}, },
{ {
// Where a password reset link lands, whether an admin made it (#5173) or it was // Where a password reset link lands, whether an admin made it or it was emailed.
// emailed (#5266). Not guest-only: the // Not guest-only: the link signs in whoever uses it as the account it was made
// link signs in whoever uses it as the account it was made for, whoever was // for, whoever was signed in on this browser before.
// signed in on this browser before.
path: "/reset-password", path: "/reset-password",
name: "reset-password", name: "reset-password",
component: () => import("../views/ResetPasswordView.vue"), component: () => import("../views/ResetPasswordView.vue"),
@@ -124,10 +120,10 @@ router.beforeEach(async (to) => {
if (to.query.capture === "1" && to.name !== "capture") { if (to.query.capture === "1" && to.name !== "capture") {
return { name: "capture" }; return { name: "capture" };
} }
// Deliberately NOT applied to /login and /register: bouncing those on desktop // Not applied to /login and /register: bouncing those on desktop would loop
// would loop against the requiresAuth guard above the moment a session is // against the requiresAuth guard above whenever a session is missing. Nothing on
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out // the desktop navigates to them (AppShell's sign-out is web-only), and a launch
// is web-only), and a fresh launch always resolves the local user. // always resolves the local user.
if (to.meta.requiresServer && isDesktop()) { if (to.meta.requiresServer && isDesktop()) {
return { name: "board" }; return { name: "board" };
} }
+12 -15
View File
@@ -3,14 +3,13 @@ import { ref } from "vue";
import { repo } from "../adapters"; import { repo } from "../adapters";
// One client build this server can hand out. Platforms it holds nothing for are // One client build this server can hand out. Platforms it holds nothing for are
// ABSENT from the map rather than present-and-null, so a key test is the whole // ABSENT from the map rather than null, so a key test is the whole question; see
// question; see client_dist.py. // client_dist.py.
// //
// Named for its twin in `core/src/sync/client.rs`, which deserializes the same // Its twin in `core/src/sync/client.rs` deserializes the same payload and is
// payload. That one is deliberately NARROWER — it only ever reads // NARROWER: it reads only `/api/client/android`, so its `version_code` is an `i64`
// `/api/client/android`, so its `version_code` is an `i64` and it declares none of // and it omits the fields Android doesn't use. Don't widen it to match: every phone
// the fields below that Android does not use. Widening it to match this is not a // in the field runs that shape.
// tidy-up: every phone in the field runs the current shape.
export interface ClientRelease { export interface ClientRelease {
// The table row's id — "android", "linux-deb", "linux-appimage", "windows". // The table row's id — "android", "linux-deb", "linux-appimage", "windows".
platform: string; platform: string;
@@ -19,12 +18,10 @@ export interface ClientRelease {
// labels cannot drift apart across the surfaces that show them. // labels cannot drift apart across the surfaces that show them.
label: string; label: string;
version: string; version: string;
// What decides "is this newer". The name is for people and sorts like a string. // What decides "is this newer"; the name is for people and sorts like a string.
// // Android's is an integer because Android's install gate compares one; the
// Not one type across platforms, deliberately: Android's is an integer because // desktop's is Tauri's semver key `1.0.<minutes>`. Nothing compares them across
// Android's own install gate compares one, and the desktop's is Tauri's semver // platforms.
// key `1.0.<minutes>`. Nothing in this app compares them — the union is here so
// the shape is honest rather than to be read.
version_code: number | string; version_code: number | string;
size: number; size: number;
sha256: string; sha256: string;
@@ -43,8 +40,8 @@ export interface PublicConfig {
enable_url_unfurl: boolean; enable_url_unfurl: boolean;
// How many days a note survives in Trash before the server purges it. 0 = forever. // How many days a note survives in Trash before the server purges it. 0 = forever.
trash_retention_days: number; trash_retention_days: number;
// Whether this server can email a reset link, so sign-in offers "Forgot password?" // Whether this server can email a reset link, so sign-in offers "Forgot password?".
// (#5266). Absent on an older server and on the offline desktop: no. // Absent on an older server and on the offline desktop: no.
password_reset_by_email?: boolean; password_reset_by_email?: boolean;
// The shortest password an account may have (the server's MIN_PASSWORD_LEN). // The shortest password an account may have (the server's MIN_PASSWORD_LEN).
// Absent on an older server and on the offline desktop, which has no accounts. // Absent on an older server and on the offline desktop, which has no accounts.
+4 -6
View File
@@ -10,7 +10,7 @@ export interface Label {
// Number of notes carrying this label (from GET /api/labels; used in label management). // Number of notes carrying this label (from GET /api/labels; used in label management).
count?: number; count?: number;
// The newest edit among the untrashed notes carrying it, listings only. Ranks the // The newest edit among the untrashed notes carrying it, listings only. Ranks the
// rail's recent tags (#5931). // rail's recent tags.
last_used?: string | null; last_used?: string | null;
} }
@@ -82,11 +82,9 @@ export const useLabelsStore = defineStore("labels", () => {
} }
async function remove(id: string): Promise<void> { async function remove(id: string): Promise<void> {
// Labels have no trash of their own — a label is organization, not content, so // Labels have no trash: a label is organization, not content. Deleting one is
// the reversible middle step notes get would be ceremony. But deleting one is // irreversible and reaches every synced device, so it asks first. The notes
// still irreversible and now reaches every synced device, so it asks first. The // survive; only the membership goes, which is what the prompt reassures about.
// notes themselves survive; only the membership goes, which is the part people
// most need reassuring about.
const label = items.value.find((lb) => lb.id === id); const label = items.value.find((lb) => lb.id === id);
const subject = label ? `the tag "${label.name}"` : "this tag"; const subject = label ? `the tag "${label.name}"` : "this tag";
const confirmed = window.confirm( const confirmed = window.confirm(
+3 -3
View File
@@ -12,7 +12,7 @@ export interface NoteFacets {
has_attachment?: boolean; has_attachment?: boolean;
created_after?: string; created_after?: string;
created_before?: string; created_before?: string;
/** "with_me": only notes other people have shared with you (#5174). */ /** "with_me": only notes other people have shared with you. */
shared?: "with_me"; shared?: "with_me";
} }
@@ -26,7 +26,7 @@ export interface NoteLabel {
color: string; color: string;
// True when the label is backed by text STILL IN THE BODY — a `#tag` written // True when the label is backed by text STILL IN THE BODY — a `#tag` written
// mid-sentence, kept in sync with those words. False covers both a label added // mid-sentence, kept in sync with those words. False covers both a label added
// through the picker and a tag lifted off a line of its own (M311), which is why // through the picker and a tag lifted off a line of its own, which is why
// it is also what decides whether a chip can be removed with a cross. // it is also what decides whether a chip can be removed with a cross.
// //
// The card reads it the other way round: a true here means the body is already // The card reads it the other way round: a true here means the body is already
@@ -91,7 +91,7 @@ export interface Note {
previews: LinkPreview[]; previews: LinkPreview[];
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
// Sharing (#5174). `edit` may change the body and checklist; everything else is the // Sharing. `edit` may change the body and checklist; everything else is the
// owner's. A recipient's copy arrives without the owner's labels. // owner's. A recipient's copy arrives without the owner's labels.
permission?: NotePermission; permission?: NotePermission;
/** The owner's own note: whether it is shared with anyone. */ /** The owner's own note: whether it is shared with anyone. */
+7 -7
View File
@@ -6,14 +6,14 @@ import router from "../router";
import { useUiStore } from "./ui"; import { useUiStore } from "./ui";
import type { Note } from "./notes"; import type { Note } from "./notes";
// Reminder delivery. In a browser it is foreground only — NO Web Push / service // Reminder delivery. In a browser it is foreground only, with no Web Push or service
// worker (per operator): while the tab is open, poll for due reminders and surface // worker: while the tab is open, poll for due reminders and surface each once as a
// each once as a toast (and, if the user opted in, a page-context OS notification). // toast (and, if the user opted in, a page-context OS notification).
// //
// The desktop doesn't poll here. Its Rust worker reads the local store, decides // The desktop doesn't poll here. Its Rust worker reads the local store, decides what
// what is due, raises the system notification itself when the window isn't in // is due, raises the system notification itself when the window isn't in front, and
// front, and tells this page so it can show the toast (#5171). The page's // tells this page so it can show the toast. In WebKitGTK the page's `Notification`
// `Notification` went nowhere in WebKitGTK, and its timer stopped with the window. // goes nowhere and its timer stops with the window.
const POLL_MS = 45_000; const POLL_MS = 45_000;
const RECENT_MS = 15 * 60 * 1000; // on first load, only announce reminders due this recently const RECENT_MS = 15 * 60 * 1000; // on first load, only announce reminders due this recently
-2
View File
@@ -9,8 +9,6 @@ import { useTitlesStore } from "./titles";
describe("the palette's note titles", () => { describe("the palette's note titles", () => {
beforeEach(() => setActivePinia(createPinia())); beforeEach(() => setActivePinia(createPinia()));
// #5165: the list loaded once per session, so a note written after the palette
// first opened could not be found by name until the page reloaded.
it("sees a note written since the last open", async () => { it("sees a note written since the last open", async () => {
vi.mocked(repo.notes.titles) vi.mocked(repo.notes.titles)
.mockResolvedValueOnce([{ id: "a", title: "Groceries" }]) .mockResolvedValueOnce([{ id: "a", title: "Groceries" }])
+4 -10
View File
@@ -7,17 +7,11 @@ export interface TitleEntry {
title: string; title: string;
} }
// Owner's {id, name} index of every non-trashed note. // Owner's {id, name} index of every non-trashed note, listed by the command palette
// so someone can jump straight to a note by name.
// //
// Outlived [[wiki-links]] (note 2897), which is what it was originally built for, // Fetched again every time the palette opens, so a note written since is findable.
// because the command palette lists it so someone can jump straight to a note by // The last list stays in place meanwhile, so the palette never opens empty.
// name. That is recall, which is what this app is for; `resolve()` went with the
// links.
//
// Fetched again every time the palette opens. It used to load once per session
// behind a `loaded` flag, with nothing ever clearing it, so a note written after
// the first open could not be found by name until a page reload (#5165). The last
// list stays in place meanwhile, so the palette never opens empty.
export const useTitlesStore = defineStore("titles", () => { export const useTitlesStore = defineStore("titles", () => {
const items = ref<TitleEntry[]>([]); const items = ref<TitleEntry[]>([]);
+1 -1
View File
@@ -53,7 +53,7 @@ export const useUiStore = defineStore("ui", () => {
run?.(); run?.();
} }
// The note whose Share dialog is open (#5174), or null. One dialog for the app, // The note whose Share dialog is open, or null. One dialog for the app,
// mounted by the shell, so the card and the editor open the same one. // mounted by the shell, so the card and the editor open the same one.
const shareNoteId = ref<string | null>(null); const shareNoteId = ref<string | null>(null);
function openShare(id: string) { function openShare(id: string) {
+48 -76
View File
@@ -44,16 +44,14 @@ body {
} }
} }
/* Motion is a feature, not a given. Anyone whose OS says "reduce motion" has told /* Motion is a feature, not a given. Anyone whose OS says "reduce motion" gets
* us something about vestibular comfort or attention, and the answer is to arrive * instant arrival rather than faster animation.
* instantly rather than to animate faster.
* *
* Global and blunt on purpose: it catches every Tailwind `transition` utility * Global and blunt on purpose: it catches every Tailwind `transition` utility and
* already scattered through the components, and it will catch the M7 motion work * every new component without each having to remember. Near-zero rather than `none`
* without each new component having to remember. Near-zero rather than `none` so * so `transitionend`/`animationend` listeners still fire and no state machine that
* `transitionend`/`animationend` listeners still fire and no state machine that * waits on them can hang. JS-driven motion reads the same preference through
* waits on them can hang. JS-driven motion can't be reached from CSS — that reads * `useReducedMotion`. */
* the same preference through `useReducedMotion`. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
@@ -66,20 +64,17 @@ body {
} }
/* Controls that a card or row reveals on hover — the note toolbar, the drag grip, /* 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, * per-item delete buttons.
* 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 * A finger cannot hover, and for a note card the hover toolbar is the ONLY way to
* reach", they are unreachable — and for a note card the hover toolbar is the ONLY * pin, colour or archive. So where hovering is impossible they are always present.
* way to pin, colour or archive. So where hovering is impossible they are simply * `@media (hover: none)` asks the device directly rather than inferring from width:
* always present. `@media (hover: none)` asks the device directly, which is more * a small laptop window still hovers, and a large tablet still doesn't.
* 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 * Placed after the Tailwind directives so it wins on source order against the
* `opacity-0` / `pointer-events-none` utilities it overrides, without !important. * `opacity-0` / `pointer-events-none` utilities it overrides, without !important.
* The `group-hover:` reveal stays in the markup — the trigger differs per component * The `group-hover:` reveal stays in the markup, since the trigger differs per
* (named groups), only this fallback is shared. */ * component (named groups); only this fallback is shared. */
@media (hover: none) { @media (hover: none) {
.hover-reveal { .hover-reveal {
opacity: 1; opacity: 1;
@@ -87,25 +82,15 @@ body {
} }
} }
/* A note card's own controls — the drag grip and the pin/colour/archive/trash set. /* 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 * as ONE element each in TWO placements. Always-visible controls floating over the
* the card's top corners" cannot both be true without the controls sitting on top of * card's top corners would sit on the note's own words.
* the note's own words.
* *
* That is exactly what a phone got. `.hover-reveal` above made the pills permanent * So the default (no hover) is a footer row IN FLOW, which cannot overlap anything.
* where nothing can hover (task 2697, correctly — they are the only way to pin or * A pointer that can hover lifts them out into floating pills, which cost no
* archive), but they were still absolutely positioned, so they covered the title: * vertical space and appear only on approach. `display: contents` drops the flex
* "thought sync tauri app" rendered as "ught sync tauri app" with the grip parked on * row out of the way so each child positions against the card. Keyed on hover
* the first three characters. * rather than width, for the reason `.hover-reveal` gives. */
*
* 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 { .note-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -140,23 +125,18 @@ body {
} }
} }
/* Board motion (M7). Defined once here rather than three times in BoardView's /* Board motion. Defined once here because the pinned, other and non-board grids are
* markup, because "how the board moves" is one idea even though the pinned, other * three TransitionGroups that move one way.
* and non-board grids are three TransitionGroups.
* *
* Vue's TransitionGroup does the FLIP itself: it measures each card before and * Vue's TransitionGroup does the FLIP itself; this supplies only the curve, fast and
* after the list changes and transitions the difference away. All we supply is the * small, so a card reads as having MOVED rather than performed.
* curve. Deliberately fast and small — the brief is continuity, so a card should
* read as having MOVED rather than as having performed.
* *
* Leavers are NOT taken out of flow with `position: absolute`, which is the usual * Leavers are NOT taken out of flow with `position: absolute`, the usual trick: this
* TransitionGroup trick: this masonry is CSS multi-column, and an absolutely * masonry is CSS multi-column, and an absolutely positioned child escapes its column
* positioned child escapes its column to the container's origin — it would fly * to the container's origin. Staying in flow costs a small settle on removal, so the
* across the board on its way out. Keeping them in flow costs a small settle when * leave is the shortest of the three.
* the element is finally removed, so the leave is the shortest of the three.
* *
* Reduced motion needs no special case here: the global guard above already * Reduced motion needs no case here: the global guard above collapses every duration. */
* collapses every duration, so this degrades to an instant cut. */
.board-move { .board-move {
transition: transform 220ms cubic-bezier(0.2, 0, 0, 1); transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
} }
@@ -177,14 +157,13 @@ body {
opacity: 0; opacity: 0;
} }
/* Editor open/close (M7). The backdrop only fades; the panel scales from the point /* Editor open/close. The backdrop only fades; the panel scales from the point the
* the card occupied, which is what carries "this card became the editor" without * card occupied, which carries "this card became the editor" without distorting the
* the distortion a true card→panel scale would put through the text. * text the way a true card→panel scale would.
* *
* Enter is slower than leave on purpose: arriving wants to be noticed as a * Enter is slower than leave: arriving should read as a connection, leaving should
* connection, leaving just wants to be out of the way. LEAVE_MS in NoteEditor.vue * get out of the way. LEAVE_MS in NoteEditor.vue must match the leave duration here;
* must stay in step with the leave duration here — it waits that long before * it waits that long before telling its host to unmount it. */
* telling its host to unmount it. */
.editor-enter-active { .editor-enter-active {
transition: opacity 200ms ease-out; transition: opacity 200ms ease-out;
} }
@@ -206,10 +185,9 @@ body {
transform: scale(0.94); transform: scale(0.94);
} }
/* Lens cross-fade (M7). Only fires between genuinely different surfaces; the /* Lens cross-fade. Fires only between genuinely different surfaces; the board's own
* board's own lenses share a component and reflow in place instead. Out is quicker * lenses share a component and reflow in place. Out is quicker than in because
* than in so the gap between them stays short — `mode="out-in"` means the two * `mode="out-in"` adds the two durations, and anything slower feels like waiting. */
* durations are additive, and anything slower starts to feel like waiting. */
.lens-enter-active { .lens-enter-active {
transition: opacity 150ms ease-out; transition: opacity 150ms ease-out;
} }
@@ -250,22 +228,16 @@ body {
@apply inline-flex min-h-[2.25rem] items-center justify-center px-3; @apply inline-flex min-h-[2.25rem] items-center justify-center px-3;
} }
} }
/* THE button shape — the ONE definition of it in the app. /* THE button shape, its one definition in the app.
* *
* It lives here, in the components layer, rather than inside BaseButton.vue, * It lives in the components layer rather than in BaseButton.vue because not every
* because not every button in this app is a <button>. A DOWNLOAD has to be an * button is a <button>. A download has to be an anchor: only an <a> carries an
* anchor: these are 3-95 MB installers, only an <a> can carry an href, and the * href, and the browser's download manager handles 3–95 MB installers better than
* browser's own download manager handles that transfer better than anything the * fetching to a blob. BaseButton.vue wears these, and so does any anchor that must
* app would do by fetching to a blob. BaseButton cannot serve that case, and a * read as a button, so the two can't drift apart.
* second copy of its class list for anchors is how a page ends up with two
* kinds of primary button that drift apart.
* *
* So: BaseButton.vue wears these, and so does any anchor that must read as a * The `disabled:` variants are not here because an anchor has no :disabled;
* button. Neither owns the look. * BaseButton adds them itself.
*
* The `disabled:` variants are NOT here on purpose — an anchor has no
* :disabled. BaseButton adds them itself, which is exactly the split: shared
* where it is shared, local where the element differs.
*/ */
.btn { .btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm
+2 -2
View File
@@ -19,7 +19,7 @@ import { roughSize } from "../size";
// The signed-in person's own account (not admin): the native clients linked to it // The signed-in person's own account (not admin): the native clients linked to it
// (the desktop and Android apps sync with a device bearer token issued here), its // (the desktop and Android apps sync with a device bearer token issued here), its
// password, and signing out everywhere else (family idea #5105, practice 4). // password, and signing out everywhere else.
const devices = useDevicesStore(); const devices = useDevicesStore();
const session = useSessionStore(); const session = useSessionStore();
const ui = useUiStore(); const ui = useUiStore();
@@ -50,7 +50,7 @@ async function load() {
} }
} }
// Attachment storage (#2939 §4): how much this account uses, against its limit. // Attachment storage: how much this account uses, against its limit.
const storage = ref<StorageUse | null>(null); const storage = ref<StorageUse | null>(null);
function storageLine(use: StorageUse): string { function storageLine(use: StorageUse): string {
+4 -4
View File
@@ -129,7 +129,7 @@ function onBoardKey(e: KeyboardEvent) {
e.preventDefault(); e.preventDefault();
openEditor(note); openEditor(note);
} else if (key === "x" && !inTrash.value && isOwnNote(note)) { } else if (key === "x" && !inTrash.value && isOwnNote(note)) {
// Trash is the owner's; archive and pin below are everyone's own (#5176). // Trash is the owner's; archive and pin below are everyone's own.
e.preventDefault(); e.preventDefault();
void notes.trash(note.id); void notes.trash(note.id);
} else if (key === "e" && !inTrash.value) { } else if (key === "e" && !inTrash.value) {
@@ -295,9 +295,9 @@ const gridBinds = computed(() => ({
</script> </script>
<template> <template>
<!-- `pb-[…safe-area-inset-bottom]`: the board is the last thing on the page, so <!-- `pb-[…safe-area-inset-bottom]` on top of py-6: the board is the last thing on
it is what ends up underneath the gesture bar once `viewport-fit=cover` lets the page, so it is what sits under the gesture bar once `viewport-fit=cover`
the page draw there. Added to the existing py-6 rather than replacing it. --> lets the page draw there. -->
<div class="mx-auto w-full max-w-6xl px-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] pt-6"> <div class="mx-auto w-full max-w-6xl px-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] pt-6">
<button <button
v-if="isMainBoard" v-if="isMainBoard"
+1 -1
View File
@@ -7,7 +7,7 @@ import BaseInput from "../components/BaseInput.vue";
import AuthLayout from "../components/AuthLayout.vue"; import AuthLayout from "../components/AuthLayout.vue";
import BaseButton from "../components/BaseButton.vue"; import BaseButton from "../components/BaseButton.vue";
// Ask for a password reset link by email (#5266). The server answers the same way // Ask for a password reset link by email. The server answers the same way
// whether or not the address has an account, so this page does too. // whether or not the address has an account, so this page does too.
const route = useRoute(); const route = useRoute();
+1 -1
View File
@@ -92,7 +92,7 @@ async function submit() {
"I can't sign in" is a bug report like any other and it needs a build "I can't sign in" is a bug report like any other and it needs a build
number; requiring a login to read one would withhold it from exactly the number; requiring a login to read one would withhold it from exactly the
people who cannot get past this page. `/api/config` is public, so this people who cannot get past this page. `/api/config` is public, so this
costs nothing that was not already public (#3181). --> costs nothing that was not already public. -->
<p <p
class="mt-8 select-all text-center text-[11px] text-neutral-400 dark:text-neutral-500" class="mt-8 select-all text-center text-[11px] text-neutral-400 dark:text-neutral-500"
:title="`Inkwell server build ${config.version || 'unknown'}`" :title="`Inkwell server build ${config.version || 'unknown'}`"
+7 -9
View File
@@ -28,11 +28,10 @@ onMounted(load);
</script> </script>
<template> <template>
<!-- max-w-6xl, matching the board and every other lens: this is the same masonry <!-- max-w-6xl, matching the board and every other lens: this is the same masonry,
now, and a narrower column would reintroduce the "different page" feeling the and a narrower column would make it feel like a different page. -->
card unification just removed. -->
<div class="mx-auto w-full max-w-6xl px-4 py-6"> <div class="mx-auto w-full max-w-6xl px-4 py-6">
<!-- Titled by the shell's persistent lens name (task 1913). --> <!-- Titled by the shell's persistent lens name. -->
<div class="mb-2 flex items-center justify-end gap-3"> <div class="mb-2 flex items-center justify-end gap-3">
<button <button
v-if="reminders.osSupported && !reminders.osEnabled" v-if="reminders.osSupported && !reminders.osEnabled"
@@ -52,11 +51,10 @@ onMounted(load);
title="No reminders" title="No reminders"
subtitle="Set a reminder on a note (in its editor) to see it here." subtitle="Set a reminder on a note (in its editor) to see it here."
/> />
<!-- The same cards, same grid, same motion as the board — reminders is a lens <!-- The same cards, grid and motion as the board: reminders is a lens on those
on those notes, not its own kind of thing (task 1913). Done/snooze moved notes, not its own kind of thing. Done/snooze sit on the card for one-tap
onto the card itself, so one-tap triage survived the change; `load` on triage; `load` on reminder-changed because this list is fetched separately
reminder-changed because this list is fetched separately from the store from the store and a cleared note has to leave it. -->
and a cleared note has to leave it. -->
<NoteGrid v-else :notes="items" @open="openEditor" @reminder-changed="load" /> <NoteGrid v-else :notes="items" @open="openEditor" @reminder-changed="load" />
</AsyncState> </AsyncState>
+2 -2
View File
@@ -9,8 +9,8 @@ import BaseInput from "../components/BaseInput.vue";
import AuthLayout from "../components/AuthLayout.vue"; import AuthLayout from "../components/AuthLayout.vue";
import BaseButton from "../components/BaseButton.vue"; import BaseButton from "../components/BaseButton.vue";
// Where a password reset link an admin made lands (#5173). Setting the password signs // Where a password reset link lands. Setting the password signs this browser in and
// this browser in and every other session and linked app out. // every other session and linked app out.
const session = useSessionStore(); const session = useSessionStore();
const config = useConfigStore(); const config = useConfigStore();
+3 -3
View File
@@ -86,9 +86,9 @@ function describe(c: Compatibility): string {
return c.reason; return c.reason;
} }
// --- App updates (M10.9). Independent of sync: an unlinked, server-less install // --- App updates. Independent of sync: an unlinked, server-less install still
// still updates itself, which is why the feed is the release host and not an // updates itself, which is why the feed is the release host and not an Inkwell
// Inkwell server. --- // server. ---
const channel = ref<UpdateChannel>("stable"); const channel = ref<UpdateChannel>("stable");
const update = ref<UpdateStatus | null>(null); const update = ref<UpdateStatus | null>(null);
const checking = ref(false); const checking = ref(false);
+2 -3
View File
@@ -102,9 +102,8 @@ onMounted(load);
<template> <template>
<div class="mx-auto w-full max-w-6xl px-4 py-6"> <div class="mx-auto w-full max-w-6xl px-4 py-6">
<!-- No <h1> here: the shell's persistent bar names the active lens (task 1913), <!-- No <h1> here: the shell's persistent bar names the active lens, so a per-view
so a per-view title would say the same thing twice, in a place that scrolls title would say the same thing twice, in a place that scrolls away. -->
away. -->
<div class="mb-1 flex flex-wrap items-center justify-end gap-3"> <div class="mb-1 flex flex-wrap items-center justify-end gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm"> <div class="flex flex-wrap items-center gap-2 text-sm">
<label class="flex items-center gap-1.5 text-neutral-500 dark:text-neutral-400"> <label class="flex items-center gap-1.5 text-neutral-500 dark:text-neutral-400">