diff --git a/web/src/app.css b/web/src/app.css
index d695d0d..a0f4808 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -232,6 +232,4 @@ input::placeholder { color: var(--fg-faint); }
outline: 1px solid var(--accent-dim) !important;
color: transparent !important;
}
-.cm-panels { background: var(--bg-alt); color: var(--fg); border-color: var(--line); }
-.cm-vim-panel { padding: 2px 8px; background: var(--bg-alt); }
-.cm-vim-panel input { background: transparent; border: 0; color: var(--accent); }
+/* The panel and vim `:` prompt are themed in Editor.svelte — see the note there. */
diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte
index def954e..7c01aa7 100644
--- a/web/src/lib/Editor.svelte
+++ b/web/src/lib/Editor.svelte
@@ -34,11 +34,35 @@
{ tag: tags.processingInstruction, color: 'var(--fg-faint)' },
])
- const editorTheme = EditorView.theme({
- '&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' },
- '.cm-content': { padding: '12px 16px', maxWidth: '90ch' },
- '.cm-line': { padding: '0' },
- })
+ /*
+ * CodeMirror's base theme paints panels and the caret for a light editor
+ * (`&light .cm-panels { color: black }`), and those selectors outrank
+ * anything the app stylesheet can say. Theme rules do win, so the parts that
+ * would otherwise render black on black live here rather than in app.css.
+ * `dark: true` also stops the light defaults applying in the first place.
+ */
+ const editorTheme = EditorView.theme(
+ {
+ '&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' },
+ '.cm-content': { padding: '12px 16px', maxWidth: '90ch' },
+ '.cm-line': { padding: '0' },
+ '.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--accent)' },
+ '.cm-panels': {
+ backgroundColor: 'var(--bg-alt)',
+ color: 'var(--fg)',
+ borderColor: 'var(--line)',
+ },
+ '.cm-panels-bottom': { borderTop: '1px solid var(--line)' },
+ // The `:` prompt is a bare text node in the panel; the input follows it.
+ '.cm-vim-panel': { padding: '2px 8px', color: 'var(--accent)' },
+ '.cm-vim-panel input': {
+ fontFamily: 'inherit',
+ color: 'var(--accent)',
+ caretColor: 'var(--accent)',
+ },
+ },
+ { dark: true },
+ )
/** Route every `:command` typed inside the editor through the shared handler. */
function bridgeExCommands() {
diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte
index cf78d6e..7287f2b 100644
--- a/web/src/lib/Help.svelte
+++ b/web/src/lib/Help.svelte
@@ -1,5 +1,5 @@
:help
+ everything is a key or a `:` command
-
+
keys
{#each keys as [key, description] (key)}
@@ -37,28 +53,29 @@
-
- commands
-
- {#each commands as spec (spec.name)}
- -
- :{spec.name}{#if spec.aliases?.length} ({spec.aliases.join(' ')}){/if}
- {#if spec.args} {spec.args}{/if}
-
- - {spec.help}
- {#if spec.bang}
- - :{spec.name}!{#if spec.aliases?.length} ({spec.aliases.map((a) => `${a}!`).join(' ')}){/if}
- - {spec.bang.help}
- {/if}
- {/each}
-
-
+ {#each grouped as [group, specs] (group)}
+
+ {titles[group]}
+
+ {#each specs as spec (spec.name)}
+ -
+ {signature(spec)}{#if spec.args}{spec.args}{/if}
+
+ - {spec.help}
+ {#if spec.bang}
+ - :{spec.name}!
+ - {spec.bang.help}
+ {/if}
+ {/each}
+
+
+ {/each}
@@ -66,25 +83,46 @@
.sheet {
display: flex;
flex-direction: column;
- gap: 12px;
- width: min(100%, 90ch);
+ gap: 14px;
+ width: min(100%, 110ch);
max-height: 100%;
background: var(--bg-alt);
border: 1px solid var(--accent-dim);
border-radius: var(--radius);
padding: 14px 16px;
overflow-y: auto;
+ overflow-x: hidden;
}
- header { display: flex; justify-content: space-between; align-items: center; }
+ header { display: flex; align-items: baseline; gap: 12px; }
+ header .faint { flex: 1; font-size: 12px; }
+ header button { align-self: center; }
- .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(30ch, 1fr)); gap: 20px; }
+ /* Newspaper columns, so the short groups fill the gaps under the long ones. */
+ .cols { columns: 38ch; column-gap: 28px; }
- h2 { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.1em; margin: 0 0 6px; }
+ section { break-inside: avoid; min-width: 0; margin: 0 0 16px; }
- dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; font-size: 12px; }
+ h2 {
+ font-size: 12px;
+ color: var(--fg-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ margin: 0 0 6px;
+ border-bottom: 1px dashed var(--line);
+ padding-bottom: 4px;
+ }
+
+ dl {
+ margin: 0;
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ gap: 3px 12px;
+ font-size: 12px;
+ }
dt { color: var(--accent); white-space: nowrap; }
- dd { margin: 0; color: var(--fg-dim); }
+ .args { margin-left: 0.6ch; }
+ dd { margin: 0; color: var(--fg-dim); overflow-wrap: anywhere; }
footer { font-size: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts
index d9c0993..680f7af 100644
--- a/web/src/lib/commands.ts
+++ b/web/src/lib/commands.ts
@@ -1,5 +1,5 @@
import { diary, type Theme } from './store.svelte'
-import { parseDay } from './util'
+import { formatDay, parseDay } from './util'
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
export const hooks = {
@@ -10,10 +10,15 @@ export const hooks = {
openCommandLine: (_initial: string) => {},
}
+/** Help groups the command list under these headings, in this order. */
+export const groups = ['entries', 'editing', 'sharing', 'view'] as const
+export type Group = (typeof groups)[number]
+
export interface CommandSpec {
name: string
aliases?: string[]
args?: string
+ group: Group
help: string
run: (arg: string) => void | Promise
/** The forcing variant, `:name!` — vim hands the bang over as an argument. */
@@ -28,26 +33,61 @@ const requireOpen = () => {
return diary.open
}
+/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
+const jumpTo = async (index: number) => {
+ if (diary.entries.length === 0) return diary.say('no entries', 'error')
+ diary.cursor = Math.min(Math.max(index, 0), diary.entries.length - 1)
+ await diary.guard(() => diary.openSelected())
+}
+
+const themes: Theme[] = ['mocha', 'green', 'amber', 'ice']
+
+const setTheme = (name: string) => {
+ if (!themes.includes(name as Theme)) {
+ return diary.say(`unknown theme: ${name} — try ${themes.join(', ')}`, 'error')
+ }
+ diary.setTheme(name as Theme)
+ diary.say(`theme ${name}`)
+}
+
+/** A filename that survives a download folder: `2026-09-05-first-light.md`. */
+const exportName = (title: string, at: number) => {
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
+ return `${formatDay(at)}${slug ? `-${slug}` : ''}.md`
+}
+
export const commands: CommandSpec[] = [
{
name: 'write',
aliases: ['w'],
+ group: 'editing',
help: 'save the open entry',
run: () => diary.guard(() => diary.save()),
+ bang: { help: 'same as :w — the bang is accepted out of habit', run: () => diary.guard(() => diary.save()) },
},
{
name: 'wq',
aliases: ['x'],
+ group: 'editing',
help: 'save and leave insert/editor mode',
run: async () => {
await diary.guard(() => diary.save())
diary.editing = false
hooks.focusList()
},
+ bang: {
+ help: 'same as :wq — the bang is accepted out of habit',
+ run: async () => {
+ await diary.guard(() => diary.save())
+ diary.editing = false
+ hooks.focusList()
+ },
+ },
},
{
name: 'quit',
aliases: ['q'],
+ group: 'editing',
help: 'close the editor, or the entry when already reading',
run: () => {
if (diary.dirty) {
@@ -76,6 +116,7 @@ export const commands: CommandSpec[] = [
name: 'new',
aliases: ['n', 'o'],
args: '[yyyy-mm-dd]',
+ group: 'entries',
help: 'start a new entry, optionally dated',
run: async (arg) => {
await diary.guard(async () => {
@@ -91,10 +132,29 @@ export const commands: CommandSpec[] = [
hooks.focusEditor()
},
},
+ {
+ name: 'today',
+ aliases: ['t'],
+ group: 'entries',
+ help: "open today's entry, starting one if there is none yet",
+ run: async () => {
+ await diary.guard(async () => {
+ if (diary.query) await diary.search('')
+ const midnight = new Date()
+ midnight.setHours(0, 0, 0, 0)
+ const from = Math.floor(midnight.getTime() / 1000)
+ const entry = diary.entries.find((e) => e.created_at >= from && e.created_at < from + 86400)
+ if (entry) await diary.openEntry(entry.id, true)
+ else await diary.createEntry()
+ })
+ hooks.focusEditor()
+ },
+ },
{
name: 'edit',
aliases: ['e'],
args: '[id]',
+ group: 'editing',
help: 'edit the open entry, or open an entry by id',
run: async (arg) => {
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
@@ -102,11 +162,54 @@ export const commands: CommandSpec[] = [
await diary.guard(() => diary.openEntry(id, true))
hooks.focusEditor()
},
+ bang: {
+ help: 'throw away the draft and re-read the entry from the server',
+ run: async () => {
+ const open = requireOpen()
+ if (!open) return
+ diary.dirty = false
+ await diary.guard(() => diary.openEntry(open.id, diary.editing))
+ diary.say(`entry:${open.id} reloaded`)
+ },
+ },
+ },
+ {
+ name: 'next',
+ aliases: ['bn'],
+ group: 'entries',
+ help: 'open the next entry down the list',
+ run: () => jumpTo(diary.cursor + 1),
+ },
+ {
+ name: 'prev',
+ aliases: ['bp'],
+ group: 'entries',
+ help: 'open the previous entry',
+ run: () => jumpTo(diary.cursor - 1),
+ },
+ {
+ name: 'first',
+ group: 'entries',
+ help: 'open the newest entry',
+ run: () => jumpTo(0),
+ },
+ {
+ name: 'last',
+ group: 'entries',
+ help: 'open the oldest entry',
+ run: () => jumpTo(diary.entries.length - 1),
+ },
+ {
+ name: 'random',
+ group: 'entries',
+ help: 'open an entry at random — good for re-reading',
+ run: () => jumpTo(Math.floor(Math.random() * diary.entries.length)),
},
{
name: 'delete',
aliases: ['d', 'rm'],
args: '[id]',
+ group: 'entries',
help: 'delete an entry (asks first)',
run: async (arg) => {
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
@@ -118,6 +221,7 @@ export const commands: CommandSpec[] = [
{
name: 'title',
args: '',
+ group: 'editing',
help: 'set the title of the open entry',
run: async (arg) => {
if (!requireOpen()) return
@@ -129,6 +233,7 @@ export const commands: CommandSpec[] = [
{
name: 'date',
args: '',
+ group: 'editing',
help: 're-date the open entry',
run: async (arg) => {
if (!requireOpen()) return
@@ -141,6 +246,7 @@ export const commands: CommandSpec[] = [
},
{
name: 'share',
+ group: 'sharing',
help: 'publish the entry behind an unguessable link and copy it',
run: async () => {
const id = diary.open?.id ?? diary.selected?.id
@@ -152,6 +258,7 @@ export const commands: CommandSpec[] = [
},
{
name: 'unshare',
+ group: 'sharing',
help: 'revoke the share link',
run: async () => {
const id = diary.open?.id ?? diary.selected?.id
@@ -163,6 +270,7 @@ export const commands: CommandSpec[] = [
},
{
name: 'link',
+ group: 'sharing',
help: 'copy the share link of the current entry',
run: async () => {
const token = diary.open?.share_token ?? diary.selected?.share_token
@@ -171,14 +279,46 @@ export const commands: CommandSpec[] = [
diary.say((await diary.copy(url)) ? `copied ${url}` : url)
},
},
+ {
+ name: 'copy',
+ aliases: ['yank'],
+ group: 'sharing',
+ help: 'copy the whole entry to the clipboard as markdown',
+ run: async () => {
+ if (!requireOpen()) return
+ const { title, body } = diary.draft
+ const text = title.trim() ? `# ${title.trim()}\n\n${body}` : body
+ diary.say((await diary.copy(text)) ? 'entry copied' : 'the clipboard said no', 'info')
+ },
+ },
+ {
+ name: 'export',
+ aliases: ['exp'],
+ group: 'sharing',
+ help: 'download the open entry as a .md file',
+ run: () => {
+ if (!requireOpen()) return
+ const { title, body, created_at } = diary.draft
+ const text = title.trim() ? `# ${title.trim()}\n\n${body}` : body
+ const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' }))
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = exportName(title.trim(), created_at)
+ anchor.click()
+ URL.revokeObjectURL(url)
+ diary.say(`wrote ${anchor.download}`)
+ },
+ },
{
name: 'upload',
aliases: ['up'],
+ group: 'editing',
help: 'pick files to attach at the cursor',
run: () => hooks.pickFiles(),
},
{
name: 'media',
+ group: 'editing',
help: 'browse everything you have uploaded',
run: () => diary.guard(async () => {
await diary.loadMedia()
@@ -189,25 +329,64 @@ export const commands: CommandSpec[] = [
name: 'search',
aliases: ['se'],
args: '[text]',
+ group: 'entries',
help: 'full-text search (empty clears)',
run: (arg) => diary.guard(() => diary.search(arg)),
},
+ {
+ name: 'clear',
+ aliases: ['noh'],
+ group: 'entries',
+ help: 'clear the search filter',
+ run: () => diary.guard(() => diary.search('')),
+ },
+ {
+ name: 'reload',
+ aliases: ['r'],
+ group: 'entries',
+ help: 're-read the entry list from the server',
+ run: () =>
+ diary.guard(async () => {
+ await diary.refresh()
+ diary.say(`${diary.entries.length} entries`)
+ }),
+ },
+ {
+ name: 'stats',
+ group: 'view',
+ help: 'word, line and entry counts',
+ run: () => {
+ const body = diary.open ? diary.draft.body : ''
+ const words = body.split(/\s+/).filter(Boolean).length
+ const here = diary.open ? `entry:${diary.open.id} ${words}w ${body.split('\n').length}L · ` : ''
+ diary.say(`${here}${diary.entries.length} entries${diary.query ? ` matching "${diary.query}"` : ''}`)
+ },
+ },
+ {
+ name: 'theme',
+ args: '',
+ group: 'view',
+ help: 'switch the colour scheme',
+ run: (arg) => setTheme(arg.trim() || diary.theme),
+ },
{
name: 'set',
args: '