DRY pass #2, batch 5, F17: the web's smaller pairs (#5372)

- desktop/bridge.ts listen<T>(): the no-op-off-desktop event listener
  that onSynced, onCaptured and onReminderDue each wrote.
- size.ts roughSize(): one decimal below 10, none above, for the client
  download sizes (MB) and the storage line (GB).
- BaseModal's title prop draws the heading row and close button that
  LabelsModal and ShareDialog each built.
- SyncView: switchChannel and switchSource share recheck(); the four
  update-card radios render from two option lists.

Kept: the board's and the shell's is-typing guards (they ignore different
elements), the drawer's nav beside the palette's commands (one is a laid-
out list with tags between its entries, the other a command list), the
status pills (each colours differently), and plurals (the copy is the
operator's call, raised in #5371).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:48:31 -04:00
co-authored by Claude Opus 5.5
parent 6e4c4930cb
commit 3dd47fa178
8 changed files with 104 additions and 101 deletions
+12 -2
View File
@@ -2,8 +2,8 @@
// The backdrop + centered dialog panel every modal shares: a fixed dimmed overlay and // The backdrop + centered dialog panel every modal shares: a fixed dimmed overlay and
// a bordered, rounded panel with role="dialog". Emits `close` on Escape and on a // a bordered, rounded panel with role="dialog". Emits `close` on Escape and on a
// backdrop pointer-down (mousedown outside the panel). The caller sizes/shadows/pads // backdrop pointer-down (mousedown outside the panel). The caller sizes/shadows/pads
// the panel via `panelClass` and fills it (its own header + content) through the // the panel via `panelClass` and fills it through the default slot; `title` adds the
// default slot. // heading row with a close button that a titled dialog wants.
// //
// Owns the dialog's focus contract on behalf of all four callers: focus moves in on // Owns the dialog's focus 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 // open, Tab is trapped inside, and focus returns to the opener on close. Doing it
@@ -13,6 +13,7 @@
// NoteEditor deliberately does NOT use this — its backdrop mousedown is drag-guarded and // 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. // its Escape/⌘-Enter handling is bespoke (unsaved-edit safety), so it keeps its own shell.
import { onBeforeUnmount, onMounted, ref } from "vue"; import { onBeforeUnmount, onMounted, ref } from "vue";
import Icon from "./Icon.vue";
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -22,6 +23,8 @@ withDefaults(
align?: "start" | "center"; align?: "start" | "center";
/** aria-label for the panel when it has no visible heading (e.g. the command palette). */ /** aria-label for the panel when it has no visible heading (e.g. the command palette). */
ariaLabel?: string; ariaLabel?: string;
/** A heading row with a close button, for a dialog that has a title. */
title?: string;
}>(), }>(),
{ panelClass: "w-full max-w-sm shadow-xl", align: "start" }, { panelClass: "w-full max-w-sm shadow-xl", align: "start" },
); );
@@ -109,6 +112,13 @@ onBeforeUnmount(() => restoreTo?.focus?.());
@keydown.esc="emit('close')" @keydown.esc="emit('close')"
@keydown.tab="onTab" @keydown.tab="onTab"
> >
<div
v-if="title"
class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800"
>
<h2 class="text-sm font-semibold">{{ title }}</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div>
<slot /> <slot />
</div> </div>
</div> </div>
+2 -8
View File
@@ -7,6 +7,7 @@
// because a wrong guess must cost a person nothing. // because a wrong guess must cost a person nothing.
import { computed } from "vue"; import { computed } from "vue";
import { useConfigStore, type ClientRelease } from "../stores/config"; import { useConfigStore, type ClientRelease } from "../stores/config";
import { roughSize } from "../size";
const config = useConfigStore(); const config = useConfigStore();
@@ -108,13 +109,6 @@ const missingPlatform = computed(() =>
const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-"))); const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-")));
const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`; const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`;
// One decimal below 10 MB, none above: these sit in one list where a 2.7 MB
// package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the
// only distinction that matters at the small end.
function readableSize(bytes: number): string {
const mb = bytes / 1024 / 1024;
return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`;
}
</script> </script>
<template> <template>
@@ -144,7 +138,7 @@ function readableSize(bytes: number): string {
second source to contradict it, a wrong version here is a wrong second source to contradict it, a wrong version here is a wrong
answer nothing can catch. Not knowing which build it is, is also answer nothing can catch. Not knowing which build it is, is also
not a reason to withhold the download. --> not a reason to withhold the download. -->
Version {{ client.version || "unknown" }} · {{ readableSize(client.size) }}<span Version {{ client.version || "unknown" }} · {{ roughSize(client.size, "MB") }}<span
v-if="client.platform === 'linux-appimage'" v-if="client.platform === 'linux-appimage'"
>, and the only one that updates itself in place</span >, and the only one that updates itself in place</span
> >
+1 -5
View File
@@ -58,11 +58,7 @@ async function doMerge(sourceId: string, targetId: string) {
</script> </script>
<template> <template>
<BaseModal panel-class="w-full max-w-sm shadow-xl" @close="emit('close')"> <BaseModal panel-class="w-full max-w-sm shadow-xl" title="Manage tags" @close="emit('close')">
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Manage tags</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div>
<div class="flex flex-col gap-2 p-4"> <div class="flex flex-col gap-2 p-4">
<form class="flex items-center gap-2" @submit.prevent="add"> <form class="flex items-center gap-2" @submit.prevent="add">
<Icon name="plus" /> <Icon name="plus" />
+1 -5
View File
@@ -105,11 +105,7 @@ onMounted(load);
</script> </script>
<template> <template>
<BaseModal panel-class="w-full max-w-md shadow-xl" @close="emit('close')"> <BaseModal panel-class="w-full max-w-md shadow-xl" title="Share note" @close="emit('close')">
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Share note</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div>
<div class="flex flex-col gap-4 p-4"> <div class="flex flex-col gap-4 p-4">
<p v-if="loading" class="py-4 text-center text-sm text-neutral-400">Loading…</p> <p v-if="loading" class="py-4 text-center text-sm text-neutral-400">Loading…</p>
+14 -9
View File
@@ -32,6 +32,17 @@ export interface IntegrationStatus {
appimage_path: string | null; appimage_path: string | null;
} }
/**
* Run `handler` with each payload of a desktop event. Returns an unlisten function,
* or a no-op where there is no event API (the web build, or a desktop runtime that
* doesn't expose one).
*/
async function listen<T>(event: string, handler: (payload: T) => void): Promise<() => void> {
const events = window.__TAURI__?.event;
if (!events) return () => {};
return events.listen<T>(event, (e) => handler(e.payload));
}
/** True when running inside the Tauri desktop shell (vs. the web build). */ /** True when running inside the Tauri desktop shell (vs. the web build). */
export function isDesktop(): boolean { export function isDesktop(): boolean {
return typeof window !== "undefined" && !!window.__TAURI__; return typeof window !== "undefined" && !!window.__TAURI__;
@@ -200,9 +211,7 @@ const SYNCED_EVENT = "inkwell://synced";
* there, so there is nothing to hear. * there, so there is nothing to hear.
*/ */
export async function onSynced(handler: (cycle: LastCycle) => void): Promise<() => void> { export async function onSynced(handler: (cycle: LastCycle) => void): Promise<() => void> {
const events = window.__TAURI__?.event; return listen<LastCycle>(SYNCED_EVENT, handler);
if (!events) return () => {};
return events.listen<LastCycle>(SYNCED_EVENT, (e) => handler(e.payload));
} }
export const sync = { export const sync = {
@@ -295,9 +304,7 @@ export const capture = {
* what it has until its next load, which is a stale list rather than a broken one. * what it has until its next load, which is a stale list rather than a broken one.
*/ */
export async function onCaptured(handler: () => void): Promise<() => void> { export async function onCaptured(handler: () => void): Promise<() => void> {
const events = window.__TAURI__?.event; return listen(CAPTURED_EVENT, () => handler());
if (!events) return () => {};
return events.listen(CAPTURED_EVENT, () => handler());
} }
/** A reminder the desktop's reminder worker found due (desktop/src-tauri/src/reminders.rs). */ /** A reminder the desktop's reminder worker found due (desktop/src-tauri/src/reminders.rs). */
@@ -319,7 +326,5 @@ const REMINDER_DUE_EVENT = "inkwell://reminder-due";
* Returns an unlisten function, or a no-op where there is no event API. * Returns an unlisten function, or a no-op where there is no event API.
*/ */
export async function onReminderDue(handler: (reminder: DueReminder) => void): Promise<() => void> { export async function onReminderDue(handler: (reminder: DueReminder) => void): Promise<() => void> {
const events = window.__TAURI__?.event; return listen<DueReminder>(REMINDER_DUE_EVENT, handler);
if (!events) return () => {};
return events.listen<DueReminder>(REMINDER_DUE_EVENT, (e) => handler(e.payload));
} }
+13
View File
@@ -0,0 +1,13 @@
// How big something is, in words a person reads at a glance.
const UNITS = { MB: 1024 ** 2, GB: 1024 ** 3 } as const;
/**
* `bytes` in `unit`, with one decimal below 10 and none above. Sizes sit in lists
* where a 2.7 MB package and a 95 MB AppImage are compared, and "3 MB" next to
* "95 MB" loses the only distinction that matters at the small end.
*/
export function roughSize(bytes: number, unit: keyof typeof UNITS): string {
const n = bytes / UNITS[unit];
return `${n < 10 ? n.toFixed(1) : n.toFixed(0)} ${unit}`;
}
+3 -7
View File
@@ -14,6 +14,7 @@ import { formatDateTime } from "../notes/datetime";
import { repo } from "../adapters"; import { repo } from "../adapters";
import type { StorageUse } from "../adapters/repo"; import type { StorageUse } from "../adapters/repo";
import { useAction } from "../composables/useAction"; import { useAction } from "../composables/useAction";
import { roughSize } from "../size";
// The signed-in person's own account (not admin): the native clients linked to it // The signed-in person's own account (not admin): the native clients linked to it
// (the desktop and Android apps sync with a device bearer token issued here), its // (the desktop and Android apps sync with a device bearer token issued here), its
@@ -51,14 +52,9 @@ async function load() {
// Attachment storage (#2939 §4): how much this account uses, against its limit. // Attachment storage (#2939 §4): how much this account uses, against its limit.
const storage = ref<StorageUse | null>(null); const storage = ref<StorageUse | null>(null);
function gigabytes(bytes: number): string {
const gb = bytes / 1024 ** 3;
return `${gb < 10 ? gb.toFixed(1) : gb.toFixed(0)} GB`;
}
function storageLine(use: StorageUse): string { function storageLine(use: StorageUse): string {
const used = gigabytes(use.used_bytes); const used = roughSize(use.used_bytes, "GB");
return use.limit_bytes === null ? `${used} used` : `${used} of ${gigabytes(use.limit_bytes)} used`; return use.limit_bytes === null ? `${used} used` : `${used} of ${roughSize(use.limit_bytes, "GB")} used`;
} }
async function link() { async function link() {
+58 -65
View File
@@ -107,6 +107,46 @@ const offeredServer = computed(
() => source.value ?? status.value?.server_url?.replace(/\/+$/, "") ?? null, () => source.value ?? status.value?.server_url?.replace(/\/+$/, "") ?? null,
); );
// The two choices on the update card, each a radio with a name and a line on what
// choosing it means.
interface UpdateOption {
title: string;
detail: string;
checked: boolean;
pick: () => Promise<void>;
}
const sourceOptions = computed<UpdateOption[]>(() => [
{
title: "Fabled-Git",
detail: "Where Inkwell is published, on the channel below.",
checked: source.value === null,
pick: () => switchSource(null),
},
{
title: offeredServer.value ?? "",
detail:
"The build that server holds. It passes on official builds only: every update is checked against the signature built into this app first.",
checked: source.value !== null,
pick: () => switchSource(offeredServer.value),
},
]);
const channelOptions = computed<UpdateOption[]>(() => [
{
title: "Stable",
detail: "Released versions only.",
checked: channel.value === "stable",
pick: () => switchChannel("stable"),
},
{
title: "Development",
detail: "Every build that passes CI. Newer, and less tested.",
checked: channel.value === "dev",
pick: () => switchChannel("dev"),
},
]);
// --- Quick capture ----------------------------------------------------------- // --- Quick capture -----------------------------------------------------------
// A desktop-local preference, so it lives here beside the update channel rather // A desktop-local preference, so it lives here beside the update channel rather
// than in admin Settings: that screen is the SERVER's, and this is a property of // than in admin Settings: that screen is the SERVER's, and this is a property of
@@ -147,15 +187,20 @@ async function checkUpdates() {
} }
} }
// After a channel or source switch: the last answer described somewhere else, so it
// means nothing now. Forget it and ask again.
async function recheck() {
update.value = null;
checkedOnce.value = false;
await checkUpdates();
}
async function switchChannel(next: UpdateChannel) { async function switchChannel(next: UpdateChannel) {
if (next === channel.value) return; if (next === channel.value) return;
updateError.value = ""; updateError.value = "";
try { try {
channel.value = await updateBridge.setChannel(next); channel.value = await updateBridge.setChannel(next);
// The previous answer described the OTHER channel, so it's meaningless now. await recheck();
update.value = null;
checkedOnce.value = false;
await checkUpdates();
} catch (e) { } catch (e) {
updateError.value = errorMessage(e, "The update channel couldn't be changed."); updateError.value = errorMessage(e, "The update channel couldn't be changed.");
} }
@@ -166,10 +211,7 @@ async function switchSource(next: string | null) {
updateError.value = ""; updateError.value = "";
try { try {
source.value = await updateBridge.setSource(next); source.value = await updateBridge.setSource(next);
// Same reason as a channel switch: the last answer came from somewhere else. await recheck();
update.value = null;
checkedOnce.value = false;
await checkUpdates();
} catch (e) { } catch (e) {
updateError.value = errorMessage(e, "The update source couldn't be changed."); updateError.value = errorMessage(e, "The update source couldn't be changed.");
} }
@@ -613,35 +655,11 @@ onBeforeUnmount(() => stopSyncListener?.());
Updates from Updates from
</legend> </legend>
<div class="mt-2 flex flex-col gap-2"> <div class="mt-2 flex flex-col gap-2">
<label class="flex items-start gap-2 text-sm"> <label v-for="o in sourceOptions" :key="o.title" class="flex items-start gap-2 text-sm">
<input <input type="radio" class="mt-1" name="update-source" :checked="o.checked" @change="o.pick()" />
type="radio"
class="mt-1"
name="update-source"
:checked="source === null"
@change="switchSource(null)"
/>
<span> <span>
<span class="font-medium">Fabled-Git</span> <span class="font-medium">{{ o.title }}</span>
<span class="block text-neutral-500 dark:text-neutral-400"> <span class="block text-neutral-500 dark:text-neutral-400">{{ o.detail }}</span>
Where Inkwell is published, on the channel below.
</span>
</span>
</label>
<label class="flex items-start gap-2 text-sm">
<input
type="radio"
class="mt-1"
name="update-source"
:checked="source !== null"
@change="switchSource(offeredServer)"
/>
<span>
<span class="font-medium">{{ offeredServer }}</span>
<span class="block text-neutral-500 dark:text-neutral-400">
The build that server holds. It passes on official builds only: every
update is checked against the signature built into this app first.
</span>
</span> </span>
</label> </label>
</div> </div>
@@ -653,36 +671,11 @@ onBeforeUnmount(() => stopSyncListener?.());
Channel Channel
</legend> </legend>
<div class="mt-2 flex flex-col gap-2"> <div class="mt-2 flex flex-col gap-2">
<label class="flex items-start gap-2 text-sm"> <label v-for="o in channelOptions" :key="o.title" class="flex items-start gap-2 text-sm">
<input <input type="radio" class="mt-1" name="update-channel" :checked="o.checked" @change="o.pick()" />
type="radio"
class="mt-1"
name="update-channel"
value="stable"
:checked="channel === 'stable'"
@change="switchChannel('stable')"
/>
<span> <span>
<span class="font-medium">Stable</span> <span class="font-medium">{{ o.title }}</span>
<span class="block text-neutral-500 dark:text-neutral-400"> <span class="block text-neutral-500 dark:text-neutral-400">{{ o.detail }}</span>
Released versions only.
</span>
</span>
</label>
<label class="flex items-start gap-2 text-sm">
<input
type="radio"
class="mt-1"
name="update-channel"
value="dev"
:checked="channel === 'dev'"
@change="switchChannel('dev')"
/>
<span>
<span class="font-medium">Development</span>
<span class="block text-neutral-500 dark:text-neutral-400">
Every build that passes CI. Newer, and less tested.
</span>
</span> </span>
</label> </label>
</div> </div>