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">
<head>
<meta charset="UTF-8" />
<!-- `viewport-fit=cover` is what makes `env(safe-area-inset-*)` resolve to
anything other than 0. It opts the page into drawing under the notch and the
gesture bar, so it is only safe alongside the padding that keeps content out
of them — see style.css (sides), the sticky header (top) and the board
(bottom). The two halves ship together, deliberately: turning this on alone
is worse than leaving it off (task 2706). -->
<!-- `viewport-fit=cover` makes `env(safe-area-inset-*)` non-zero and draws the page
under the notch and gesture bar. Only safe with the padding that keeps content
out of them: style.css (sides), the sticky header (top) and the board (bottom). -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#F5C518" />
<meta
+4 -9
View File
@@ -1,12 +1,7 @@
// Inkwell service worker — installable shell, deliberately NOT offline-first.
//
// This exists so the web app satisfies the PWA install criteria ("Add to Home
// Screen") and shows a friendly page when a navigation happens with no network.
// 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).
// 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
// apps are): it caches only the offline page, so an installed copy is always the live
// web app.
const CACHE = "inkwell-shell-v1";
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
// Tauri command backed by the on-device SQLite store (desktop/src-tauri/src/local).
// Selected by index.ts when running inside the desktop shell with no server.
// The desktop implementation of the repository seam: each operation is a Tauri
// command over the on-device SQLite store (desktop/src-tauri/src/local).
//
// 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
// yet (account auth, device linking) — those 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.
// parameters (labelIds -> label_ids). Operations with no offline meaning (account
// auth, device linking) reject with a clear message.
import { invoke } from "../desktop/bridge";
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 }),
setLabels: (id, labelIds) => invoke<Note>("notes_set_labels", { id, labelIds }),
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
// only ASCII.
uploadAttachment: async (id, file) =>
@@ -91,7 +88,7 @@ export const local: Repo = {
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.
shares: {
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
// instead of reaching for the HTTP client directly, so the SAME UI can run
// against the REST backend (web + a server-connected desktop) or a fully-local
// 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.
// The data-source seam. Stores and views talk to this interface, so the same UI runs
// against the REST backend (`rest.ts`) or the desktop's on-device store (`local.ts`,
// over Tauri `invoke`); `index.ts` picks one.
//
// Keep this interface a thin, semantic mirror of the current calls: every method
// maps 1:1 to a backend operation and returns the same shape the stores already
// consume. Client-side logic (list reconciliation, optimistic updates, toasts)
// stays in the stores — the repo is data access only.
// Each method maps 1:1 to a backend operation. Client-side logic (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 { 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. */
export interface Member {
@@ -54,7 +50,7 @@ export interface Member {
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 {
id: string;
name: string;
+4 -8
View File
@@ -1,8 +1,6 @@
// REST implementation of the repository seam: maps each semantic operation to the
// existing HTTP endpoint via `api/client`. This is the ONLY place that knows about
// URL paths, query strings, and multipart bodies. Behaviour here must stay a
// 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.
// The REST implementation of the repository seam: each operation is an HTTP endpoint
// via `api/client`. The only place that knows URL paths, query strings and multipart
// bodies.
import { api } from "../api/client";
import type { Note, NoteRevision } from "../stores/notes";
@@ -26,9 +24,7 @@ import type {
StorageUse,
} from "./repo";
// Render a board query to the GET /api/notes query string. Mirrors the param
// 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).
// Render a board query to the GET /api/notes query string.
function notesQuery(q: NoteListQuery): string {
const params = new URLSearchParams();
params.set("filter", q.view);
+3 -3
View File
@@ -6,9 +6,9 @@ import { appLink } from "../router/links";
import OneTimeSecret from "./OneTimeSecret.vue";
import { useRowAction, useLoad } from "../composables/useAction";
// 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
// when the server can send email (#5266).
// Admin: the accounts on this instance, and a password reset link for any of them.
// The admin hands the link over; a person can also mail one to themselves when the
// server can send email.
const session = useSessionStore();
+1 -1
View File
@@ -6,7 +6,7 @@ import { formatShortDateTime } from "../notes/datetime";
import { useLoad } from "../composables/useAction";
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).
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.
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
// empty space is a bug that reads as a design choice. Note 3127 §5: with version
// 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.
// In the browser it is the server's version; in the desktop shell `config_get`
// returns the desktop build's own. A linked server's version is shown on Sync.
const buildVersion = computed(() => config.version || "unknown");
const buildLabel = computed(
() => `Inkwell ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
@@ -55,7 +48,7 @@ const buildLabel = computed(
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.
const viewer = computed(() => session.user?.id ?? "local");
const tagViews = ref<TagViews>({});
@@ -209,16 +202,8 @@ onBeforeUnmount(() => {
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);
/**
* Search is a FACET, not a destination.
*
* 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.
* 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.
*/
function onSearch(value: string) {
searchText.value = value;
@@ -233,9 +218,8 @@ function onSearch(value: string) {
}, 250);
}
// The URL is the filter state (see notes/facets.ts), so the box READS from it rather
// than holding its own copy — which is also what keeps it in step with the Filters
// panel's Clear button.
// The URL is the filter state (notes/facets.ts), so the box reads from it rather than
// holding a copy, and the Filters panel's Clear button clears it too.
watch(
() => route.query.q,
(q) => {
@@ -260,26 +244,21 @@ async function signOut() {
<template>
<div class="flex min-h-full flex-col">
<!-- First tab stop on every page. The header and sidebar are a dozen-odd tab
stops that repeat on every navigation; without this a keyboard user walks
all of them again to reach their own notes. Hidden until focused. -->
<!-- First tab stop on every page, so a keyboard user can skip the header and
sidebar. Hidden until focused. -->
<a
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"
>
Skip to notes
</a>
<!-- `pt-[env(safe-area-inset-top)]`: with `viewport-fit=cover` the page draws
under the status bar / notch, and this is the one box that sits there — a
sticky header pinned to y=0. Resolves to 0 wherever there is no cutout, so
it costs nothing on a desktop or a flat-topped phone. -->
<!-- `pt-[env(safe-area-inset-top)]`: the page draws under the status bar, and this
sticky header is the box that sits there. 0 where there is no cutout. -->
<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"
>
<!-- Wraps, so the search field can take a line of its own on a narrow screen.
In one row it was sharing ~360px with a menu button, the logo, the lens
name and four icons, which left every one of them truncated — the lens read
as "N…" and the search box as an empty pill. -->
<!-- Wraps, so the search field takes a line of its own on a narrow screen
instead of truncating everything in one row. -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
<button
type="button"
@@ -306,25 +285,17 @@ async function signOut() {
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
</RouterLink>
<!-- The active lens, named in the persistent chrome rather than inside each
view. Which lens you're looking at is a property of the SPACE, not of a
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. -->
<!-- The active lens, named in the bar that never moves, beside the app name,
so it stays put while everything beneath re-filters. -->
<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
hidden on narrow screens. There, the lens name simply takes the space
the app name vacated — you already know which app you're in. -->
<!-- The separator goes with the app name, which is hidden on narrow screens. -->
<span aria-hidden="true" class="hidden sm:inline">/</span>
<span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span>
</span>
<!-- `order-last w-full` drops this onto its own line below sm (a full-width
flex item forces the wrap); from sm it returns to the middle of the row.
ONE input either way, moved by CSS rather than duplicated — the `/`
shortcut focuses `searchInput`, and two of those would be one ref too
many. -->
<!-- `order-last w-full` puts this on its own line below sm; from sm it returns
to the middle of the row. One input, moved by CSS, because the `/`
shortcut focuses `searchInput`. -->
<div class="order-last flex w-full justify-center sm:order-none sm:w-auto sm:flex-1">
<input
ref="searchInput"
@@ -346,10 +317,8 @@ async function signOut() {
>
<Icon name="plus" /> <span class="hidden sm:inline">New</span>
</button>
<!-- Whose account this is only means something when there IS an account.
The desktop signs in as a synthetic local user so the shared router's
auth guard resolves; naming it would invent a login the app doesn't
have. -->
<!-- Not on the desktop, which signs in as a synthetic local user only so the
router's auth guard resolves. -->
<span
v-if="!desktopApp"
class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400"
@@ -364,10 +333,8 @@ async function signOut() {
>
<Icon name="sync" />
</RouterLink>
<!-- Server-side concept: it lists the tokens a server has issued to native
clients. The desktop IS one of those clients, so here the list is always
empty and issuing a token rejects — its own relationship with a server
lives under /sync instead. -->
<!-- The tokens a server has issued to native clients. The desktop is one, so
it has none; its own link to a server is under /sync. -->
<RouterLink
v-if="!desktopApp"
to="/account"
@@ -386,11 +353,8 @@ async function signOut() {
>
<Icon name="settings" />
</RouterLink>
<!-- Hidden on the desktop, where it was a trap rather than an action:
logout nulls the synthetic local user and redirects to /login, but the
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. -->
<!-- Hidden on the desktop: there is no account to sign out of, and the
offline adapter rejects every sign-in, so there would be no way back. -->
<button
v-if="!desktopApp"
type="button"
@@ -444,9 +408,8 @@ async function signOut() {
<ExportNotes />
<ImportNotes />
<!-- Tags last: it is the one list here that grows without limit, so
everything with a fixed place sits above it and stays put. And it
shows only the few tags in use (#5931), the rest one press away. -->
<!-- Tags last: the one list that grows, so everything fixed sits above it. It
shows the few tags in use, the rest one press away. -->
<div class="mt-3 flex items-center justify-between px-3 pb-1">
<span class="section-label">Tags</span>
<button
@@ -483,13 +446,8 @@ async function signOut() {
{{ allTags ? "Fewer tags" : `All tags (${labels.items.length})` }}
</button>
<!-- Account, settings and sign-out, for the screens where they are NOT in
the header. Four icons plus a search field never fit one phone-width
row, and the header is the wrong place to lose: it holds the only way
back to the board. Here they get room to be named instead of guessed
at from a glyph.
Hidden from sm up, where the header carries them again — so they are
in exactly one place at any width, never both. -->
<!-- Account, settings and sign-out on phone widths, where they don't fit the
header. Hidden from sm up, where the header carries them. -->
<div
v-if="!desktopApp"
class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
@@ -512,10 +470,8 @@ async function signOut() {
</div>
</nav>
<!-- The build. `mt-auto` puts it at the foot of the rail when the nav is
short and lets it simply follow when the nav has scrolled.
`select-all` because the one thing anybody does with this is copy it
into a bug report. -->
<!-- The build, at the foot of the rail (`mt-auto`). `select-all` because it
gets copied into bug reports. -->
<p
class="mt-auto select-all px-3 pt-6 text-[11px] text-neutral-400 dark:text-neutral-500"
:title="buildLabel"
@@ -524,17 +480,10 @@ async function signOut() {
</p>
</aside>
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
the viewport — several browsers scroll to a plain anchor without focusing
it, which leaves the next Tab back at the top of the page. -->
<!-- Cross-fade between lenses so switching reads as the same space
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. -->
<!-- tabindex="-1" so the skip link moves focus here, not just the viewport. -->
<!-- Cross-fade between views. Unkeyed: board, archive, trash and tag views all
render BoardView, and keying on the route would remount and refetch it.
Between those lenses NoteGrid animates an in-place reflow. -->
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
<RouterView v-slot="{ Component }">
<Transition name="lens" mode="out-in">
+1 -1
View File
@@ -1,5 +1,5 @@
<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
// `retry`); otherwise renders the default slot (the caller decides empty vs content).
defineProps<{ loading: boolean; error?: string; errorTitle?: string }>();
+1 -1
View File
@@ -7,7 +7,7 @@ import { roughSize } from "../size";
import { useUiStore } from "../stores/ui";
// 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 {
started_at: string;
+2 -5
View File
@@ -11,11 +11,8 @@ withDefaults(
</script>
<template>
<!-- The look comes from `.btn` + a variant in style.css, NOT from here. A
download has to be an <a> (only an anchor can carry an href and hand the
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. -->
<!-- The look is `.btn` + a variant in style.css, so a download <a> can wear it too.
The disabled: variants stay here, since an anchor has no :disabled. -->
<button
:type="type"
: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
// 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
// open, Tab is trapped inside, and focus returns to the opener on close. Doing it
// here rather than per-modal is why CommandPalette's own input focus is the only
// bespoke focus handling left.
// Owns the dialog's focus: it moves in on open, Tab is trapped inside, and focus
// returns to the opener on close.
//
// NoteEditor deliberately does NOT use this — its backdrop mousedown is drag-guarded and
// its Escape/⌘-Enter handling is bespoke (unsaved-edit safety), so it keeps its own shell.
// NoteEditor doesn't use this: its backdrop mousedown is drag-guarded and its
// Escape/⌘-Enter handling protects unsaved edits.
import { onBeforeUnmount, onMounted, ref } from "vue";
import Icon from "./Icon.vue";
@@ -54,9 +52,8 @@ function focusable(): HTMLElement[] {
/**
* Keep Tab inside the panel.
*
* `aria-modal="true"` tells assistive tech the rest of the page is inert, but it
* doesn't enforce anything — without this, Tab walks straight out into content the
* user has just been told isn't there.
* `aria-modal="true"` tells assistive tech the rest of the page is inert but doesn't
* enforce it.
*/
function onTab(e: KeyboardEvent): void {
const items = focusable();
@@ -80,9 +77,8 @@ function onTab(e: KeyboardEvent): void {
onMounted(() => {
restoreTo = document.activeElement as HTMLElement | null;
// The fix for more than tab order: Escape is handled ON the panel, so until
// focus is actually inside it, Escape did nothing at all in every modal that
// doesn't focus its own input (LabelsModal, the integration prompt, shortcuts).
// Escape is handled on the panel, so focus has to be inside it for Escape to work
// in modals that don't focus their own input.
const target = focusable()[0] ?? panel.value;
target?.focus();
});
@@ -98,9 +94,8 @@ onBeforeUnmount(() => restoreTo?.focus?.());
:class="align === 'center' ? 'items-center' : 'items-start pt-[12vh]'"
@mousedown.self="emit('close')"
>
<!-- tabindex="-1" so the panel itself can hold focus when it contains nothing
focusable — without it, focus would stay behind the overlay and Escape,
which is handled here, would never fire. -->
<!-- tabindex="-1" so the panel can hold focus when it contains nothing focusable;
Escape is handled here. -->
<div
ref="panel"
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">
// 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
// stops being a product. So this LEADS with the download that fits the machine
// asking and keeps the rest quiet but visible — nothing is behind a disclosure,
// because a wrong guess must cost a person nothing.
// Leads with the download that fits the machine asking and keeps the rest visible
// but quiet, so a wrong guess costs nothing.
import { computed } from "vue";
import { useConfigStore, type ClientRelease } from "../stores/config";
import { roughSize } from "../size";
@@ -16,13 +14,10 @@ type Family = "android" | "windows" | "linux" | "mac" | "ios" | "other";
/**
* Which OS is asking, from the user agent.
*
* ORDER IS THE WHOLE ALGORITHM. Android's UA contains "Linux", an iPad's contains
* "Mac OS X", and a Chromebook's contains "X11" — so each narrow test has to run
* before the broad one that would otherwise swallow it.
* Order matters: Android's UA contains "Linux", an iPad's "Mac OS X" and a
* Chromebook's "X11", so each narrow test runs before the broad one.
*
* `navigator.userAgent` rather than `userAgentData`: the reduced UA Chrome now
* 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.
* `navigator.userAgent`, since Chrome's reduced UA still carries the platform token.
*/
function detectFamily(ua: string): Family {
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.
//
// Linux gets all three because the UA says "Linux" and nothing about dpkg or
// pacman — there is no more specific answer to be had, so the three are named for
// 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.
// Linux gets all three: the UA doesn't say which distro. macOS and iOS lead with
// nothing, as there is no build for either (see `missingPlatform`).
const LEAD: Record<Family, string[]> = {
android: ["android"],
windows: ["windows"],
@@ -102,10 +93,9 @@ const missingPlatform = computed(() =>
!lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "",
);
// The one-command Linux install (milestone 325 step 5), shown wherever the server
// holds a Linux client to install. Against the address this page was reached on,
// which is the one the person can evidently reach; the script the server sends
// back carries Settings → Public address when that is set.
// The one-command Linux install, shown when the server holds a Linux client. Uses the
// address this page was reached on; the script itself carries Settings → Public
// address when that is set.
const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-")));
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">
<p class="text-sm text-neutral-800 dark:text-neutral-100">{{ client.label }}</p>
<p class="mt-0.5 text-xs text-neutral-400">
<!-- `unknown` rather than a blank or a plausible default: with no
second source to contradict it, a wrong version here is a wrong
answer nothing can catch. Not knowing which build it is, is also
not a reason to withhold the download. -->
<!-- `unknown` rather than a blank or a plausible default; the download
is offered either way. -->
Version {{ client.version || "unknown" }} · {{ roughSize(client.size, "MB") }}<span
v-if="client.platform === 'linux-appimage'"
>, and the only one that updates itself in place</span
>
</p>
</div>
<!-- An anchor, never BaseButton and never a fetch: these are 3–95 MB and
the browser's own download manager handles the transfer better than
anything this app would do with a blob. It wears `.btn` — the same
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. -->
<!-- An anchor, not a fetch: the browser's download manager handles 3–95 MB
better than a blob. It wears `.btn`, like BaseButton. `download` has no
filename because the server's Content-Disposition names the file. -->
<a
:href="client.url"
download
+1 -4
View File
@@ -6,10 +6,7 @@ import { useUiStore } from "../stores/ui";
import { useAction } from "../composables/useAction";
// 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.
//
// 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.
// Renders nothing anywhere else: the web, Windows, or an AppImage-less Linux build.
const ui = useUiStore();
const status = ref<IntegrationStatus | null>(null);
const { busy, act } = useAction();
+1 -1
View File
@@ -9,7 +9,7 @@ import Icon from "./Icon.vue";
import { useRowAction, useLoad } from "../composables/useAction";
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
// 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">
// 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 }>();
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 { 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,
// 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 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.
const config = useConfigStore();
const sendEmail = ref(true);
+4 -7
View File
@@ -2,14 +2,11 @@
import type { InlineToken } from "../notes/markdown";
import { tagTextClasses } from "../notes/colors";
// Emphasis, code, and `#tags`. `[[wiki-links]]` were the one token type that needed a
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
// of that.
// Emphasis, code, and `#tags`.
//
// `tagColors` maps a lowercased tag name to the colour stored on that label. Threaded
// down from the card rather than looked up here, because this component renders text
// and has no idea which note the text belongs to — and a tag the operator recoloured
// must read the same here as it does on a chip.
// `tagColors` maps a lowercased tag name to its label's colour, passed down from the
// card (this component doesn't know which note it renders), so an inline tag matches
// its chip.
defineProps<{ tokens: InlineToken[]; tagColors?: Record<string, string> }>();
</script>
+2 -4
View File
@@ -10,10 +10,8 @@ const props = defineProps<{
toggleable?: boolean;
tagColors?: Record<string, string>;
}>();
// Ticking a box rewrites a line of the note's body, which is a thing only the owner
// of that note can do — so this renders the checkbox and hands the intent up rather
// than reaching for the store itself. The card wires it; a read-only render does not
// pass `toggleable` and the boxes are inert.
// Ticking a box rewrites a line of the body, so this emits the intent rather than
// touching the store. The card wires it; without `toggleable` the boxes are inert.
const emit = defineEmits<{ toggle: [index: number, checked: boolean] }>();
const blocks = computed(() => parseMarkdown(props.text));
</script>
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
// 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.
// The default slot sits after Share, for the editor's history toggle.
const props = defineProps<{ note: Note; own: boolean; canShare: boolean }>();
+46 -113
View File
@@ -38,10 +38,9 @@ const emit = defineEmits<{
const notes = useNotesStore();
const config = useConfigStore();
// --- Sharing (#5174). Someone else's note shows who shared it and offers none of
// the owner's controls; at `view` its checkboxes are inert too. The desktop offers
// Share as well: linked it goes through the server, unlinked the dialog says sharing
// needs one (#5175). ---
// --- Sharing. Someone else's note shows who shared it and offers none of the
// owner's controls; at `view` its checkboxes are inert too. On an unlinked desktop
// the Share dialog says sharing needs a server. ---
const own = computed(() => isOwnNote(props.note));
const editable = computed(() => canEditText(props.note));
const canShare = own;
@@ -53,9 +52,8 @@ const sharedLine = computed(() => {
return props.note.shared ? "Shared" : "";
});
// --- Retention countdown. A note in Trash is on a clock, and the card is the only
// place someone browsing Trash would ever find that out in time to restore it.
// Null whenever nothing is going to happen: not trashed, or retention turned off. ---
// --- Retention countdown, so someone browsing Trash can restore a note in time.
// Null when not trashed or retention is off. ---
const trashDays = computed(() =>
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 otherAttachments = computed(() => props.note.attachments.filter((a) => !rendersInline(a.mime)));
// How much of a note the CARD shows. Android has always clamped to 8
// (`MAX_PREVIEW_LINES`); the web rendered the whole body, so one long note could
// produce a card taller than the screen and push everything else off the board.
// How much of a note the card shows: 8 lines, as on Android (`MAX_PREVIEW_LINES`),
// so one long note can't push the board off the screen.
//
// It matters more now that the title is gone (M13 step 4). The first line used to be
// the thing your eye caught; with one weight throughout, an unbounded card is just a
// 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.
// Clamped in the string rather than with CSS `line-clamp`, which behaves unreliably
// around the block elements MarkdownText emits, and skips parsing what isn't shown.
const PREVIEW_LINES = 8;
// --- Links ------------------------------------------------------------------
//
// A note that is NOTHING but a URL is a link, and its preview is the whole card —
// showing the raw URL underneath a card that already says where it goes is saying the
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and
// gets a compact strip at the bottom instead. What counts as lone is notes/links.ts.
// A note that is nothing but a URL shows its preview as the whole card, without the
// raw URL. A URL inside a note gets a compact strip at the bottom. What counts as
// lone is notes/links.ts.
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
@@ -106,8 +96,8 @@ const bodyPreview = computed(() => {
/** 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
* clamp above because that only ever drops lines from the end. `updateItem` takes
* it as the item id, which is exactly what an id is now (M304). */
* clamp above because that only drops lines from the end. `updateItem` takes it as
* the item id. */
function toggleTask(index: number, checked: boolean) {
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
// source card; `dragOver` shows where the drop will land.
//
// Pointer rather than native HTML5 drag-and-drop because that API never fires from
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
// 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.
// Pointer events rather than HTML5 drag-and-drop, which never fires from touch.
// composables/useCardDrag.ts says why the state is shared. ---
// A shared note moves on the recipient's board only, so anyone may drag it.
const canDrag = () => !!props.reorderable && !props.note.trashed;
const dragging = computed(() => draggingId.value === props.note.id);
const dragOver = computed(() => overId.value === props.note.id);
@@ -140,9 +128,8 @@ function onGripDown(e: PointerEvent) {
startX = e.clientX;
startY = e.clientY;
moved = false;
// Capture so the gesture keeps reporting to this grip even once the finger has
// travelled onto another card — without it the stream stops at the first
// boundary crossing.
// Capture, so the gesture keeps reporting to this grip once the finger is over
// another card.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Claims the gesture from the browser's own scroll/text-selection handling.
e.preventDefault();
@@ -193,15 +180,12 @@ watch(
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
// text still in the note" since M311, so a chip for one printed the same tag twice —
// once where it was typed, once in this row — and the loud copy was the duplicate. A
// 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.
// Only the tags the body isn't showing: `via_tag` means "backed by text in the note",
// and that tag is tinted where it sits (MarkdownInline). The chips are tags lifted off
// their own line and labels added through the picker.
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.
// Folded because `#Todo` and `#todo` are one tag.
const tagColors = computed<Record<string, string>>(() => {
@@ -226,52 +210,16 @@ const tagColors = computed<Record<string, string>>(() => {
]"
:data-note-id="note.id"
>
<!-- THE EDGE IS THE CARD'S BOUNDARY, and since M315 it is the ONLY thing that
varies from the board: the fill is one neutral (NOTE_CARD_SURFACE) and no
longer says anything about the note. That makes this line load-bearing rather
than decorative — it is what a card IS.
It was already neutral before the fill was. The version that came from the
palette was a `{hue}-900` border and failed twice over: the line was the
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. -->
<!-- The edge is the card's boundary: the fill is one neutral (NOTE_CARD_SURFACE),
so this line is what separates a card from the board. #b8b8b8 on white is
1.98:1 and #404040 on #171717 is 1.73:1. An opaque grey, not a translucent
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
body rather than beside it, so they don't compete with its first line, the
note's name. -->
<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
branch used to distinguish a `#tag` from a picker label; it cannot any more,
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. -->
<!-- Every chip carries the `#`, so a lifted chip reads as the `#todo` somebody
typed, as on Android. -->
<span
v-for="lb in chipLabels"
:key="lb.id"
@@ -302,10 +250,8 @@ const tagColors = computed<Record<string, string>>(() => {
</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
controls and those cannot live inside a button — and the card is the same
shape whether or not it happens to carry items today. -->
<!-- A focusable div rather than a <button>, because a checklist nests interactive
controls, which can't live inside a button. -->
<div
role="button"
tabindex="0"
@@ -313,9 +259,8 @@ const tagColors = computed<Record<string, string>>(() => {
@click="emit('open', note)"
@keydown.enter="emit('open', note)"
>
<!-- A lone URL renders as its preview and nothing else. Until the fetch lands
— or if it never does — the URL itself stands in, so the card is never
blank and the link is never unreachable. -->
<!-- A lone URL renders as its preview. Until the fetch lands, or if it never
does, the URL itself stands in. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText
@@ -332,17 +277,13 @@ const tagColors = computed<Record<string, string>>(() => {
Empty note
</p>
</div>
<!-- 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
M13 — which put a stranger's headline where the note's first line should be. -->
<!-- Inline links: a compact strip at the foot of the card, under the note's own
words. -->
<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 />
</div>
<!-- No separate checklist block any more. A checklist is lines of the body (M304),
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. -->
<!-- A checklist is lines of the body, drawn in place by MarkdownText above. -->
<div v-if="sharedLine" class="mt-2">
<span
@@ -376,9 +317,7 @@ const tagColors = computed<Record<string, string>>(() => {
<polyline points="12 6 12 12 16 14" />
</svg>
{{ formatShortDateTime(note.remind_at) }}
<!-- Recurrence was only ever shown in the reminders list; carried onto the
card so unifying them didn't quietly drop it, and so a repeating note
reads as repeating on the board too. -->
<!-- Recurrence, so a repeating note reads as repeating on the board. -->
<span v-if="note.recurrence" :title="`Repeats ${note.recurrence}`">· ↻ {{ note.recurrence }}</span>
</span>
@@ -411,19 +350,13 @@ const tagColors = computed<Record<string, string>>(() => {
</span>
</div>
<!-- The card's controls: the drag grip, then the action set.
Placement is CSS's job, not the markup's — `.note-actions` in style.css puts
them where the device can actually use them. Where a pointer hovers they lift
out of flow into the floating top-corner pills they have always been; where
nothing can hover they stay here, in flow, as a footer row. The alternative
(a permanently visible overlay) sat on top of the note's own title, which is
what a phone was showing.
`hover-reveal` still handles the visibility half — see style.css. -->
<!-- The card's controls: the drag grip, then the action set. `.note-actions` in
style.css places them: floating top-corner pills where a pointer hovers, a
footer row where nothing can. `hover-reveal` handles visibility. -->
<div class="note-actions">
<!-- Reorder is gated behind this grip so a normal click or tap never starts a
drag. Board views only (reorderable). A pointer-only affordance — there is
no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture
to us instead of the browser's scrolling. -->
<!-- Reorder is gated behind this grip so a click or tap never starts a drag.
Board views only. Pointer-only, hence tabindex="-1". `touch-none` takes the
gesture from the browser's scrolling. -->
<button
v-if="canDrag()"
type="button"
+36 -68
View File
@@ -49,11 +49,9 @@ const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
// The tag row. Refreshed from every note the server hands back, not only taken when
// the editor opens: a `#tag` typed into the body becomes a tag on the SERVER's side of
// a save, so the copy taken at mount never learns of it (#5913). The store's own copy
// 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.
// The tag row, refreshed from every note the server hands back: a `#tag` typed into
// the body becomes a tag on the server's side of a save. Not the store's copy, which
// drops a note that leaves the current lens on save.
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
function takeLabels(saved: Note): void {
labelList.value = [...saved.labels];
@@ -124,16 +122,16 @@ const liveNote = computed<Note>(() =>
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? 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";
// 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 —
// this only stops the editor offering what would be refused.
const own = computed(() => isOwnNote(liveNote.value));
const editable = computed(() => canEditText(liveNote.value));
// 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 sharedLine = computed(() => {
if (own.value) return "";
@@ -198,15 +196,9 @@ async function flush(): Promise<void> {
// ---- idle autosave ----
//
// This editor used to write ONLY on close, and the reason was cost: a body write
// snapshotted a revision, so saving often meant a version history of thirty
// snapshots of one paragraph being typed. The price was durability — a tab closed
// 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.
// Writes on an idle pause, so a tab closed mid-paragraph keeps the paragraph. Both
// engines keep one revision per editing session (`revisions.py`, `store.rs`'s
// `should_snapshot`), so frequent writes don't flood the history.
const AUTOSAVE_MS = 1000;
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
@@ -218,9 +210,8 @@ function cancelAutosave(): void {
}
async function autosave(): Promise<void> {
// `flush` returns without writing while a save is in flight, which would silently
// drop everything typed since that save began. Re-arming rather than skipping is
// what keeps that from being a lost paragraph.
// `flush` doesn't write while a save is in flight, so re-arm rather than skip, or
// everything typed since that save began would be lost.
if (saving.value) {
scheduleAutosave();
return;
@@ -228,9 +219,8 @@ async function autosave(): Promise<void> {
try {
await flush();
} catch {
// Swallowed on purpose. An autosave that interrupts typing with an error is
// worse than one that waits for the next pause, and `close` still surfaces a
// real failure at the moment the person is looking at the editor.
// Swallowed: an autosave error shouldn't interrupt typing, and `close` still
// surfaces a real failure.
}
}
@@ -242,11 +232,8 @@ function scheduleAutosave(): void {
}, AUTOSAVE_MS);
}
// EDIT mode only, deliberately. In compose, `dismiss` discards a note that was
// never persisted, so that an accidental keystroke or a type-to-compose never
// 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.
// Edit mode only. In compose, `dismiss` discards a note that was never persisted, so
// a stray keystroke never litters the board; an autosave would create the row.
watch(body, () => {
if (!isCreate.value) scheduleAutosave();
});
@@ -272,14 +259,12 @@ async function commitAndContinue(): Promise<void> {
const first = blocks.value[0];
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
// leave has to play BEFORE the host unmounts us, so the component has to control
// its own visibility and tell the host afterwards.
// Starts TRUE, with `appear` driving the entry animation. Starting false and
// flipping it on mount would be the obvious shape, but the panel lives inside this
// v-if — it wouldn't exist yet to measure.
// Owned here, not by the hosting views: the leave plays before the host unmounts us,
// so the component controls its own visibility and tells the host afterwards.
// Starts true, with `appear` driving the entry; the panel lives inside this v-if, so
// it wouldn't exist yet to measure on mount.
const visible = ref(true);
const panel = ref<HTMLElement | null>(null);
/** Matches .editor-leave-active in style.css. */
@@ -287,15 +272,12 @@ const LEAVE_MS = 140;
onMounted(() => {
const from = takeMorphOrigin();
// Grow from the card that was clicked. Set as a transform-origin rather than
// animating between two rects — see useEditorMorph for why. No origin (compose,
// or a card that scrolled out of view) simply grows from its own centre.
// Grow from the card that was clicked, as a transform-origin (see useEditorMorph).
// With no origin it grows from its own centre.
if (!from || !panel.value) return;
// offsetLeft/offsetTop, NOT getBoundingClientRect: the enter-from class has
// already applied scale(0.94) by now, so the bounding rect is of the SHRUNKEN
// panel and the origin would land a few pixels off. Offsets are layout geometry
// 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.
// offsetLeft/offsetTop, not getBoundingClientRect, which would measure the panel
// already scaled to 0.94. The offset parent is the inset-0 backdrop, so these are
// viewport coordinates, like the captured point.
panel.value.style.transformOrigin =
`${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.
async function close(): Promise<void> {
// Cancelled first: a timer that fires during the leave animation would write
// through a component on its way out, after `flush` has already saved the same
// text.
// Cancelled first, or a timer firing during the leave would write again after
// `flush` saved.
cancelAutosave();
await flush();
await finish();
@@ -557,13 +538,9 @@ async function removeLabel(id: string) {
}
// ---- add a checklist ----
//
// Appends an empty item and puts the caret in it. Unlike every other toolbar button
// this one needs NO persisted note to hang anything off — a checklist is part of the
// body (M304), so it works on an empty compose box the moment it opens.
//
// 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.
// Appends an empty item and puts the caret in it. Needs no persisted note, since a
// checklist is part of the body. Appends rather than inserting at the caret, because
// a block editor has no single caret.
function addChecklist(): void {
const next = plusTask(blocks.value);
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]"
@mousedown.self="onBackdropMousedown"
>
<!-- One editor card for BOTH compose (empty note) and edit — a single surface.
`editor-panel` is the animation's handle: the backdrop only fades, while
this scales from the card that opened it (see useEditorMorph). -->
<!-- One editor card for compose and edit. `editor-panel` is the animation's
handle: the backdrop fades while this scales from the card that opened it. -->
<div
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"
@@ -751,10 +727,8 @@ function revPreview(rev: NoteRevision): string {
device until you remove it.
</p>
<!-- The server unfurls a note's URLs in the background after each save
(`unfurl_queue.py`) and the preview arrives on a later read. Removing one
happens here and nowhere else: the editor has room to offer it, and the
card deliberately doesn't. -->
<!-- The server unfurls a note's URLs after each save and the preview arrives
on a later read. Removing one happens only here, not on the card. -->
<div v-if="liveNote.previews.length" class="flex flex-col gap-2">
<LinkPreview
v-for="p in liveNote.previews"
@@ -851,8 +825,6 @@ function revPreview(rev: NoteRevision): string {
</li>
</ul>
</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">
<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="labelChipClasses(lb)"
>
<!-- `#` on every chip, matching the card. This row still lists the tags
the BODY owns too — it is the control surface, and `via_tag` is what
decides whether there is a cross to remove one with. -->
<!-- `#` on every chip, matching the card. Lists the body's tags too;
`via_tag` decides whether a chip has a cross. -->
#{{ lb.name }}
<button
v-if="!lb.via_tag"
@@ -934,9 +905,6 @@ function revPreview(rev: NoteRevision): string {
</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 gap-0.5">
<button
+5 -14
View File
@@ -3,18 +3,11 @@ import NoteCard from "./NoteCard.vue";
import type { Note } from "../stores/notes";
/**
* The masonry of note cards — the one place the board's LAYOUT and its MOTION are
* defined.
*
* 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.
* The masonry of note cards: the one place the board's layout and motion are
* defined, so every lens is the same board re-filtered.
*
* `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
* list it didn't own.
* grids.
*/
defineProps<{
notes: Note[];
@@ -36,10 +29,8 @@ defineEmits<{
<template>
<!-- `board` is the transition name defined in style.css; `appear` covers first
paint so a lens arrives eased rather than snapping in.
Two columns even on a phone, as the Android board has (Scribe #2950): one
column made a phone's board a list. The gap tightens to match there, since
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. -->
Two columns even on a phone, as on Android, with a tighter gap there.
NoteCard's bottom margin is the vertical half of this gap. -->
<TransitionGroup
tag="div"
name="board"
+1 -1
View File
@@ -3,7 +3,7 @@ import { useUiStore } from "../stores/ui";
import Icon from "./Icon.vue";
// 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(
defineProps<{
+1 -1
View File
@@ -2,7 +2,7 @@
import { onBeforeUnmount, ref, watch } from "vue";
// 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.
//
// <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 { counted } from "../counted";
// Share a note with other people on this instance (#5174). The owner picks a member,
// or a group the admin made (#5177), and says whether they may only read it or also
// Share a note with other people on this instance. The owner picks a member,
// 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
// regardless.
+5 -14
View File
@@ -3,16 +3,9 @@ import { ref } from "vue";
/**
* The card reorder gesture, shared across every NoteCard on screen.
*
* Module-level refs on purpose: with native HTML5 drag-and-drop the browser routed
* `dragover`/`drop` to whatever element was under the cursor, so each card learned
* on its own that it was the drop target. Pointer Events don't work that way — once
* 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.
* Module-level refs: a captured pointer sends every move to the card being dragged,
* so it hit-tests for itself and publishes the result here for the other cards.
* Pointer Events rather than HTML5 drag-and-drop, which never fires from touch.
*/
/** 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`.
*
* Reads the DOM rather than tracking geometry because the board is a CSS masonry
* (`columns-*`), where the visual order isn't derivable from the model order and
* cards reflow as the column count changes. Asking the browser what is actually
* under the finger is the only answer that stays true.
* Reads the DOM rather than tracking geometry: in a CSS masonry (`columns-*`) the
* visual order isn't derivable from the model order.
*/
export function cardIdAt(x: number, y: number, self: string): string | null {
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.
*
* The brief is continuity of the same object: opening a note shouldn't read as a
* modal cutting in over the board, it should read as THAT card becoming the editor.
* The cheap, robust way to say that is to scale the panel from the point the card
* occupies rather than from its own centre — the eye reads the origin and infers
* the rest.
* Opening a note should read as that card becoming the editor, so the panel scales
* from the point the card occupies. Not a true shared-element morph: scaling by the
* real card→panel ratio distorts the text and reads as a zoom.
*
* Deliberately NOT a true shared-element morph. Scaling the panel by the real
* card→panel ratio distorts the text inside it on the way, and a card is often a
* 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.
* A viewport point, not a rect, which survives the card being filtered away or
* reflowed while the editor is open.
*/
let origin: { x: number; y: number } | null = null;
@@ -32,9 +24,7 @@ export function captureMorphOrigin(noteId: string): void {
/**
* Read the origin and clear it.
*
* Consume-on-read so a compose (which has no card, and so captures nothing) can't
* inherit the origin of whatever note was edited before it and grow from a
* seemingly arbitrary corner.
* Consume-on-read, so a compose (no card) doesn't inherit the previous note's origin.
*/
export function takeMorphOrigin(): { x: number; y: number } | null {
const taken = origin;
+2 -7
View File
@@ -5,13 +5,8 @@ import { useLabelsStore } from "../stores/labels";
/**
* What to call the lens currently in view.
*
* Keyed off the route name rather than each view declaring its own title, so the
* label sits in one place and can't go missing (the board never had one)
* 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.
* Keyed off the route name, so the shell's header and the browser tab read the same
* name. A tag lens is named by the tag itself.
*/
export function useLensName(): ComputedRef<string> {
const route = useRoute();
+3 -6
View File
@@ -2,9 +2,7 @@ import { ref } from "vue";
import { useNotesStore, type Note } from "../stores/notes";
import { captureMorphOrigin } from "./useEditorMorph";
// Shared controller for hosting the NoteEditor modal. Every view that opens the
// editor used to re-declare the same editing/open/close/navigate glue; this is the
// single implementation.
// Shared controller for hosting the NoteEditor modal.
//
// - `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
@@ -16,9 +14,8 @@ export function useNoteEditor(options: { onClose?: () => void; list?: () => Note
const editing = ref<Note | null>(null);
function open(note: Note): void {
// Every view opens the editor through here, so this is the one place that knows
// which card the user actually clicked — and therefore the only place the
// grow-from-the-card animation can be told where to start (M7).
// Every view opens the editor through here, so this is where the grow-from-the-
// card animation learns which card was clicked.
captureMorphOrigin(note.id);
editing.value = note;
}
+3 -3
View File
@@ -2,9 +2,9 @@ import { ref, type Ref } from "vue";
import type { Note } from "../stores/notes";
import { errorMessage } from "../api/errors";
// The load-a-list-of-notes scaffold every recall view (Timeline, Reminders)
// hand-rolled: an items ref + loading/error flags + a load() that fills them and never
// leaves a half-state. The view supplies just the fetcher; wire the returned refs into
// Loading a list of notes for a recall view (Timeline, Reminders): an items ref,
// loading/error flags, and a load() that never leaves a half-state. The view supplies
// the fetcher; wire the returned refs into
// <AsyncState> and pass `items` to <EmptyState>/cards.
//
// `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>".
*
* index.html's static <title> only names the app, and nothing updated it after
* that, so every tab read the same — or, where the browser preferred the URL, no
* 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.
* Mounted once in App.vue. A route outside the shell names itself with `meta.title`;
* a board lens takes the name the shell's header shows.
*/
export function usePageTitle(): void {
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.
*
* The CSS half of this lives in style.css and neutralises every declarative
* transition on its own. This is for the half CSS can't reach: motion driven from
* JavaScript — the FLIP reflow and the card↔editor morph — where the honest
* response isn't a faster animation but no animation at all, jumping straight to
* the final state.
* style.css neutralises declarative transitions. This is for motion driven from
* JavaScript (the FLIP reflow, the card↔editor morph), which jumps straight to the
* final state instead.
*
* Reactive rather than a one-shot read: the setting can change while the app is
* 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.
* Reactive, since the setting can change while the app is open.
*/
export function useReducedMotion(): Readonly<Ref<boolean>> {
const reduced = ref(prefersReducedMotion());
+11 -16
View File
@@ -11,10 +11,8 @@ interface TauriGlobal {
options?: { headers?: Record<string, string> },
) => Promise<T>;
};
// Also from `withGlobalTauri`. Needed because quick capture puts the app in TWO
// windows, each with its own Pinia stores — a note saved in one is invisible to
// the other until something says so, and an event is the only channel between
// them that does not involve polling SQLite.
// Also from `withGlobalTauri`. Quick capture runs a second window with its own
// Pinia stores, and an event is how a note saved in one reaches the other.
event?: {
listen: <T>(event: string, handler: (e: { payload: T }) => void) => Promise<() => void>;
};
@@ -78,7 +76,7 @@ export const 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,
// 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".
*/
export type Compatibility =
@@ -231,7 +229,7 @@ export const sync = {
hasPending: () => invoke<boolean>("sync_has_pending"),
};
// --- In-app updates (M10.9) --------------------------------------------------
// --- In-app updates ----------------------------------------------------------
/** `stable` follows tagged releases; `dev` follows every green build. */
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.
*
* They disagree more often than you would like: a combination can be saved and
* refuse to register because a window manager or another app already holds it,
* and on Wayland a compositor may refuse global grabs entirely. `registered:
* 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.
* A saved combination can fail to register when another app holds it, or when a
* Wayland compositor refuses global grabs. `registered: false` with a non-empty
* `shortcut` is that case, and the UI says so.
*/
export interface CaptureShortcut {
/** 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.
*
* 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
* what it has until its next load, which is a stale list rather than a broken one.
* runtime that does not expose the event API; the board then updates on its next
* load.
*/
export async function onCaptured(handler: () => void): Promise<() => void> {
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
* 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
* only ever show broken. */
* and the desktop's blob scheme as opaque bytes, where an `<img>` of it would show
* broken. */
export function rendersInline(mime: string): boolean {
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 note is still one markdown body underneath (M304) — this is a rendering and
// input shape, and nothing below the editor can tell it exists. `joinBlocks` puts the
// string back together on every edit, and for a body already in canonical form it
// returns exactly what `splitBlocks` was handed.
// The note is still one markdown body; nothing below the editor sees blocks.
// `joinBlocks` rebuilds the string on every edit, and for a canonical body returns
// exactly what `splitBlocks` was handed.
//
// Why blocks at all: a checklist item has to be a real `<input type="checkbox">`, and
// a widget cannot live inside a `<textarea>`. Only a `contenteditable` could hold one,
// and that is a different editor with a different set of problems.
// Blocks because a checklist item is a real `<input type="checkbox">`, which can't
// live inside a `<textarea>`. A run of prose lines is one block, so typing a
// paragraph feels like typing a paragraph.
//
// A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
// 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.
// Mirrors android/.../ui/EditorBlock.kt, so the two editors behave the same.
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.
*
* 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
* one. Without that half a list is impossible to get out of.
* 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.
*
* Appends rather than splitting at the caret: splitting an item in two is a rarity,
* and the caret is at the end for every ordinary use of that key.
* Appends rather than splitting at the caret, which is at the end in ordinary use.
*/
export function afterEnter(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number } {
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.
*
* 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 } {
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.
*
* 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
* text yet.
* caret.
*
* Returns THE SAME ARRAY when there was nothing to promote; the caller relies on that
* to leave the ref alone, so a blur that changed nothing doesn't re-key every field.
* Returns the same array when there was nothing to promote, so the caller can leave
* the ref alone and not re-key every field.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
* reopen.
+28 -64
View File
@@ -1,6 +1,5 @@
// Note color palette. Keys match the backend's NOTE_COLORS; the actual tints live
// here (frontend concern). Class strings are full literals so Tailwind's content
// scanner (src/**/*.ts) keeps them in the build.
// The label colour palette. Keys match the backend's NOTE_COLORS; the tints live
// here. Class strings are full literals so Tailwind's content scanner keeps them.
import { foldTag } from "./tags";
@@ -19,9 +18,8 @@ export const NOTE_COLOR_KEYS = [
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
/** Membership test for a colour key arriving from the server, which may be newer
* than this client. Once a lookup in a card-fill table; since M315 there is no such
* table, and this guards a LABEL's stored colour on its way into the palette. */
/** Membership test for a label's colour key arriving from the server, which may be
* newer than this client. */
const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
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.
*
* `picked` maps a folded tag name (`foldTag`) to the colour stored on that label, so a tag the
* operator has recoloured reads the same inline as it does on a chip. A tag the note
* does not carry as a label yet — just typed, not yet derived — is not in the map, and
* `resolveLabelColor` derives one from the name exactly as the chip would have.
* `picked` maps a folded tag name (`foldTag`) to its label's stored colour, so a
* recoloured tag reads the same inline as on a chip. A tag just typed isn't in the
* map, and `resolveLabelColor` derives one from the name as the chip would.
*/
export function tagTextClasses(name: string, picked?: Record<string, string>): string {
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.
*
* One function rather than the composition written out at each call site, because the
* board's chip and the editor's chip have to be the same pill — the same tag changing
* 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.
* One function, so the board's chip and the editor's are the same pill. Takes the
* label, not its colour: an uncoloured tag derives one from its name.
*/
export function labelChipClasses(label: { name: string; color?: string | null }): string {
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.
//
// Every `#tag` is born colourless, so without this a board of tags is a board of
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
// Every `#tag` is born colourless, so its colour is hashed from its name:
// deterministic, the same on every surface, with no column to store.
//
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// 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.
// `android/.../ui/DerivedTint.kt` computes the same hash over the same key order, and
// the two must agree. Both are pinned to `core/testdata/grammar.json` (`tint`), read
// by `notes/grammar.test.ts`, and to DerivedTintTest.kt.
export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
(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.
*
* Chosen because both languages compute it identically in ten lines with no
* library. Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has
* no equivalent, and reimplementing Java's from memory in TypeScript is exactly
* how a mirror drifts.
* Both languages compute it identically in a few lines with no library; JS has no
* `String.hashCode()`.
*
* `& 0xff` is a no-op for the ASCII of a UUID, and is kept because it states the
* intent — this hashes BYTES, so the Kotlin side reading `id[i].code and 0xFF`
* is the same function rather than a coincidence.
* `& 0xff` is a no-op for a UUID's ASCII and states that this hashes bytes, like the
* Kotlin side's `id[i].code and 0xFF`.
*/
export function tintHash(id: string): number {
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.
*
* Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag`
* ever typed is `default`: `notes/tags.py` mints one as `Label(owner_id=…, name=name)`
* with no colour, so it takes the column default. Without deriving, a board of tags
* would be a board of identical grey chips.
* A typed `#tag` is created with the `default` colour, so when nobody picked one the
* colour is derived from the name rather than stored. Derived, not written when the
* tag is created, so no creation path can miss it; renaming a tag recolours it.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965.
* 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.
* A picked colour is stored and wins.
*
* An explicitly-picked colour is still stored and still wins, so tag colours stay
* editable exactly as asked.
*
* 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.
* The name is folded, so `#Todo` and `#todo` share a colour; `foldTag` here and on
* Android are both locale-independent lowercasing.
*/
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
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
// core/testdata/grammar.json under `tint`.
//
// Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable
// and it is not a bug: colour hints that two tags are distinct, it never claims two
// chips of one colour are the same tag. The chip's text is what says which tag it is.
// `work`/`ideas` and `home`/`reading` collide, which nine keys make unavoidable; the
// chip's text says which tag it is.
// ---------------------------------------------------------------------------
// 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)
// dark #fafafa on #171717 17.17
//
// The fill is deliberately the weakest number: the edge and the shadow separate a card
// from the board, and a fill strong enough to do it alone would make every card a panel.
// The fill is deliberately the weakest number: the edge and shadow separate a card from
// the board.
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
// than `trash_retention_days` (0 = keep forever). Counting down from the note's own
// deleted_at is what turns that from a surprise into a policy: a card in Trash can
// say how long it has left while there's still time to restore it. ---
// than `trash_retention_days` (0 = keep forever), so a card in Trash says how long it
// has left. ---
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// Whole days a trashed note has left, or null when nothing will happen to it
// (retention off, or the note isn't trashed).
//
// Rounds DOWN deliberately. Rounding up would report "1 day left" for a note with
// ten minutes on the clock — overstating the time remaining is the one error here
// that actually costs someone a note.
// Rounds down: overstating the time left is the error that costs someone a note.
export function trashDaysLeft(
deletedAt: string | null | undefined,
retentionDays: number,
+2 -2
View File
@@ -10,8 +10,8 @@ describe("the board's filter URL", () => {
expect(facetCount(facets)).toBe(5);
});
// Removed in #5180: reminders have their own lens and dates are Timeline's. A link
// saved before then must open the plain board, not one filtered with no chip to say so.
// Reminders have their own lens and dates are Timeline's. A link carrying those
// 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", () => {
const facets = facetsFromQuery({
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 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
// 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.
// tags.py), so the browser can't drift on a checklist or a tag colour.
import { describe, expect, it } from "vitest";
import fixture from "../../../core/testdata/grammar.json";
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
// 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
// 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
// stays plain text; this only formats what's shown. We render the parsed tree as
// Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced
// 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.
// A small, dependency-free Markdown parser for the read view (note cards). Rendered
// as Vue vnodes, never v-html, so there is no HTML-injection surface. Headings
// (#..###), lists, blockquote, fenced code, and inline **bold** / *italic* / _italic_
// / `code`. A heading needs a space after `#`, so a #tag is never one.
export interface InlineToken {
/** `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;
// 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
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
// 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**`.
// `#tag` is last: a `#tag` inside backticks is claimed by `code` first and stays
// literal, as in the core, and a tag must not start inside `**bold #x**`.
//
// 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
@@ -77,12 +68,10 @@ export function parseInline(text: string): InlineToken[] {
// A checklist item: the third implementation of one grammar, alongside
// 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 `+`.
// The other two implementations do not take `+`, and one grammar in three places has
// to be one grammar; a `+ [ ] x` line renders as an ordinary bullet everywhere,
// which is at least consistent.
// `-` and `*` only, though the `ul` matcher below also takes `+`: the other two
// implementations don't, so `+ [ ] x` is an ordinary bullet everywhere.
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
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
// 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
// from the notes already loaded, so a tag applied a moment ago counts before the
// listing is next fetched;
+1 -1
View File
@@ -1,6 +1,6 @@
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
// with no permission came from a source without sharing (the offline desktop): it is
// 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
// 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 {
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
// WINDOW — no shell, no nav, one field. Desktop only: there is no global
// hotkey in a browser tab and nothing to summon it.
// The quick-capture window: no shell, no nav, one field. Desktop only, since a
// browser tab has no global hotkey.
path: "/capture",
name: "capture",
component: () => import("../views/CaptureView.vue"),
@@ -51,11 +50,9 @@ const router = createRouter({
// Per-user account: linked devices (native-client sync tokens), the password,
// and signing out everywhere else. Any user.
//
// The mirror of `requiresDesktop` above: this one needs a SERVER. The desktop
// is itself one of the devices this page lists, so offline the list is always
// empty and issuing a token rejects — its server relationship lives at /sync.
// 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.
// Needs a server: the desktop is itself one of the devices listed, and its link
// to a server lives at /sync. Guarded here, not just hidden in the shell, so a
// typed URL can't land on a dead end.
path: "/account",
name: "account",
component: () => import("../views/AccountView.vue"),
@@ -74,7 +71,7 @@ const router = createRouter({
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.
path: "/forgot-password",
name: "forgot-password",
@@ -82,10 +79,9 @@ const router = createRouter({
meta: { title: "Forgot password", guestOnly: true },
},
{
// Where a password reset link lands, whether an admin made it (#5173) or it was
// emailed (#5266). Not guest-only: the
// link signs in whoever uses it as the account it was made for, whoever was
// signed in on this browser before.
// Where a password reset link lands, whether an admin made it or it was emailed.
// Not guest-only: the link signs in whoever uses it as the account it was made
// for, whoever was signed in on this browser before.
path: "/reset-password",
name: "reset-password",
component: () => import("../views/ResetPasswordView.vue"),
@@ -124,10 +120,10 @@ router.beforeEach(async (to) => {
if (to.query.capture === "1" && to.name !== "capture") {
return { name: "capture" };
}
// Deliberately NOT applied to /login and /register: bouncing those on desktop
// would loop against the requiresAuth guard above the moment a session is
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out
// is web-only), and a fresh launch always resolves the local user.
// Not applied to /login and /register: bouncing those on desktop would loop
// against the requiresAuth guard above whenever a session is missing. Nothing on
// the desktop navigates to them (AppShell's sign-out is web-only), and a launch
// always resolves the local user.
if (to.meta.requiresServer && isDesktop()) {
return { name: "board" };
}
+12 -15
View File
@@ -3,14 +3,13 @@ import { ref } from "vue";
import { repo } from "../adapters";
// 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
// question; see client_dist.py.
// ABSENT from the map rather than null, so a key test is the whole question; see
// client_dist.py.
//
// Named for its twin in `core/src/sync/client.rs`, which deserializes the same
// payload. That one is deliberately NARROWER — it only ever reads
// `/api/client/android`, so its `version_code` is an `i64` and it declares none of
// the fields below that Android does not use. Widening it to match this is not a
// tidy-up: every phone in the field runs the current shape.
// Its twin in `core/src/sync/client.rs` deserializes the same payload and is
// NARROWER: it reads only `/api/client/android`, so its `version_code` is an `i64`
// and it omits the fields Android doesn't use. Don't widen it to match: every phone
// in the field runs that shape.
export interface ClientRelease {
// The table row's id — "android", "linux-deb", "linux-appimage", "windows".
platform: string;
@@ -19,12 +18,10 @@ export interface ClientRelease {
// labels cannot drift apart across the surfaces that show them.
label: string;
version: string;
// What decides "is this newer". The name is for people and sorts like a string.
//
// Not one type across platforms, deliberately: Android's is an integer because
// Android's own install gate compares one, and the desktop's is Tauri's semver
// key `1.0.<minutes>`. Nothing in this app compares them — the union is here so
// the shape is honest rather than to be read.
// 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
// desktop's is Tauri's semver key `1.0.<minutes>`. Nothing compares them across
// platforms.
version_code: number | string;
size: number;
sha256: string;
@@ -43,8 +40,8 @@ export interface PublicConfig {
enable_url_unfurl: boolean;
// How many days a note survives in Trash before the server purges it. 0 = forever.
trash_retention_days: number;
// 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.
// Whether this server can email a reset link, so sign-in offers "Forgot password?".
// Absent on an older server and on the offline desktop: no.
password_reset_by_email?: boolean;
// 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.
+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).
count?: number;
// 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;
}
@@ -82,11 +82,9 @@ export const useLabelsStore = defineStore("labels", () => {
}
async function remove(id: string): Promise<void> {
// Labels have no trash of their own — a label is organization, not content, so
// the reversible middle step notes get would be ceremony. But deleting one is
// still irreversible and now reaches every synced device, so it asks first. The
// notes themselves survive; only the membership goes, which is the part people
// most need reassuring about.
// Labels have no trash: a label is organization, not content. Deleting one is
// irreversible and reaches every synced device, so it asks first. The notes
// survive; only the membership goes, which is what the prompt reassures about.
const label = items.value.find((lb) => lb.id === id);
const subject = label ? `the tag "${label.name}"` : "this tag";
const confirmed = window.confirm(
+3 -3
View File
@@ -12,7 +12,7 @@ export interface NoteFacets {
has_attachment?: boolean;
created_after?: 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";
}
@@ -26,7 +26,7 @@ export interface NoteLabel {
color: string;
// 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
// 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.
//
// 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[];
created_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.
permission?: NotePermission;
/** 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 type { Note } from "./notes";
// Reminder delivery. In a browser it is foreground only — NO Web Push / service
// worker (per operator): while the tab is open, poll for due reminders and surface
// each once as a toast (and, if the user opted in, a page-context OS notification).
// Reminder delivery. In a browser it is foreground only, with no Web Push or service
// worker: while the tab is open, poll for due reminders and surface each once as a
// 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
// what is due, raises the system notification itself when the window isn't in
// front, and tells this page so it can show the toast (#5171). The page's
// `Notification` went nowhere in WebKitGTK, and its timer stopped with the window.
// The desktop doesn't poll here. Its Rust worker reads the local store, decides what
// is due, raises the system notification itself when the window isn't in front, and
// tells this page so it can show the toast. In WebKitGTK the page's `Notification`
// goes nowhere and its timer stops with the window.
const POLL_MS = 45_000;
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", () => {
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 () => {
vi.mocked(repo.notes.titles)
.mockResolvedValueOnce([{ id: "a", title: "Groceries" }])
+4 -10
View File
@@ -7,17 +7,11 @@ export interface TitleEntry {
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,
// because the command palette lists it so someone can jump straight to a note by
// 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.
// Fetched again every time the palette opens, so a note written since is findable.
// The last list stays in place meanwhile, so the palette never opens empty.
export const useTitlesStore = defineStore("titles", () => {
const items = ref<TitleEntry[]>([]);
+1 -1
View File
@@ -53,7 +53,7 @@ export const useUiStore = defineStore("ui", () => {
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.
const shareNoteId = ref<string | null>(null);
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
* us something about vestibular comfort or attention, and the answer is to arrive
* instantly rather than to animate faster.
/* Motion is a feature, not a given. Anyone whose OS says "reduce motion" gets
* instant arrival rather than faster animation.
*
* Global and blunt on purpose: it catches every Tailwind `transition` utility
* already scattered through the components, and it will catch the M7 motion work
* without each new component having to remember. Near-zero rather than `none` so
* `transitionend`/`animationend` listeners still fire and no state machine that
* waits on them can hang. JS-driven motion can't be reached from CSS — that reads
* the same preference through `useReducedMotion`. */
* Global and blunt on purpose: it catches every Tailwind `transition` utility and
* every new component without each having to remember. Near-zero rather than `none`
* so `transitionend`/`animationend` listeners still fire and no state machine that
* waits on them can hang. JS-driven motion reads the same preference through
* `useReducedMotion`. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -66,20 +64,17 @@ body {
}
/* 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,
* because the rule below is the whole reason they need naming.
* per-item delete buttons.
*
* A finger cannot hover. On a device with no hover at all these are not "harder to
* reach", they are unreachable — and for a note card the hover toolbar is the ONLY
* way to pin, colour or archive. So where hovering is impossible they are simply
* always present. `@media (hover: none)` asks the device directly, which is more
* honest than inferring it from viewport width: a small window on a laptop still
* hovers, and a large tablet still doesn't.
* A finger cannot hover, and for a note card the hover toolbar is the ONLY way to
* pin, colour or archive. So where hovering is impossible they are always present.
* `@media (hover: none)` asks the device directly rather than inferring from width:
* a small laptop window still hovers, and a large tablet still doesn't.
*
* Placed after the Tailwind directives so it wins on source order against the
* `opacity-0` / `pointer-events-none` utilities it overrides, without !important.
* The `group-hover:` reveal stays in the markup — the trigger differs per component
* (named groups), only this fallback is shared. */
* The `group-hover:` reveal stays in the markup, since the trigger differs per
* component (named groups); only this fallback is shared. */
@media (hover: none) {
.hover-reveal {
opacity: 1;
@@ -87,25 +82,15 @@ body {
}
}
/* A note card's own controls — the drag grip and the pin/colour/archive/trash set.
* ONE element each, in TWO placements, because "always visible" and "floating over
* the card's top corners" cannot both be true without the controls sitting on top of
* the note's own words.
/* A note card's own controls — the drag grip and the pin/colour/archive/trash set —
* as ONE element each in TWO placements. Always-visible controls floating over the
* card's top corners would sit on the note's own words.
*
* That is exactly what a phone got. `.hover-reveal` above made the pills permanent
* where nothing can hover (task 2697, correctly — they are the only way to pin or
* archive), but they were still absolutely positioned, so they covered the title:
* "thought sync tauri app" rendered as "ught sync tauri app" with the grip parked on
* the first three characters.
*
* So the default — no hover — is a footer row IN FLOW, which cannot overlap anything
* by construction. A pointer that can hover lifts them back out into the floating
* pills they have always been, where they cost no vertical space and appear only on
* approach. `display: contents` drops the flex row itself out of the way so each
* child positions against the card.
*
* Keyed on hover rather than width, for the reason `.hover-reveal` already gives: a
* narrow window on a laptop still hovers, and a wide tablet still doesn't. */
* So the default (no hover) is a footer row IN FLOW, which cannot overlap anything.
* A pointer that can hover lifts them out into floating pills, which cost no
* vertical space and appear only on approach. `display: contents` drops the flex
* row out of the way so each child positions against the card. Keyed on hover
* rather than width, for the reason `.hover-reveal` gives. */
.note-actions {
display: flex;
align-items: center;
@@ -140,23 +125,18 @@ body {
}
}
/* Board motion (M7). Defined once here rather than three times in BoardView's
* markup, because "how the board moves" is one idea even though the pinned, other
* and non-board grids are three TransitionGroups.
/* Board motion. Defined once here because the pinned, other and non-board grids are
* three TransitionGroups that move one way.
*
* Vue's TransitionGroup does the FLIP itself: it measures each card before and
* after the list changes and transitions the difference away. All we supply is the
* curve. Deliberately fast and small — the brief is continuity, so a card should
* read as having MOVED rather than as having performed.
* Vue's TransitionGroup does the FLIP itself; this supplies only the curve, fast and
* small, so a card reads as having MOVED rather than performed.
*
* Leavers are NOT taken out of flow with `position: absolute`, which is the usual
* TransitionGroup trick: this masonry is CSS multi-column, and an absolutely
* positioned child escapes its column to the container's origin — it would fly
* across the board on its way out. Keeping them in flow costs a small settle when
* the element is finally removed, so the leave is the shortest of the three.
* Leavers are NOT taken out of flow with `position: absolute`, the usual trick: this
* masonry is CSS multi-column, and an absolutely positioned child escapes its column
* to the container's origin. Staying in flow costs a small settle on removal, so the
* leave is the shortest of the three.
*
* Reduced motion needs no special case here: the global guard above already
* collapses every duration, so this degrades to an instant cut. */
* Reduced motion needs no case here: the global guard above collapses every duration. */
.board-move {
transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
}
@@ -177,14 +157,13 @@ body {
opacity: 0;
}
/* Editor open/close (M7). The backdrop only fades; the panel scales from the point
* the card occupied, which is what carries "this card became the editor" without
* the distortion a true card→panel scale would put through the text.
/* Editor open/close. The backdrop only fades; the panel scales from the point the
* card occupied, which carries "this card became the editor" without distorting the
* text the way a true card→panel scale would.
*
* Enter is slower than leave on purpose: arriving wants to be noticed as a
* connection, leaving just wants to be out of the way. LEAVE_MS in NoteEditor.vue
* must stay in step with the leave duration here — it waits that long before
* telling its host to unmount it. */
* Enter is slower than leave: arriving should read as a connection, leaving should
* get out of the way. LEAVE_MS in NoteEditor.vue must match the leave duration here;
* it waits that long before telling its host to unmount it. */
.editor-enter-active {
transition: opacity 200ms ease-out;
}
@@ -206,10 +185,9 @@ body {
transform: scale(0.94);
}
/* Lens cross-fade (M7). Only fires between genuinely different surfaces; the
* board's own lenses share a component and reflow in place instead. Out is quicker
* than in so the gap between them stays short — `mode="out-in"` means the two
* durations are additive, and anything slower starts to feel like waiting. */
/* Lens cross-fade. Fires only between genuinely different surfaces; the board's own
* lenses share a component and reflow in place. Out is quicker than in because
* `mode="out-in"` adds the two durations, and anything slower feels like waiting. */
.lens-enter-active {
transition: opacity 150ms ease-out;
}
@@ -250,22 +228,16 @@ body {
@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,
* because not every button in this app is a <button>. A DOWNLOAD has to be an
* anchor: these are 3-95 MB installers, only an <a> can carry an href, and the
* browser's own download manager handles that transfer better than anything the
* app would do by fetching to a blob. BaseButton cannot serve that case, and a
* second copy of its class list for anchors is how a page ends up with two
* kinds of primary button that drift apart.
* It lives in the components layer rather than in BaseButton.vue because not every
* button is a <button>. A download has to be an anchor: only an <a> carries an
* href, and the browser's download manager handles 3–95 MB installers better than
* fetching to a blob. BaseButton.vue wears these, and so does any anchor that must
* read as a button, so the two can't drift apart.
*
* So: BaseButton.vue wears these, and so does any anchor that must read as a
* button. Neither owns the look.
*
* 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.
* The `disabled:` variants are not here because an anchor has no :disabled;
* BaseButton adds them itself.
*/
.btn {
@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 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 session = useSessionStore();
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);
function storageLine(use: StorageUse): string {
+4 -4
View File
@@ -129,7 +129,7 @@ function onBoardKey(e: KeyboardEvent) {
e.preventDefault();
openEditor(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();
void notes.trash(note.id);
} else if (key === "e" && !inTrash.value) {
@@ -295,9 +295,9 @@ const gridBinds = computed(() => ({
</script>
<template>
<!-- `pb-[…safe-area-inset-bottom]`: the board is the last thing on the page, so
it is what ends up underneath the gesture bar once `viewport-fit=cover` lets
the page draw there. Added to the existing py-6 rather than replacing it. -->
<!-- `pb-[…safe-area-inset-bottom]` on top of py-6: the board is the last thing on
the page, so it is what sits under the gesture bar once `viewport-fit=cover`
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">
<button
v-if="isMainBoard"
+1 -1
View File
@@ -7,7 +7,7 @@ import BaseInput from "../components/BaseInput.vue";
import AuthLayout from "../components/AuthLayout.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.
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
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
costs nothing that was not already public (#3181). -->
costs nothing that was not already public. -->
<p
class="mt-8 select-all text-center text-[11px] text-neutral-400 dark:text-neutral-500"
:title="`Inkwell server build ${config.version || 'unknown'}`"
+7 -9
View File
@@ -28,11 +28,10 @@ onMounted(load);
</script>
<template>
<!-- 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
card unification just removed. -->
<!-- max-w-6xl, matching the board and every other lens: this is the same masonry,
and a narrower column would make it feel like a different page. -->
<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">
<button
v-if="reminders.osSupported && !reminders.osEnabled"
@@ -52,11 +51,10 @@ onMounted(load);
title="No reminders"
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
on those notes, not its own kind of thing (task 1913). Done/snooze moved
onto the card itself, so one-tap triage survived the change; `load` on
reminder-changed because this list is fetched separately from the store
and a cleared note has to leave it. -->
<!-- The same cards, grid and motion as the board: reminders is a lens on those
notes, not its own kind of thing. Done/snooze sit on the card for one-tap
triage; `load` on reminder-changed because this list is fetched separately
from the store and a cleared note has to leave it. -->
<NoteGrid v-else :notes="items" @open="openEditor" @reminder-changed="load" />
</AsyncState>
+2 -2
View File
@@ -9,8 +9,8 @@ import BaseInput from "../components/BaseInput.vue";
import AuthLayout from "../components/AuthLayout.vue";
import BaseButton from "../components/BaseButton.vue";
// Where a password reset link an admin made lands (#5173). Setting the password signs
// this browser in and every other session and linked app out.
// Where a password reset link lands. Setting the password signs this browser in and
// every other session and linked app out.
const session = useSessionStore();
const config = useConfigStore();
+3 -3
View File
@@ -86,9 +86,9 @@ function describe(c: Compatibility): string {
return c.reason;
}
// --- App updates (M10.9). Independent of sync: an unlinked, server-less install
// still updates itself, which is why the feed is the release host and not an
// Inkwell server. ---
// --- App updates. Independent of sync: an unlinked, server-less install still
// updates itself, which is why the feed is the release host and not an Inkwell
// server. ---
const channel = ref<UpdateChannel>("stable");
const update = ref<UpdateStatus | null>(null);
const checking = ref(false);
+2 -3
View File
@@ -102,9 +102,8 @@ onMounted(load);
<template>
<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),
so a per-view title would say the same thing twice, in a place that scrolls
away. -->
<!-- No <h1> here: the shell's persistent bar names the active lens, so a per-view
title would say the same thing twice, in a place that scrolls away. -->
<div class="mb-1 flex flex-wrap items-center justify-end gap-3">
<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">