AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
+3
-6
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 }>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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> = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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,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,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,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();
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }])
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'}`"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user