This commit is contained in:
nvrl committed 2026-10-17 13:44:05 +02:00
1 parent 747332153c
commit 8a37ca5dfe
25 files changed
+2050 -976

No files matched your search

+5
View File
@@ -29,6 +29,11 @@ WORKSPACE_SECURE_COOKIE=0
# <ssh-ed25519 ...>` in the app, then `sshfs -P <port> anything@host:/ mnt/`
# (the username is ignored — only the key decides who gets in).
#
# On the mount, `mkdir` at the root makes a space and anywhere else a folder;
# a new `.md` file is a new document. A leading `# Heading` is the document's
# title. Editor temp, swap and backup files are kept in memory for the session
# only, and renaming one onto a `.md` name saves it into that document.
#
# Under Docker, bind 0.0.0.0 (not 127.0.0.1) as the app listens inside the
# container — the host-side address in compose.yml's `ports:` is what
# actually restricts who can reach it.
+1 -1
View File
@@ -73,7 +73,7 @@ The desktop UI is modal. In the browse pane:
Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual
mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`,
`:upload`, `:media`, `:search`, `:backup`, `:set theme=mocha|green|amber|ice` and
`:upload`, `:media`, `:search`, `:backup` and
`:set novim`. The tree has its own: `:space [name]` switches (`:space! <name>`
makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
+13 -6
View File
@@ -300,10 +300,18 @@ pub async fn create_space(
return Err(AppError::BadRequest("a space needs a name".into()));
}
let slug = unique_slug(&state.db, None, &slugify(name), None).await?;
let id = insert_space(&state.db, name, input.has_board.unwrap_or(true)).await?;
state.backup.signal();
load(&state, id).await.map(Json)
}
/// A new space, last in the switcher. Shared by the API and by `mkdir` at the
/// root of the SFTP mount, which is why it takes a pool and not the state.
pub async fn insert_space(db: &SqlitePool, name: &str, has_board: bool) -> AppResult<i64> {
let slug = unique_slug(db, None, &slugify(name), None).await?;
let now = crate::now();
let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM nodes WHERE parent_id IS NULL")
.fetch_one(&state.db)
.fetch_one(db)
.await?
.get("next");
@@ -315,14 +323,13 @@ pub async fn create_space(
.bind(name)
.bind(&slug)
.bind(position)
.bind(i64::from(input.has_board.unwrap_or(true)))
.bind(i64::from(has_board))
.bind(now)
.fetch_one(&state.db)
.fetch_one(db)
.await?
.get("id");
state.backup.signal();
load(&state, id).await.map(Json)
Ok(id)
}
pub async fn list(
+822 -327
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -10,6 +10,8 @@
"@codemirror/language": "^6.10.8",
"@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.2",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/noto-sans": "^5.3.0",
"@lezer/highlight": "^1.2.1",
"@replit/codemirror-vim": "^6.2.1",
"dompurify": "^3.2.4",
@@ -100,6 +102,10 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="],
"@fontsource-variable/noto-sans": ["@fontsource-variable/noto-sans@5.3.0", "", {}, "sha512-e3ZuxzY9AL2psXPZlhwhnYrHUHv6PMoyK9MQ7croF0CUgURLqu9EMjW7q2FgVLwij1tjLsIHQrnwQ8aUcg4BiA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+2 -2
View File
@@ -4,9 +4,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0e0d" />
<meta name="theme-color" content="#0c0d0b" />
<title>~/workspace</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b0e0d'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' fill='%2354f2a1' text-anchor='middle'%3E~%3C/text%3E%3C/svg%3E" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230c0d0b'/%3E%3Ccircle cx='32' cy='32' r='15' fill='none' stroke='%23eef1ea' stroke-width='3'/%3E%3Ccircle cx='32' cy='32' r='7' fill='%23eef1ea'/%3E%3C/svg%3E" />
</head>
<body>
<div id="app"></div>
+2
View File
@@ -22,6 +22,8 @@
"@codemirror/language": "^6.10.8",
"@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.2",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/noto-sans": "^5.3.0",
"@lezer/highlight": "^1.2.1",
"@replit/codemirror-vim": "^6.2.1",
"dompurify": "^3.2.4",
+99 -42
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { hooks, runCommand } from './lib/commands'
import { embedSnippet } from './lib/markdown'
import Bar from './lib/Bar.svelte'
import Board from './lib/Board.svelte'
import CommandLine from './lib/CommandLine.svelte'
import DocumentPane from './lib/DocumentPane.svelte'
import Help from './lib/Help.svelte'
import Login from './lib/Login.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte'
import Rail from './lib/Rail.svelte'
import Shared from './lib/Shared.svelte'
import Sidebar from './lib/Sidebar.svelte'
import StatusBar from './lib/StatusBar.svelte'
@@ -31,10 +33,6 @@
if (!shareToken) void workspace.boot()
$effect(() => {
document.documentElement.dataset.theme = workspace.theme
})
$effect(() => {
folderInput?.setAttribute('webkitdirectory', '')
})
@@ -90,6 +88,18 @@
await workspace.guard(() => workspace.importFolder(files))
}
// What the empty pane offers, in the key/value shape of a status readout.
const offers: [string, string][] = [
['o', 'write a new document'],
['O', 'make a folder'],
['R', 'rename what is marked'],
['b', "this space's board"],
['j / k', 'browse, l opens'],
['h', 'up one level'],
['/', 'search this space'],
['?', 'help'],
]
/*
* The board reads the same keys as the list, one dimension wider: h/l walk
* the lists, j/k the cards, and the shifted pair takes the card along. Only
@@ -361,40 +371,45 @@
{#if shareToken}
<Shared token={shareToken} shareKey={shareKey} />
{:else if workspace.booting}
<div class="boot faint">booting ~/workspace…</div>
<div class="boot label">booting ~/workspace<span class="cursor">_</span></div>
{:else if !workspace.user}
<Login />
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="shell" bind:this={shell} tabindex="-1">
{#if workspace.view === 'board'}
<!-- The board takes the whole width: its columns are the two panes. -->
<div class="panes board"><Board /></div>
{:else}
<div class="panes" class:reading={!!workspace.open}>
<div class="list"><Sidebar /></div>
<div class="entry">
{#if workspace.open}
<DocumentPane />
{:else}
<div class="placeholder faint">
<pre>{` ┌─────────────────────────────┐
│ nothing open │
│ │
│ o write a new document │
│ O make a folder │
│ R rename what is marked │
│ b this space's board │
│ j/k browse, l opens │
│ h up one level │
│ / search this space │
│ ? help │
└─────────────────────────────┘`}</pre>
<Bar />
<Rail />
<!-- The opening in the frame: everything that scrolls is seen through it. -->
<main class="porthole">
{#if workspace.view === 'board'}
<!-- The board takes the whole width: its columns are the two panes. -->
<div class="panes board"><Board /></div>
{:else}
<div class="panes" class:reading={!!workspace.open}>
<div class="list"><Sidebar /></div>
<div class="entry">
{#if workspace.open}
<DocumentPane />
{:else}
<div class="placeholder">
<div class="standby resolve">
<h2 class="label rule">nothing open</h2>
<dl>
{#each offers as [key, what] (key)}
<dt>{key}</dt>
<dd>{what}</dd>
{/each}
<dt>status</dt>
<dd class="light">standing by<span class="cursor">_</span></dd>
</dl>
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
{/if}
</main>
<StatusBar />
@@ -426,25 +441,63 @@
.shell {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
outline: none;
background: var(--hull);
}
/*
* The porthole: a rounded opening in the hull, outlined by a single rim. The
* rim is drawn on top by ::after, so the panes scroll underneath it the way
* windows sit under the frame on the desktop.
*/
.porthole {
position: absolute;
top: var(--bar-h);
right: var(--rail-w);
bottom: var(--status-h);
left: var(--frame);
overflow: hidden;
border-radius: var(--frame-radius);
background:
radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%),
var(--base);
}
.porthole::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 1px var(--rim);
pointer-events: none;
z-index: 5;
}
.panes {
flex: 1;
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: minmax(30ch, 22vw) 1fr;
grid-template-columns: minmax(32ch, 24vw) 1fr;
}
.panes.board { grid-template-columns: 1fr; }
.list, .entry { min-width: 0; overflow: hidden; }
.list, .entry { min-width: 0; min-height: 0; overflow: hidden; }
.placeholder { display: grid; place-items: center; height: 100%; }
.placeholder pre { margin: 0; font-size: 12px; line-height: 1.5; }
.placeholder { display: grid; place-items: center; height: 100%; padding: 24px; }
.standby { width: min(100%, 44ch); }
.standby h2 { margin: 0 0 18px; font-weight: 400; }
.standby dl {
display: grid;
grid-template-columns: 7rem 1fr;
gap: 6px 0;
margin: 0;
font-size: 12px;
}
/* Keys keep their case: `o` and `O` are different keys. */
.standby dt { color: var(--light); letter-spacing: 0.08em; }
.standby dt:last-of-type { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
.standby dd { margin: 0; color: var(--fg); }
.standby dd.light { color: var(--light); }
.overlay {
position: absolute;
@@ -452,10 +505,13 @@
z-index: 30;
display: grid;
place-items: center;
padding: 24px;
background: color-mix(in srgb, var(--bg) 86%, transparent);
padding: calc(var(--bar-h) + 8px) 24px 24px;
background: color-mix(in oklab, var(--ink) 72%, transparent);
animation: fade var(--t-base) var(--ease-glide) both;
}
@keyframes fade { from { opacity: 0; } }
.hidden { display: none; }
@media (max-width: 760px) {
@@ -463,5 +519,6 @@
.panes .entry { display: none; }
.panes.reading .list { display: none; }
.panes.reading .entry { display: block; }
.overlay { padding: 12px; }
}
</style>
+270 -131
View File
@@ -1,67 +1,64 @@
/* ── palette ─────────────────────────────────────────────────────────────── */
/* Catppuccin Mocha, accented with the palette's red. */
:root, [data-theme='mocha'] {
--bg: #1e1e2e;
--bg-alt: #181825;
--bg-lift: #313244;
--line: #45475a;
--fg: #cdd6f4;
--fg-dim: #a6adc8;
--fg-faint: #6c7086;
--accent: #f38ba8;
--accent-dim: #a35b70;
--warn: #f9e2af;
--error: #eba0ac;
--select: #4a3543;
/*
* YoRHa dark, the palette of the desktop this runs on (quickshell Theme.qml,
* hypr palette.lua) and of narl.io. A green-black ramp, sage-grey text, and
* only two colours that mean anything: `light` is focus, selection and
* whatever is live; `alarm` is something wrong. Nothing else carries hue.
*/
:root {
--ink: #090a08;
--hull: #0c0d0b;
--base: #0f110e;
--surface: #161915;
--surface-alt: #20241f;
--rim: #2a2f28;
--overlay: #383e35;
--outline: #545c52;
--fg: #ccd2cb;
--subtle: #9fa7a2;
--muted: #79847a;
--light: #eef1ea;
--light-dim: #8e978d;
--light-soft: #bcc1bf;
--alarm: #c8684f;
--font-sans: "Noto Sans Variable", "Noto Sans", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace;
/* The frame: a bar on top, a rail on the right, a band at the bottom, and a
rounded opening between them that the panes are seen through. */
--bar-h: 40px;
--rail-w: 48px;
--status-h: 28px;
--frame: 8px;
--frame-radius: 18px;
/* Concentric with the frame: 18 outside, 12 one step in, 6 for the small things. */
--radius-s: 6px;
--radius-m: 10px;
--radius-l: 12px;
/* Quick start, long settle, no overshoot. */
--ease-snap: cubic-bezier(0.22, 1, 0.36, 1);
--ease-glide: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in: cubic-bezier(0.55, 0, 0.9, 0.35);
--t-fast: 120ms;
--t-base: 220ms;
--t-slow: 380ms;
--t-lazy: 700ms;
--font: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
--radius: 2px;
color-scheme: dark;
}
[data-theme='green'] {
--bg: #0b0e0d;
--bg-alt: #101513;
--bg-lift: #161d1a;
--line: #1e2b26;
--fg: #cfe3d9;
--fg-dim: #6f8a7f;
--fg-faint: #3f5049;
--accent: #54f2a1;
--accent-dim: #2f9c69;
--warn: #f2c14e;
--error: #ff6b6b;
--select: #1c3a2c;
}
[data-theme='amber'] {
--bg: #0e0b06;
--bg-alt: #151009;
--bg-lift: #1d160c;
--line: #2e2413;
--fg: #e8d5ae;
--fg-dim: #8d7550;
--fg-faint: #4d3f28;
--accent: #ffb454;
--accent-dim: #b57a25;
--warn: #f2c14e;
--error: #ff6b6b;
--select: #3a2a10;
}
[data-theme='ice'] {
--bg: #080b10;
--bg-alt: #0d1219;
--bg-lift: #121a24;
--line: #1c2836;
--fg: #cddbe8;
--fg-dim: #6d8399;
--fg-faint: #3d4f60;
--accent: #7cc7ff;
--accent-dim: #3d84b8;
--warn: #f2c14e;
--error: #ff6b6b;
--select: #17324a;
@media (max-width: 767px) {
:root {
--rail-w: var(--frame);
--status-h: 40px;
--frame: 6px;
--frame-radius: 14px;
}
}
/* ── base ────────────────────────────────────────────────────────────────── */
@@ -73,10 +70,10 @@ html, body {
}
body {
background: var(--bg);
background: var(--hull);
color: var(--fg);
font-family: var(--font);
font-size: 14px;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
overflow: hidden;
@@ -85,118 +82,260 @@ body {
#app { height: 100%; }
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid var(--accent-dim); }
a:hover { background: var(--select); }
a {
color: var(--fg);
text-decoration: underline;
text-decoration-color: var(--overlay);
text-decoration-thickness: 1px;
text-underline-offset: 4px;
transition: color var(--t-base) var(--ease-glide), text-decoration-color var(--t-base) var(--ease-glide);
}
a:hover { color: var(--light); text-decoration-color: var(--light); }
a:focus-visible { background: var(--light); color: var(--ink); text-decoration: none; outline: none; }
/*
* A button rests as a hairline and a label. Hover lifts it a step and lights
* the label; only focus inverts — the NieR cursor, light fill and ink text.
*/
button {
font: inherit;
color: var(--fg-dim);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--subtle);
background: transparent;
border: 1px solid var(--line);
border-radius: var(--radius);
border: 1px solid color-mix(in oklab, var(--outline) 55%, transparent);
border-radius: var(--radius-s);
padding: 2px 8px;
cursor: pointer;
transition:
background-color var(--t-base) var(--ease-glide),
color var(--t-base) var(--ease-glide),
border-color var(--t-base) var(--ease-glide);
}
button:hover { color: var(--accent); border-color: var(--accent-dim); }
button:focus-visible, input:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }
button:hover { background: var(--surface-alt); color: var(--light); border-color: var(--outline); }
button:active { transform: translateY(1px); }
button:focus-visible { background: var(--light); color: var(--ink); border-color: var(--light); outline: none; }
button:disabled { color: var(--muted); cursor: default; }
input, textarea {
font: inherit;
color: var(--fg);
background: var(--bg-alt);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 4px 8px;
background: var(--surface);
border: 1px solid var(--rim);
border-radius: var(--radius-s);
padding: 6px 10px;
transition: border-color var(--t-base) var(--ease-glide);
}
input::placeholder { color: var(--fg-faint); }
input:focus, textarea:focus { border-color: var(--light-dim); outline: none; }
input::placeholder { color: var(--muted); }
::selection { background: var(--select); color: var(--fg); }
::selection { background: var(--light); color: var(--ink); }
/* thin, unobtrusive scrollbars everywhere */
* { scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
* { scrollbar-width: thin; scrollbar-color: var(--overlay) transparent; }
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: var(--line); }
*::-webkit-scrollbar-thumb { background: var(--overlay); border-radius: 4px; }
*::-webkit-scrollbar-track { background: transparent; }
.dim { color: var(--fg-dim); }
.faint { color: var(--fg-faint); }
.accent { color: var(--accent); }
.error { color: var(--error); }
.subtle { color: var(--subtle); }
.muted { color: var(--muted); }
.light { color: var(--light); }
.alarm { color: var(--alarm); }
/* ── vocabulary ──────────────────────────────────────────────────────────── */
/* A caption: small, mono, upper case and spaced out. */
.label {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
/* A caption followed by a hairline that runs to the end of the row. */
.rule {
display: flex;
align-items: center;
gap: 12px;
}
.rule::after {
content: '';
height: 1px;
flex: 1;
background: var(--overlay);
}
/* The square that says "this one": overlay at rest, light when it matters. */
.marker {
display: inline-block;
width: 7px;
height: 7px;
flex: none;
background: var(--overlay);
transition: background-color var(--t-base) var(--ease-glide);
}
.display {
font-family: var(--font-sans);
font-weight: 200;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--light);
}
/* The blinking block after "standing by". */
.cursor { margin-left: 2px; animation: blink 1.1s steps(1) infinite; }
/* ── motion ──────────────────────────────────────────────────────────────── */
@keyframes resolve {
from { opacity: 0; transform: translateY(6px) scale(0.985); }
}
@keyframes rise {
from { opacity: 0; transform: translateY(12px); }
}
@keyframes blink {
50% { opacity: 0; }
}
/* Things settle into place; `--i` staggers a group of them. */
.resolve {
animation: resolve var(--t-lazy) var(--ease-snap) both;
animation-delay: calc(var(--i, 0) * 80ms);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
animation-delay: 0ms !important;
transition-duration: 0.01ms !important;
}
}
/* ── rendered markdown ───────────────────────────────────────────────────── */
.md { max-width: 78ch; }
/* Reading is set in the sans; anything that is code stays mono. */
.md {
max-width: 72ch;
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.7;
color: var(--fg);
}
.md > :first-child { margin-top: 0; }
.md h1, .md h2, .md h3, .md h4 {
color: var(--accent);
font-size: 1em;
font-weight: 700;
margin: 1.6em 0 0.6em;
letter-spacing: 0.02em;
color: var(--light);
font-weight: 400;
line-height: 1.3;
margin: 1.8em 0 0.6em;
}
.md h1 { font-size: 1.7em; font-weight: 300; letter-spacing: 0.01em; }
.md h2 {
display: flex;
align-items: center;
gap: 14px;
font-size: 1.25em;
}
.md h2::after { content: ''; height: 1px; flex: 1; background: var(--overlay); }
.md h3 { font-size: 1.05em; font-weight: 500; }
.md h4 {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--subtle);
}
.md h1::before { content: '# '; color: var(--fg-faint); }
.md h2::before { content: '## '; color: var(--fg-faint); }
.md h3::before { content: '### '; color: var(--fg-faint); }
.md h4::before { content: '#### '; color: var(--fg-faint); }
.md p { margin: 0 0 1em; }
.md ul, .md ol { margin: 0 0 1em; padding-left: 2ch; }
.md li { margin: 0.15em 0; }
.md ul { list-style: none; }
.md ul > li::before { content: '- '; color: var(--fg-faint); margin-left: -2ch; }
.md strong { color: var(--light); font-weight: 600; }
.md ul, .md ol { margin: 0 0 1em; padding-left: 1.6em; }
.md li { margin: 0.2em 0; }
.md li::marker { color: var(--muted); }
.md ul { list-style: none; padding-left: 1.4em; }
.md ul > li { position: relative; }
.md ul > li::before {
content: '';
position: absolute;
left: -1.1em;
top: 0.72em;
width: 5px;
height: 5px;
background: var(--overlay);
}
.md ul > li:has(> input[type='checkbox'])::before { content: none; }
.md blockquote {
margin: 0 0 1em;
padding-left: 1.5ch;
border-left: 2px solid var(--accent-dim);
color: var(--fg-dim);
padding-left: 1.2em;
border-left: 1px solid var(--outline);
color: var(--subtle);
}
.md code {
background: var(--bg-lift);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 4px;
font-size: 0.92em;
font-family: var(--font-mono);
font-size: 0.86em;
background: var(--surface);
border: 1px solid var(--rim);
border-radius: 4px;
padding: 0 5px;
}
.md pre {
background: var(--bg-alt);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 12px;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
background: var(--surface);
border: 1px solid var(--rim);
border-radius: var(--radius-s);
padding: 12px 14px;
overflow-x: auto;
margin: 0 0 1em;
}
.md pre code { background: none; border: 0; padding: 0; }
.md hr { border: 0; border-top: 1px dashed var(--line); margin: 2em 0; }
.md pre code { background: none; border: 0; padding: 0; font-size: inherit; }
.md hr { border: 0; border-top: 1px solid var(--overlay); margin: 2.4em 0; }
.md img, .md video {
max-width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
border: 1px solid var(--rim);
border-radius: var(--radius-s);
display: block;
margin: 0 0 1em;
}
.md audio { width: 100%; margin: 0 0 1em; }
.md table { border-collapse: collapse; margin: 0 0 1em; display: block; overflow-x: auto; }
.md th, .md td { border: 1px solid var(--line); padding: 3px 8px; text-align: left; }
.md th { color: var(--accent); }
.md input[type='checkbox'] { accent-color: var(--accent-dim); }
.md table {
border-collapse: collapse;
margin: 0 0 1em;
display: block;
overflow-x: auto;
font-size: 14px;
}
.md th, .md td { border-bottom: 1px solid var(--rim); padding: 6px 14px 6px 0; text-align: left; }
.md th {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
border-bottom-color: var(--overlay);
}
.md input[type='checkbox'] { accent-color: var(--light-dim); margin-right: 0.5em; }
/* ── codemirror ──────────────────────────────────────────────────────────── */
.cm-editor { height: 100%; background: transparent; }
.cm-editor.cm-focused { outline: none; }
.cm-scroller {
font-family: var(--font) !important;
font-family: var(--font-mono) !important;
font-size: 14px;
line-height: 1.6;
line-height: 1.65;
padding-bottom: 40vh;
}
.cm-content { caret-color: var(--accent); }
.cm-gutters { background: transparent; border: 0; color: var(--fg-faint); }
.cm-editor .cm-activeLineGutter { background: transparent; color: var(--fg-dim); }
.cm-content { caret-color: var(--light); }
.cm-gutters { background: transparent; border: 0; color: var(--muted); }
.cm-editor .cm-activeLineGutter { background: transparent; color: var(--subtle); }
/*
* CodeMirror injects its own `.cm-activeLine` rule (a light blue #cceeff44)
* into the document after this stylesheet, so an equally specific selector
* loses and the current line renders as a bright box. Qualify it to win.
*/
.cm-editor .cm-activeLine { background: color-mix(in srgb, var(--fg) 4%, transparent); }
.cm-selectionBackground, .cm-content ::selection { background: var(--select) !important; }
.cm-editor .cm-activeLine { background: color-mix(in oklab, var(--fg) 4%, transparent); }
.cm-selectionBackground, .cm-content ::selection { background: var(--overlay) !important; }
/*
* Insert mode: a 2px bar reads far better than the browser's hairline caret.
@@ -207,29 +346,29 @@ input::placeholder { color: var(--fg-faint); }
.cm-editor .cm-cursor,
.cm-editor .cm-cursor-primary,
.cm-editor .cm-dropCursor {
border-left: 2px solid var(--accent);
border-left: 2px solid var(--light);
border-right: 0;
margin-left: -1px;
}
.cm-editor .cm-cursor-secondary { border-left-color: var(--accent-dim); }
.cm-editor .cm-cursor-secondary { border-left-color: var(--light-dim); }
/*
* Normal mode: the vim plugin paints the character *inside* the block cursor,
* inheriting its syntax colour, so an accent-coloured glyph — a heading's `#`,
* a link — disappears against the accent-coloured block. Force the glyph to the
* background colour instead, which is legible whatever the token was.
* inheriting its syntax colour, so a light glyph — a heading's `#`, a link —
* disappears against the light block. Force the glyph to ink instead, which
* is legible whatever the token was: the same inversion as everywhere else.
*/
.cm-fat-cursor,
.cm-animate-fat-cursor {
background: var(--accent) !important;
color: var(--bg) !important;
background: var(--light) !important;
color: var(--ink) !important;
opacity: 1 !important;
}
.cm-fat-cursor.cm-cursor-secondary { background: var(--accent-dim) !important; }
.cm-fat-cursor.cm-cursor-secondary { background: var(--light-dim) !important; }
/* Unfocused, the plugin hardcodes a pink outline; keep it on-theme. */
.cm-editor:not(.cm-focused) .cm-fat-cursor {
background: none !important;
outline: 1px solid var(--accent-dim) !important;
outline: 1px solid var(--light-dim) !important;
color: transparent !important;
}
/* The panel and vim `:` prompt are themed in Editor.svelte — see the note there. */
+197
View File
@@ -0,0 +1,197 @@
<script lang="ts">
/*
* The top of the frame, laid out like the desktop bar it is copied from: the
* mark, the spaces as workspace pills, where in the space the list is, and
* the clock. Spaces and crumbs are clickable, because a tree is the one thing
* that is genuinely faster to walk with a pointer than with `:cd`.
*/
import { hooks } from './commands'
import { workspace } from './store.svelte'
// The space is the first crumb, and it is already named by its pill.
const crumbs = $derived(workspace.path.slice(1))
let now = $state(new Date())
$effect(() => {
const timer = setInterval(() => (now = new Date()), 10_000)
return () => clearInterval(timer)
})
const clock = $derived(
now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }),
)
</script>
<header class="bar">
<span class="mark" aria-hidden="true"></span>
<span class="divider"></span>
<nav class="spaces" aria-label="spaces">
{#each workspace.spaces as space (space.id)}
<button
class="pill"
aria-current={space.id === workspace.spaceId}
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
>
{space.slug}
</button>
{/each}
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
+
</button>
</nav>
<span class="divider path"></span>
<nav class="crumbs" aria-label="path">
{#if workspace.view === 'board'}
<span class="here">board</span>
{:else}
<button
class="crumb"
class:here={crumbs.length === 0}
onclick={() => workspace.spaceId !== null && workspace.guard(() => workspace.goTo(workspace.spaceId!))}
>
~
</button>
{#each crumbs as crumb, index (crumb.id)}
<span class="sep">/</span>
<button
class="crumb"
class:here={index === crumbs.length - 1}
onclick={() => workspace.guard(() => workspace.goTo(crumb.id))}
>
{crumb.slug}
</button>
{/each}
{/if}
</nav>
<span class="spacer"></span>
<span class="readout user">
<span class="key">user</span>
<span class="value">{workspace.user}</span>
</span>
<span class="divider wide"></span>
<time class="clock">{clock}</time>
</header>
<style>
.bar {
position: absolute;
inset: 0 0 auto 0;
height: var(--bar-h);
display: flex;
align-items: center;
gap: 12px;
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 10px);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--subtle);
white-space: nowrap;
overflow: hidden;
}
/* A ring with a dot in it: the favicon, and the desktop's launcher. */
.mark {
position: relative;
width: 12px;
height: 12px;
flex: none;
border: 1.5px solid var(--light);
border-radius: 999px;
}
.mark::after {
content: '';
position: absolute;
inset: 2px;
border-radius: 999px;
background: var(--light);
}
.divider {
width: 1px;
height: 14px;
flex: none;
background: var(--overlay);
}
.spaces, .crumbs {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.spaces { flex: none; max-width: 50%; }
.crumbs { flex: 0 1 auto; }
/* Workspace pills: muted at rest, light on hover, inverted when current. */
.pill {
min-width: 22px;
height: 20px;
padding: 0 6px;
border: 0;
border-radius: 4px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: none;
color: var(--muted);
background: transparent;
}
.pill:hover { background: transparent; color: var(--light); }
.pill[aria-current='true'] { background: var(--light); color: var(--ink); }
.pill:focus-visible { background: var(--light); color: var(--ink); }
.pill.add { color: var(--overlay); }
.pill.add:hover { color: var(--light); }
/* A crumb is a link along the path; a hairline grows under it on hover. */
.crumb {
position: relative;
border: 0;
border-radius: 0;
padding: 2px 0;
background: transparent;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: none;
color: var(--subtle);
}
.crumb::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 1px;
background: var(--light);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--t-base) var(--ease-snap);
}
.crumb:hover { background: transparent; color: var(--light); }
.crumb:hover::after { transform: scaleX(1); }
.crumb.here, .here { color: var(--light); }
.sep { color: var(--overlay); }
span.here { letter-spacing: 0.06em; text-transform: none; }
.spacer { flex: 1; }
.readout { display: flex; gap: 8px; font-variant-numeric: tabular-nums; }
.key { color: var(--muted); }
.value { color: var(--fg); text-transform: none; letter-spacing: 0.06em; }
.clock {
color: var(--light);
letter-spacing: 0.12em;
font-variant-numeric: tabular-nums;
}
@media (max-width: 767px) {
.bar { padding-left: 12px; padding-right: 12px; gap: 10px; }
.crumbs, .divider.path, .user, .divider.wide { display: none; }
.spaces { max-width: none; flex: 1; }
}
</style>
+68 -63
View File
@@ -5,8 +5,6 @@
* this file only draws and — for a pointer — moves the two cursors.
*/
import type { Card } from './api'
import { hooks, runCommand } from './commands'
import SpaceBar from './SpaceBar.svelte'
import { workspace } from './store.svelte'
import { formatDay } from './util'
@@ -39,28 +37,22 @@
</script>
<div class="board">
<div class="head">
<span class="accent">~/{workspace.space?.slug ?? ''}/board</span>
<span class="faint count">
<div class="head label rule">
<span class="marker"></span>
<span class="name">{workspace.space?.slug ?? ''}</span>
<span>board</span>
<span class="count">
{workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards
</span>
<span class="tools">
<button title="new card on this list (o)" onclick={() => hooks.openCommandLine(':card ')}>+ card</button>
<button title="new list (:list &lt;name&gt;)" onclick={() => hooks.openCommandLine(':list ')}>+ list</button>
<button title="back to the tree (:board)" onclick={() => runCommand('board')}>tree</button>
<button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button>
<button title="help (?)" onclick={() => runCommand('help')}>?</button>
</span>
</div>
<SpaceBar />
<div class="columns" bind:this={columns}>
{#each workspace.board?.lists ?? [] as list, listIndex (list.id)}
<section class="column" class:on={listIndex === workspace.listCursor}>
<header>
<header class="label">
<span class="marker"></span>
<span class="name">{list.name}</span>
<span class="faint">{list.cards.length}</span>
<span class="n">{list.cards.length}</span>
</header>
<ul>
@@ -75,11 +67,11 @@
ondblclick={() => workspace.guard(() => workspace.openCard())}
>
<span class="title">
<span class="tick">{card.done_at === null ? '[ ]' : '[x]'}</span>
{card.title}
<span class="tick" class:ticked={card.done_at !== null}></span>
<span class="text">{card.title}</span>
</span>
{#if card.due_at !== null || card.node_id !== null}
<span class="meta faint">
<span class="meta">
{#if card.due_at !== null}
<span class={urgency(card)}>due {formatDay(card.due_at)}</span>
{/if}
@@ -91,13 +83,13 @@
</button>
</li>
{:else}
<li class="empty faint">no cards — o adds one</li>
<li class="empty muted">no cards — o adds one</li>
{/each}
</ul>
</section>
{:else}
<div class="empty faint">
no lists yet — [+] above, or :list &lt;name&gt;, makes a column. then o, or +, adds a card to it.
<div class="empty muted">
no lists yet — :list &lt;name&gt; makes a column. then o adds a card to it.
</div>
{/each}
</div>
@@ -112,57 +104,54 @@
}
.head {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid var(--line);
flex: none;
padding: 16px 20px 10px 22px;
white-space: nowrap;
}
.count { flex: 1; text-align: right; white-space: nowrap; }
.tools { display: flex; gap: 2px; flex: none; }
.tools button { padding: 0 6px; line-height: 18px; }
.head .marker { background: var(--light); }
.head .name { color: var(--light); }
.columns {
flex: 1;
min-height: 0;
display: flex;
align-items: stretch;
gap: 0;
gap: 10px;
padding: 4px 14px 14px;
overflow: auto;
}
.column {
display: flex;
flex-direction: column;
min-width: 26ch;
min-width: 28ch;
flex: 1 1 0;
max-width: 44ch;
border-right: 1px solid var(--line);
max-width: 46ch;
min-height: 0;
border-radius: var(--radius-m);
background: color-mix(in oklab, var(--surface) 70%, transparent);
border: 1px solid transparent;
transition: border-color var(--t-base) var(--ease-glide), background-color var(--t-base) var(--ease-glide);
}
/* The list the cursor is on: its marker lights and its edge shows. */
.column.on { border-color: var(--rim); background: var(--surface); }
.column header {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--line);
color: var(--fg-dim);
position: sticky;
top: 0;
background: var(--bg);
align-items: center;
gap: 10px;
padding: 12px 14px 8px;
}
/* The list the cursor is on, marked the way the sidebar marks its space. */
.column.on header .name { color: var(--accent); font-weight: 700; }
.column.on { background: var(--bg-alt); }
.column header .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.column.on header .marker { background: var(--light); }
.column.on header .name { color: var(--light); }
.n { font-variant-numeric: tabular-nums; }
ul {
list-style: none;
margin: 0;
padding: 4px 0 24px;
padding: 2px 6px 10px;
overflow-y: auto;
flex: 1;
}
@@ -171,31 +160,47 @@
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
gap: 3px;
width: 100%;
margin: 1px 0;
border: 0;
border-radius: 0;
padding: 4px 10px;
border-radius: var(--radius-s);
padding: 7px 10px;
text-align: left;
color: var(--fg-dim);
white-space: normal;
font-size: 13px;
letter-spacing: 0;
text-transform: none;
color: var(--fg);
}
.card:hover { background: var(--bg-lift); color: var(--fg); }
.card.active { background: var(--select); color: var(--fg); }
.card.done .title { text-decoration: line-through; color: var(--fg-faint); }
.card:hover { background: var(--surface-alt); color: var(--light); border-color: transparent; }
.card:active { transform: none; }
.card.active, .card:focus-visible { background: var(--light); color: var(--ink); }
.card.active :is(.meta, .today, .overdue, .doc) { color: var(--ink); }
.card.active .tick { border-color: var(--ink); }
.card.active .tick.ticked { background: var(--ink); }
.card.done .text { text-decoration: line-through; color: var(--muted); }
.card.done.active .text { color: var(--ink); }
.tick { color: var(--accent); }
.title { display: block; }
.meta { display: flex; gap: 8px; font-size: 12px; padding-left: 4ch; }
.meta .today { color: var(--warn); }
.meta .overdue { color: var(--error); }
.title { display: flex; align-items: baseline; gap: 9px; font-family: var(--font-sans); font-size: 14px; }
.tick {
width: 9px;
height: 9px;
flex: none;
border: 1px solid var(--outline);
border-radius: 2px;
transform: translateY(1px);
}
.tick.ticked { background: var(--light-dim); border-color: var(--light-dim); }
.meta { display: flex; gap: 10px; font-size: 11px; padding-left: 18px; color: var(--muted); }
.meta .today { color: var(--light); }
.meta .overdue { color: var(--alarm); }
.doc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 24ch; }
.empty { padding: 12px 10px; }
.empty { padding: 12px 10px; font-size: 12px; }
@media (pointer: coarse) {
.card { padding: 8px 10px; }
.tools button { padding: 4px 10px; }
.card { padding: 10px; }
}
</style>
+34 -21
View File
@@ -57,9 +57,9 @@
{#each matches as spec (spec.name)}
<li>
<button onclick={() => (value = `:${spec.name} `, input?.focus())}>
<span class="accent">:{spec.name}</span>
{#if spec.args}<span class="faint">{spec.args}</span>{/if}
<span class="dim">{spec.help}</span>
<span class="name">:{spec.name}</span>
{#if spec.args}<span class="muted">{spec.args}</span>{/if}
<span class="help">{spec.help}</span>
</button>
</li>
{/each}
@@ -72,47 +72,60 @@
</div>
<style>
/*
* The command line slides up out of the bottom band, the way a popout comes
* out from behind the desktop's frame rather than fading in over it.
*/
.cmdline {
position: absolute;
left: 0;
right: 0;
bottom: 0;
left: calc(var(--frame) + 12px);
right: calc(var(--rail-w) + 12px);
bottom: calc(var(--status-h) - 1px);
z-index: 20;
display: flex;
flex-direction: column;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-bottom: 0;
border-radius: var(--radius-l) var(--radius-l) 0 0;
overflow: hidden;
animation: rise var(--t-slow) var(--ease-snap) both;
}
form { display: flex; }
form { display: flex; padding: 6px; }
input {
flex: 1;
height: 24px;
background: var(--bg);
border: 0;
border-top: 1px solid var(--accent-dim);
border-radius: 0;
color: var(--accent);
padding: 0 8px;
height: 30px;
background: var(--surface);
border: 1px solid var(--rim);
border-radius: var(--radius-s);
color: var(--light);
padding: 0 10px;
font-size: 13px;
}
.completions {
list-style: none;
margin: 0;
padding: 4px 0;
background: var(--bg-alt);
border-top: 1px solid var(--line);
padding: 6px 6px 0;
max-height: 40vh;
overflow-y: auto;
}
.completions button {
display: flex;
gap: 8px;
gap: 10px;
align-items: baseline;
width: 100%;
border: 0;
border-radius: 0;
text-align: left;
padding: 1px 10px;
padding: 3px 10px;
font-size: 12px;
letter-spacing: 0;
text-transform: none;
}
.completions button:hover { background: var(--select); }
.completions button:hover { background: var(--surface-alt); }
.name { color: var(--light); }
.help { color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
+88 -45
View File
@@ -66,19 +66,21 @@
<section class="pane">
<header>
<div class="crumb" title="{workspace.here}/{doc.slug}">
<span class="accent">{workspace.here}/{doc.slug}</span>
{#if workspace.dirty}<span class="warn">[+]</span>{/if}
<div class="crumb label rule" title="{workspace.here}/{doc.slug}">
<span class="marker" class:live={workspace.editing}></span>
<span class="path">{workspace.here}/{doc.slug}</span>
{#if workspace.dirty}<span class="dirty">modified</span>{/if}
</div>
<div class="row">
<div class="meta">
<span class="faint">{formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)}</span>
<span class="faint">·</span>
<span class="faint">edited {relative(doc.updated_at)}</span>
<span class="faint">·</span>
<span class="faint">{words} words</span>
</div>
<dl class="meta">
<dt>written</dt>
<dd>{formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)}</dd>
<dt>edited</dt>
<dd>{relative(doc.updated_at)}</dd>
<dt>words</dt>
<dd>{words}</dd>
</dl>
<div class="actions">
{#if isTouchDevice}
@@ -94,7 +96,7 @@
<button class:on={doc.shared} onclick={() => runCommand(doc.shared ? 'unshare' : 'share')}>
{doc.shared ? 'unshare' : 'share'}
</button>
<button onclick={() => runCommand('d')}>delete</button>
<button class="danger" onclick={() => runCommand('d')}>delete</button>
<button onclick={() => runCommand('q')}>close</button>
</div>
</div>
@@ -102,8 +104,9 @@
{#if sharePath}
<div class="sharebar">
<span class="accent">◉ public</span>
<span class="faint">{sharePath}</span>
<span class="ring"></span>
<span class="label public">public</span>
<span class="link">{sharePath}</span>
<button onclick={() => runCommand('link')}>new link</button>
</div>
{/if}
@@ -111,7 +114,7 @@
{#if workspace.editing}
<input
class="title"
placeholder="name (optional)"
placeholder="Untitled"
bind:value={workspace.draft.name}
oninput={() => workspace.touch()}
onkeydown={(event) => {
@@ -124,7 +127,7 @@
<div class="body">
{#key doc.id}
{#await import('./Editor.svelte')}
<div class="loading faint">loading editor…</div>
<div class="loading label">loading editor<span class="cursor">_</span></div>
{:then module}
{@const Editor = module.default}
<Editor />
@@ -141,12 +144,12 @@
{:else}
<div class="body reading" bind:this={reader}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<article class="md" onclick={follow}>
<article class="md resolve" onclick={follow}>
{#if doc.name.trim()}<h1 class="entrytitle">{doc.name}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
{#if !doc.body.trim()}
<p class="faint">this document is empty — press <span class="accent">i</span> to write.</p>
<p class="muted">this document is empty — press <span class="key">i</span> to write.</p>
{/if}
</article>
</div>
@@ -164,74 +167,114 @@
header {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 12px;
border-bottom: 1px solid var(--line);
gap: 10px;
padding: 16px 24px 12px 28px;
flex: none;
}
.crumb {
display: flex;
align-items: baseline;
gap: 6px;
.crumb { min-width: 0; white-space: nowrap; }
.crumb .marker.live { background: var(--light); }
.path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--subtle);
letter-spacing: 0.08em;
text-transform: none;
}
.dirty { flex: none; color: var(--light); }
.row {
display: flex;
gap: 8px;
gap: 12px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.meta { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 12px; min-width: 0; }
.warn { color: var(--warn); flex: none; }
.meta {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
margin: 0;
font-size: 11px;
min-width: 0;
}
.meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
.meta dd { margin: 0 10px 0 0; color: var(--fg); }
.actions { display: flex; gap: 4px; flex-wrap: wrap; flex: none; }
.actions .on { color: var(--accent); border-color: var(--accent-dim); }
.actions .on { color: var(--light); border-color: var(--light-dim); }
.actions .danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
.sharebar {
display: flex;
gap: 8px;
gap: 10px;
align-items: center;
padding: 4px 12px;
background: var(--bg-lift);
border-bottom: 1px solid var(--line);
margin: 0 24px 8px 28px;
padding: 6px 6px 6px 12px;
border: 1px solid var(--rim);
border-radius: var(--radius-m);
background: var(--surface);
font-size: 12px;
overflow-x: auto;
white-space: nowrap;
flex: none;
}
.ring {
width: 8px;
height: 8px;
flex: none;
border: 1.5px solid var(--light);
border-radius: 999px;
}
.public { color: var(--light); }
.sharebar .link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
.title {
flex: none;
margin: 8px 12px 0;
margin: 8px 24px 4px 28px;
padding: 2px 0 6px;
background: transparent;
border: 0;
border-bottom: 1px dashed var(--line);
border-bottom: 1px solid var(--overlay);
border-radius: 0;
color: var(--accent);
font-weight: 700;
padding: 2px 4px;
color: var(--light);
font-family: var(--font-sans);
font-size: 24px;
font-weight: 300;
}
.title:focus { border-bottom-color: var(--light-dim); }
.loading { padding: 16px 20px; }
.loading { padding: 16px 28px; }
.body { flex: 1; min-height: 0; overflow: hidden; }
.reading { overflow-y: auto; padding: 16px 20px 30vh; }
.entrytitle { margin-top: 0 !important; }
.entrytitle::before { content: none !important; }
.reading { overflow-y: auto; padding: 18px 28px 30vh; }
.entrytitle { margin-top: 0 !important; font-size: 2.1em !important; }
.key {
font-family: var(--font-mono);
font-size: 12px;
padding: 0 5px;
border-radius: 4px;
background: var(--light);
color: var(--ink);
}
.mobilebar {
display: flex;
gap: 6px;
padding: 6px 12px calc(6px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: var(--bg-alt);
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--rim);
flex: none;
}
.mobilebar button { flex: 1; padding: 8px; }
@media (max-width: 760px) {
header { padding: 14px 16px 10px; }
.row { flex-direction: column; align-items: flex-start; }
.sharebar { margin: 0 16px 8px; }
.title { margin: 8px 16px 4px; }
.reading { padding: 14px 16px 30vh; }
}
</style>
+19 -19
View File
@@ -27,16 +27,16 @@
let view: EditorView | undefined
const markdownHighlight = HighlightStyle.define([
{ tag: tags.heading, color: 'var(--accent)', fontWeight: '700' },
{ tag: tags.strong, color: 'var(--fg)', fontWeight: '700' },
{ tag: tags.emphasis, color: 'var(--fg)', fontStyle: 'italic' },
{ tag: tags.link, color: 'var(--accent)' },
{ tag: tags.url, color: 'var(--accent-dim)' },
{ tag: tags.monospace, color: 'var(--warn)' },
{ tag: tags.quote, color: 'var(--fg-dim)', fontStyle: 'italic' },
{ tag: tags.list, color: 'var(--accent-dim)' },
{ tag: tags.contentSeparator, color: 'var(--fg-faint)' },
{ tag: tags.processingInstruction, color: 'var(--fg-faint)' },
{ tag: tags.heading, color: 'var(--light)', fontWeight: '600' },
{ tag: tags.strong, color: 'var(--light)', fontWeight: '700' },
{ tag: tags.emphasis, color: 'var(--subtle)', fontStyle: 'italic' },
{ tag: tags.link, color: 'var(--light)', textDecoration: 'underline', textDecorationColor: 'var(--overlay)' },
{ tag: tags.url, color: 'var(--light-dim)' },
{ tag: tags.monospace, color: 'var(--light-soft)', backgroundColor: 'var(--surface)' },
{ tag: tags.quote, color: 'var(--subtle)', fontStyle: 'italic' },
{ tag: tags.list, color: 'var(--light-dim)' },
{ tag: tags.contentSeparator, color: 'var(--muted)' },
{ tag: tags.processingInstruction, color: 'var(--muted)' },
])
/*
@@ -49,23 +49,23 @@
const editorTheme = EditorView.theme(
{
'&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' },
'.cm-content': { padding: '12px 16px', maxWidth: '90ch' },
'.cm-content': { padding: '12px 28px', maxWidth: '90ch' },
'.cm-line': { padding: '0' },
'.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--accent)' },
'.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--light)' },
'.cm-panels': {
backgroundColor: 'var(--bg-alt)',
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
borderColor: 'var(--line)',
borderColor: 'var(--rim)',
},
'.cm-panels-bottom': { borderTop: '1px solid var(--line)' },
'.cm-panels-bottom': { borderTop: '1px solid var(--rim)' },
/*
* The `:` prompt is a bare text node in the panel; the input follows it.
* The input is an inline-block whose baseline sits below the text node's,
* so the two are laid out as flex items instead of aligned by baseline.
*/
'.cm-vim-panel': {
padding: '2px 8px',
color: 'var(--accent)',
padding: '4px 12px',
color: 'var(--light)',
display: 'flex',
alignItems: 'center',
},
@@ -73,8 +73,8 @@
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'var(--accent)',
caretColor: 'var(--accent)',
color: 'var(--light)',
caretColor: 'var(--light)',
flex: '1',
},
},
+39 -30
View File
@@ -60,8 +60,7 @@
<div class="sheet">
<header>
<span class="accent">:help</span>
<span class="faint">everything is a key or a `:` command</span>
<span class="label rule"><span class="marker"></span><span class="title">help</span> everything is a key or a `:` command</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
@@ -92,7 +91,7 @@
<dl>
{#each specs as spec (spec.name)}
<dt>
{signature(spec)}{#if spec.args}<span class="args faint">{spec.args}</span>{/if}
{signature(spec)}{#if spec.args}<span class="args muted">{spec.args}</span>{/if}
</dt>
<dd>{spec.help}</dd>
{#if spec.bang}
@@ -105,59 +104,69 @@
{/each}
</div>
<footer class="faint">
<footer>
inside the editor the full vim keymap is live — hjkl, w/b, dd, ciw, visual mode, macros, and
<span class="accent">:w</span> to write. drop or paste an image, video or audio file into the
editor to attach it. <span class="accent">:12</span> jumps straight to entry 12. yanks and
<span class="key">:w</span> to write. drop or paste an image, video or audio file into the
editor to attach it. <span class="key">:12</span> jumps straight to entry 12. yanks and
deletes are copied to the system clipboard —
<span class="accent">:set noclipboard</span> keeps them in vim's registers.
<span class="key">:set noclipboard</span> keeps them in vim's registers.
</footer>
</div>
<style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
width: min(100%, 110ch);
gap: 18px;
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;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
}
header { display: flex; align-items: baseline; gap: 12px; }
header .faint { flex: 1; font-size: 12px; }
header button { align-self: center; }
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.sheet { width: min(100%, 112ch); overflow-y: auto; overflow-x: hidden; }
/* Newspaper columns, so the short groups fill the gaps under the long ones. */
.cols { columns: 38ch; column-gap: 28px; }
.cols { columns: 38ch; column-gap: 32px; }
section { break-inside: avoid; min-width: 0; margin: 0 0 16px; }
section { break-inside: avoid; min-width: 0; margin: 0 0 20px; }
h2 {
font-size: 12px;
color: var(--fg-dim);
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 8px;
font-size: 10px;
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0 0 6px;
border-bottom: 1px dashed var(--line);
padding-bottom: 4px;
color: var(--muted);
}
h2::after { content: ''; height: 1px; flex: 1; background: var(--overlay); }
dl {
margin: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 3px 12px;
gap: 4px 14px;
font-size: 12px;
}
dt { color: var(--accent); white-space: nowrap; }
dt { color: var(--light); white-space: nowrap; }
.args { margin-left: 0.6ch; }
dd { margin: 0; color: var(--fg-dim); overflow-wrap: anywhere; }
dd { margin: 0; color: var(--subtle); overflow-wrap: anywhere; }
footer { font-size: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
footer {
font-size: 12px;
color: var(--muted);
border-top: 1px solid var(--overlay);
padding-top: 12px;
line-height: 1.7;
}
.key { color: var(--light); }
</style>
+68 -25
View File
@@ -35,26 +35,30 @@
</script>
<main>
<form onsubmit={submit}>
<pre class="banner">{`╭──────────────────────────────────╮
│ ~/workspace │
╰──────────────────────────────────╯`}</pre>
<form class="resolve" onsubmit={submit}>
<div class="brand">
<span class="mark" aria-hidden="true"></span>
<h1 class="display">workspace</h1>
</div>
<p class="faint">a private workspace. one user. one machine.</p>
<p class="label rule">a private workspace · one user</p>
<label>
<span class="accent">login:</span>
<span class="label">login</span>
<input bind:this={field} bind:value={username} autocomplete="username" spellcheck="false" />
</label>
<label>
<span class="accent">password:</span>
<span class="label">password</span>
<input type="password" bind:value={password} autocomplete="current-password" />
</label>
<div class="row">
<button type="submit" disabled={busy}>{busy ? 'authenticating…' : 'enter'}</button>
{#if error}<span class="error">{error}</span>{/if}
<button class="enter" type="submit" disabled={busy}>
<span class="dot"></span>
{busy ? 'authenticating' : 'enter'}
</button>
{#if error}<span class="alarm">{error}</span>{/if}
</div>
</form>
</main>
@@ -65,31 +69,70 @@
place-items: center;
height: 100%;
padding: 24px;
background:
radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%),
var(--base);
}
form {
display: flex;
flex-direction: column;
gap: 12px;
width: min(100%, 46ch);
background: var(--bg-alt);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
gap: 16px;
width: min(100%, 44ch);
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 28px;
}
.banner {
color: var(--accent);
font-size: 13px;
line-height: 1.3;
margin: 0;
overflow-x: auto;
.brand { display: flex; align-items: center; gap: 14px; }
h1 { margin: 0; font-size: 26px; text-shadow: 0 2px 24px var(--ink); }
/* The ring-and-dot mark, larger than in the bar. */
.mark {
position: relative;
width: 18px;
height: 18px;
flex: none;
border: 2px solid var(--light);
border-radius: 999px;
}
.mark::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 999px;
background: var(--light);
}
p { margin: 0 0 4px; font-size: 12px; }
p { margin: 0 0 4px; font-size: 10px; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
label { display: flex; flex-direction: column; gap: 6px; }
label .label { font-size: 10px; }
input { height: 36px; }
.row { display: flex; align-items: center; gap: 12px; }
.row button { padding: 6px 16px; }
.row { display: flex; align-items: center; gap: 14px; margin-top: 4px; }
/* The desktop's action button: a hairline in light, a dot, a spaced label. */
.enter {
display: flex;
align-items: center;
gap: 10px;
height: 44px;
padding: 0 20px 0 16px;
font-size: 11px;
letter-spacing: 0.22em;
color: var(--light);
border-color: color-mix(in oklab, var(--light) 55%, transparent);
}
.dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
opacity: 0.35;
transition: opacity var(--t-base) var(--ease-glide);
}
.enter:hover .dot, .enter:focus-visible .dot { opacity: 1; }
.alarm { font-size: 12px; }
</style>
+35 -21
View File
@@ -21,8 +21,7 @@
<div class="sheet">
<header>
<span class="accent">:media</span>
<span class="faint">{workspace.media.length} files</span>
<span class="label rule"><span class="marker"></span><span class="title">media</span> {workspace.media.length} files</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
@@ -33,13 +32,13 @@
{#if file.mime.startsWith('image/')}
<img src={file.url} alt={file.filename} loading="lazy" />
{:else}
<span class="faint">{file.mime.split('/')[0]}</span>
<span class="label">{file.mime.split('/')[0]}</span>
{/if}
</div>
<div class="info">
<span class="name">{file.filename}</span>
<span class="faint">
<span class="muted">
{formatBytes(file.size)} · {formatStamp(file.created_at)} ·
{usage(file.node_ids)}
</span>
@@ -55,50 +54,65 @@
>
{/if}
<button onclick={() => workspace.copy(location.origin + file.url)}>copy url</button>
<button onclick={() => remove(file.id, file.filename, file.node_ids)}>rm</button>
<button class="danger" onclick={() => remove(file.id, file.filename, file.node_ids)}>rm</button>
</div>
</li>
{:else}
<li class="faint">nothing uploaded yet — attach a file from the editor.</li>
<li class="muted">nothing uploaded yet — attach a file from the editor.</li>
{/each}
</ul>
</div>
<style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 10px;
width: min(100%, 90ch);
gap: 18px;
max-height: 100%;
background: var(--bg-alt);
border: 1px solid var(--accent-dim);
border-radius: var(--radius);
padding: 14px 16px;
overflow: hidden;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
animation: resolve var(--t-slow) var(--ease-snap) both;
}
header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.sheet { width: min(100%, 92ch); overflow: hidden; }
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
li { display: flex; align-items: center; gap: 12px; padding: 4px; border-bottom: 1px solid var(--line); }
li {
display: flex;
align-items: center;
gap: 14px;
padding: 6px 8px;
border-radius: var(--radius-m);
transition: background-color var(--t-base) var(--ease-glide);
}
li:hover { background: var(--surface); }
.thumb {
width: 48px;
height: 36px;
width: 52px;
height: 38px;
flex: none;
display: grid;
place-items: center;
background: var(--bg);
border: 1px solid var(--line);
background: var(--surface);
border: 1px solid var(--rim);
border-radius: var(--radius-s);
overflow: hidden;
font-size: 10px;
}
.thumb .label { font-size: 9px; letter-spacing: 0.14em; }
.thumb img { width: 100%; height: 100%; object-fit: cover; }
.info { display: flex; flex-direction: column; min-width: 0; flex: 1; font-size: 12px; }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); font-family: var(--font-sans); font-size: 14px; }
.muted { font-size: 11px; }
.actions { display: flex; gap: 4px; flex: none; }
.danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
</style>
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
/*
* The right edge of the frame: the things a pointer needs a door for. Each
* door is labelled with the key that does the same thing, so the rail
* teaches the keymap instead of replacing it. `compact` lays the same doors
* out in a row, for the bottom band on a phone.
*/
import { hooks, runCommand } from './commands'
import { workspace } from './store.svelte'
const { compact = false }: { compact?: boolean } = $props()
type Door = { key: string; caption: string; title: string; run: () => unknown; on?: boolean }
const doors = $derived.by((): Door[] => {
if (workspace.view === 'board') {
return [
{ key: 'o', caption: 'card', title: 'new card on this list (o)', run: () => hooks.openCommandLine(':card ') },
{ key: '+', caption: 'list', title: 'new list (:list <name>)', run: () => hooks.openCommandLine(':list ') },
{ key: 'b', caption: 'tree', title: 'back to the tree (b, q)', run: () => runCommand('board'), on: true },
{ key: ':', caption: 'cmd', title: 'command line (:)', run: () => hooks.openCommandLine(':') },
{ key: '?', caption: 'help', title: 'help (?)', run: () => runCommand('help') },
]
}
const board: Door = workspace.space?.has_board
? { key: 'b', caption: 'board', title: 'board (b)', run: () => runCommand('board') }
: { key: '+b', caption: 'board', title: 'give this space a board (:set board)', run: () => runCommand('set board') }
return [
{ key: 'o', caption: 'new', title: 'new document (o)', run: () => runCommand('new') },
{ key: 'O', caption: 'dir', title: 'new folder (O)', run: () => hooks.openCommandLine(':mkdir ') },
...(workspace.space ? [board] : []),
{ key: '/', caption: 'find', title: 'search (/)', run: () => hooks.openCommandLine('/') },
{ key: ':', caption: 'cmd', title: 'command line (:)', run: () => hooks.openCommandLine(':') },
{ key: '?', caption: 'help', title: 'help (?)', run: () => runCommand('help') },
]
})
</script>
<nav class="rail" class:compact aria-label="tools">
{#each doors as door (door.caption)}
<button class="door" class:on={door.on} title={door.title} onclick={door.run}>
<span class="key">{door.key}</span>
<span class="caption">{door.caption}</span>
</button>
{/each}
</nav>
<style>
.rail {
position: absolute;
top: var(--bar-h);
right: 0;
bottom: var(--status-h);
width: var(--rail-w);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 6px 0;
}
.door {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 40px;
padding: 7px 0 6px;
border: 0;
border-radius: var(--radius-s);
background: transparent;
color: var(--muted);
letter-spacing: 0;
text-transform: none;
}
/* A light bar grows down the left edge on hover, as on the desktop rail. */
.door::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
border-radius: 2px;
background: var(--light);
transform: scaleY(0);
transform-origin: top;
transition: transform var(--t-base) var(--ease-snap);
}
.door:hover { background: var(--surface-alt); color: var(--light); }
.door:hover::before { transform: scaleY(1); }
.door.on { color: var(--light); }
.door:focus-visible { background: var(--light); color: var(--ink); }
.key {
font-size: 15px;
line-height: 1;
transition: transform var(--t-base) var(--ease-snap);
}
.door:hover .key { transform: scale(1.08); }
.caption {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
@media (max-width: 767px) {
.rail:not(.compact) { display: none; }
}
.compact {
position: static;
width: auto;
flex-direction: row;
padding: 0;
gap: 2px;
}
.compact .door { width: 38px; padding: 4px 0; }
.compact .door::before { display: none; }
.compact .caption { display: none; }
</style>
+18 -19
View File
@@ -25,20 +25,21 @@
<main>
{#if error}
<p class="error">{error}</p>
<p class="alarm">{error}</p>
{:else if doc}
<article class="md">
<div class="meta faint">
{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)}
<article class="md resolve">
<div class="meta label rule">
<span class="marker"></span>
<span>{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)}</span>
<span class="tag">shared document</span>
</div>
{#if doc.name.trim()}<h1>{doc.name}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html renderMarkdown(doc.body, { token, key: shareKey })}
</article>
<footer class="faint">— written in ~/workspace</footer>
<footer class="label">— written in ~/workspace</footer>
{:else}
<p class="faint">loading…</p>
<p class="label">loading<span class="cursor">_</span></p>
{/if}
</main>
@@ -46,26 +47,24 @@
main {
height: 100%;
overflow-y: auto;
padding: 40px 20px 20vh;
padding: 56px 20px 20vh;
display: flex;
flex-direction: column;
align-items: center;
background:
radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%),
var(--base);
}
article { width: min(100%, 78ch); }
article { width: min(100%, 72ch); }
.meta { display: flex; gap: 10px; align-items: center; font-size: 12px; margin-bottom: 12px; }
.meta { margin-bottom: 28px; font-size: 10px; }
.meta .marker { background: var(--light); }
.tag {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 6px;
color: var(--accent-dim);
}
.tag { color: var(--light); }
h1 { margin-top: 0 !important; }
h1::before { content: none !important; }
h1 { margin-top: 0 !important; font-size: 2.1em !important; }
footer { width: min(100%, 78ch); margin-top: 40px; font-size: 12px; }
p { width: min(100%, 78ch); }
footer { width: min(100%, 72ch); margin-top: 56px; font-size: 10px; }
p { width: min(100%, 72ch); }
</style>
+91 -86
View File
@@ -1,7 +1,5 @@
<script lang="ts">
import type { NodeSummary } from './api'
import { hooks, runCommand } from './commands'
import SpaceBar from './SpaceBar.svelte'
import { workspace } from './store.svelte'
import { formatDay, formatTime, formatWeekday } from './util'
@@ -29,33 +27,16 @@
</script>
<div class="sidebar">
<div class="head">
<span class="accent name">~/{workspace.space?.slug ?? 'diary'}</span>
<span class="faint count">
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}
</span>
<span class="tools">
<!-- The board is the one view with no row in the list to click, so it
needs a door of its own; a space without one is offered the switch. -->
{#if workspace.space?.has_board}
<button class="board" title="board (b)" onclick={() => runCommand('board')}>board</button>
{:else if workspace.space}
<button class="board faint" title="give this space a board (:set board)" onclick={() => runCommand('set board')}>+board</button>
{/if}
<button title="new document (o)" onclick={() => runCommand('new')}>+</button>
<button title="new folder (O)" onclick={() => hooks.openCommandLine(':mkdir ')}>[+]</button>
<button title="search (/)" onclick={() => hooks.openCommandLine('/')}>/</button>
<button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button>
<button title="help (?)" onclick={() => runCommand('help')}>?</button>
</span>
<div class="head label rule">
<span class="marker"></span>
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
<span class="count">{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}</span>
</div>
<SpaceBar />
{#if workspace.query}
<div class="filter">
<span class="faint">/</span>{workspace.query}
<button title="clear search" onclick={() => workspace.guard(() => workspace.search(''))}>esc</button>
<span class="muted">/</span><span class="query">{workspace.query}</span>
<button title="clear search (n)" onclick={() => workspace.guard(() => workspace.search(''))}>esc</button>
</div>
{/if}
@@ -65,9 +46,8 @@
<!-- Day headings only make sense in a list that is a journal: documents,
in date order, not a search and not a folder listing. -->
{#if !workspace.query && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
<li class="daybreak">
<span>{formatDay(node.created_at)}</span>
<span class="faint">{formatWeekday(node.created_at)}</span>
<li class="daybreak label rule">
<span><span class="day">{formatDay(node.created_at)}</span> {formatWeekday(node.created_at)}</span>
</li>
{/if}
<li>
@@ -78,15 +58,15 @@
data-index={index}
onclick={() => pick(index)}
>
<span class="caret">{index === workspace.cursor ? '>' : ' '}</span>
{#if workspace.marked?.id === node.id}<span class="marked" title="marked — p drops it in a folder">m</span>{/if}
<span class="time">{aside(node)}</span>
<span class="title">{node.kind === 'document' ? '' : '/'}{label(node)}</span>
{#if node.shared}<span class="shared" title="shared">◉</span>{/if}
<span class="dot"></span>
<span class="title">{label(node)}{#if node.kind !== 'document'}<span class="slash">/</span>{/if}</span>
{#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if}
{#if node.shared}<span class="shared" title="shared"></span>{/if}
<span class="aside">{aside(node)}</span>
</button>
</li>
{:else}
<li class="empty faint">
<li class="empty muted">
{workspace.query ? 'no matches' : 'nothing here yet — o writes, O makes a folder'}
</li>
{/each}
@@ -98,87 +78,112 @@
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-alt);
border-right: 1px solid var(--line);
min-width: 0;
--pane: color-mix(in oklab, var(--surface) 55%, var(--base));
background: var(--pane);
border-right: 1px solid var(--rim);
}
.head,
.filter {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid var(--line);
.head {
flex: none;
}
.filter {
color: var(--warn);
overflow: hidden;
padding: 16px 16px 10px 18px;
white-space: nowrap;
}
.head .marker { background: var(--light); }
.name { color: var(--light); min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.count { flex: none; }
.filter {
display: flex;
align-items: center;
gap: 6px;
margin: 0 10px 6px;
padding: 4px 6px 4px 10px;
border-radius: var(--radius-s);
background: var(--surface-alt);
font-size: 12px;
flex: none;
}
.query { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--light); }
ul {
list-style: none;
margin: 0;
padding: 4px 0 24px;
padding: 2px 8px 24px;
overflow-y: auto;
flex: 1;
}
.daybreak {
display: flex;
gap: 8px;
padding: 10px 10px 2px;
color: var(--fg-dim);
font-size: 12px;
position: sticky;
top: 0;
background: var(--bg-alt);
z-index: 1;
padding: 14px 8px 6px 10px;
font-size: 10px;
background: linear-gradient(var(--pane) 70%, transparent);
}
.day { color: var(--subtle); }
/*
* A row rests bare, lifts a step on hover, and the cursor inverts it — light
* fill, ink text — the same mark focus makes everywhere else.
*/
.row {
display: flex;
align-items: baseline;
gap: 8px;
align-items: center;
gap: 10px;
width: 100%;
margin: 1px 0;
padding: 5px 10px;
border: 0;
border-radius: 0;
padding: 2px 10px;
border-radius: var(--radius-s);
text-align: left;
color: var(--fg-dim);
font-size: 13px;
letter-spacing: 0;
text-transform: none;
color: var(--subtle);
}
.row:hover { background: var(--surface-alt); color: var(--light); border-color: transparent; }
.row:active { transform: none; }
.row.open { color: var(--light); }
.row.active, .row:focus-visible { background: var(--light); color: var(--ink); }
.row.active :is(.aside, .slash, .badge), .row:focus-visible :is(.aside, .slash, .badge) { color: var(--ink); }
.row:hover { background: var(--bg-lift); color: var(--fg); }
.row.open { color: var(--fg); }
.row.active { background: var(--select); color: var(--fg); }
.caret { color: var(--accent); flex: none; width: 1ch; }
.time { color: var(--fg-faint); flex: none; font-size: 12px; }
.title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.shared { color: var(--accent); flex: none; }
.marked { color: var(--warn); flex: none; font-weight: 700; }
.empty { padding: 12px 10px; }
.name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The count is short and fixed-width-ish; the space name is the part that
can give way when the sidebar is narrow. */
.count { flex: none; white-space: nowrap; display: none; }
.tools { display: flex; gap: 1px; flex: none; }
.tools button { padding: 0 5px; line-height: 18px; }
@media (min-width: 480px) {
.count { display: inline; }
/* The open document carries a dot; the others keep its place. */
.dot {
width: 6px;
height: 6px;
flex: none;
border-radius: 999px;
background: transparent;
transition: background-color var(--t-base) var(--ease-glide);
}
.tools .board { color: var(--accent); }
.tools .board.faint { color: var(--fg-faint); }
.row.open .dot { background: var(--light); }
.row.open.active .dot { background: var(--ink); }
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-sans);
font-size: 14px;
}
.slash { color: var(--muted); margin-left: 1px; }
.aside { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.badge { flex: none; font-size: 11px; font-weight: 700; color: var(--light-dim); }
.shared {
width: 7px;
height: 7px;
flex: none;
border: 1.5px solid currentColor;
border-radius: 999px;
}
.empty { padding: 12px 10px; font-size: 12px; }
/* Roomier tap targets where there is no keyboard. */
@media (pointer: coarse) {
.row { padding: 7px 10px; }
.tools button { padding: 4px 10px; }
.count { display: none; }
.row { padding: 9px 10px; }
}
</style>
-78
View File
@@ -1,78 +0,0 @@
<script lang="ts">
/*
* The two lines above the list: which space is open, and where inside it the
* list currently is. Both are clickable, because a tree is the one thing that
* is genuinely faster to navigate with a pointer than with `:cd`.
*/
import { hooks } from './commands'
import { workspace } from './store.svelte'
// The space is the first crumb, and it is already named by the bar above.
const crumbs = $derived(workspace.path.slice(1))
</script>
<div class="spaces">
{#each workspace.spaces as space (space.id)}
<button
class="space"
class:on={space.id === workspace.spaceId}
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
>
{space.slug}
</button>
{/each}
<button class="space add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
+
</button>
</div>
{#if crumbs.length > 0}
<div class="crumbs">
<button onclick={() => workspace.spaceId !== null && workspace.guard(() => workspace.goTo(workspace.spaceId!))}>
/
</button>
{#each crumbs as crumb, index (crumb.id)}
<span class="faint">/</span>
<button
class:last={index === crumbs.length - 1}
onclick={() => workspace.guard(() => workspace.goTo(crumb.id))}
>
{crumb.slug}
</button>
{/each}
</div>
{/if}
<style>
.spaces,
.crumbs {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
border-bottom: 1px solid var(--line);
flex: none;
overflow-x: auto;
white-space: nowrap;
}
.crumbs { font-size: 12px; }
button {
border: 0;
border-radius: 0;
background: transparent;
color: var(--fg-dim);
padding: 0 6px;
line-height: 18px;
}
button:hover { color: var(--fg); background: var(--bg-lift); }
.space.on { color: var(--bg); background: var(--accent-dim); font-weight: 700; }
.add { color: var(--fg-faint); }
.crumbs .last { color: var(--accent); }
@media (pointer: coarse) {
button { padding: 4px 10px; }
}
</style>
+44 -30
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import Rail from './Rail.svelte'
import { workspace } from './store.svelte'
import { isTouchDevice } from './util'
@@ -8,65 +9,78 @@
// The path is the file name here: work/einarbeiten/01-was-ist-eebus.
const file = $derived(
workspace.open
? `${workspace.here}/${workspace.open.slug}${workspace.dirty ? ' [+]' : ''}`
: `${workspace.here || '~'}/`,
workspace.open ? `${workspace.here}/${workspace.open.slug}` : `${workspace.here || '~'}/`,
)
const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null)
</script>
<footer>
<span class="mode" class:insert={mode === 'INSERT'} class:visual={mode.startsWith('VISUAL')}>
<span
class="mode"
class:insert={mode === 'INSERT' || mode === 'EDIT'}
class:visual={mode.startsWith('VISUAL')}
>
{mode}
</span>
<span class="file">{file}</span>
<span class="file">{file}{#if workspace.dirty}<span class="dirty"> [+]</span>{/if}</span>
<span class="msg" class:error={workspace.flash?.kind === 'error'}>
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>
{workspace.flash?.text ?? ''}
</span>
{#if lines !== null}<span class="faint">{lines}L</span>{/if}
<span class="faint hint">
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
<span class="hint">
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
</span>
<span class="user accent">{workspace.user}</span>
<div class="tools"><Rail compact /></div>
</footer>
<style>
footer {
position: absolute;
inset: auto 0 0 0;
height: var(--status-h);
display: flex;
align-items: center;
gap: 12px;
padding: 0 8px 0 0;
height: 24px;
background: var(--bg-lift);
border-top: 1px solid var(--line);
font-size: 12px;
flex: none;
gap: 14px;
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 4px);
font-size: 11px;
color: var(--subtle);
white-space: nowrap;
overflow: hidden;
}
.mode {
background: var(--accent-dim);
color: var(--bg);
font-weight: 700;
padding: 0 10px;
height: 100%;
display: flex;
align-items: center;
letter-spacing: 0.08em;
height: 18px;
padding: 0 8px;
border-radius: 4px;
flex: none;
font-weight: 600;
letter-spacing: 0.18em;
background: var(--surface-alt);
color: var(--fg);
transition: background-color var(--t-base) var(--ease-glide), color var(--t-base) var(--ease-glide);
}
.mode.insert { background: var(--accent); }
.mode.visual { background: var(--warn); }
.mode.insert { background: var(--light); color: var(--ink); }
.mode.visual { background: var(--light-dim); color: var(--ink); }
.file { color: var(--fg-dim); flex: none; }
.msg { flex: 1; overflow: hidden; text-overflow: ellipsis; color: var(--fg); }
.msg.error { color: var(--error); }
.user { flex: none; }
.file { flex: none; color: var(--subtle); }
.dirty { color: var(--light); }
.msg { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--fg); }
.msg.alarm { color: var(--alarm); }
.readout { color: var(--fg); font-variant-numeric: tabular-nums; }
.unit { color: var(--muted); margin-left: 1px; }
.hint { color: var(--muted); letter-spacing: 0.06em; }
@media (max-width: 720px) {
.hint { display: none; }
.tools { display: none; }
@media (max-width: 767px) {
footer { padding-right: 6px; gap: 10px; }
.hint, .file, .readout { display: none; }
.tools { display: flex; flex: none; }
}
</style>
+3 -22
View File
@@ -1,5 +1,5 @@
import { api, type BackupStatus } from './api'
import { workspace, type Theme } from './store.svelte'
import { workspace } from './store.svelte'
import { formatDay, parseDay } from './util'
/** How far the reading pane is asked to scroll: lines, a half page, or an end. */
@@ -58,16 +58,6 @@ const findFolder = (target: string) => {
)
}
const themes: Theme[] = ['mocha', 'green', 'amber', 'ice']
const setTheme = (name: string) => {
if (!themes.includes(name as Theme)) {
return workspace.say(`unknown theme: ${name} — try ${themes.join(', ')}`, 'error')
}
workspace.setTheme(name as Theme)
workspace.say(`theme ${name}`)
}
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
const ago = (at: number | null) => {
if (!at) return 'never'
@@ -649,18 +639,11 @@ export const commands: CommandSpec[] = [
)
},
},
{
name: 'theme',
args: '<mocha|green|amber|ice>',
group: 'view',
help: 'switch the colour scheme',
run: (arg) => setTheme(arg.trim() || workspace.theme),
},
{
name: 'set',
args: '<option>',
group: 'view',
help: 'theme=mocha|green|amber|ice, vim, novim, clipboard, noclipboard, board, noboard',
help: 'vim, novim, clipboard, noclipboard, board, noboard',
run: (arg) => {
const option = arg.trim()
if (option === 'vim') return workspace.setVim(true), workspace.say('vim keys on')
@@ -673,8 +656,6 @@ export const commands: CommandSpec[] = [
// options above this one goes to the server.
if (option === 'board') return workspace.guard(() => workspace.setBoard(true))
if (option === 'noboard') return workspace.guard(() => workspace.setBoard(false))
const theme = /^theme=(\w+)$/.exec(option)?.[1]
if (theme) return setTheme(theme)
workspace.say(`unknown option: ${option}`, 'error')
},
},
@@ -756,7 +737,7 @@ for (const spec of commands) {
for (const alias of spec.aliases ?? []) lookup.set(alias, spec)
}
/** Run an ex-command line such as `w`, `date 2026-01-01` or `set theme=amber`. */
/** Run an ex-command line such as `w`, `date 2026-01-01` or `set novim`. */
export async function runCommand(line: string): Promise<void> {
const input = line.trim().replace(/^:/, '')
if (!input) return
-8
View File
@@ -13,7 +13,6 @@ export type Pane = 'list' | 'document'
/** The tree of documents, or the board of the space being browsed. */
export type View = 'tree' | 'board'
export type Flash = { text: string; kind: 'info' | 'error' } | null
export type Theme = 'mocha' | 'green' | 'amber' | 'ice'
/**
* The whole application state. It is deliberately one object: the keyboard
@@ -64,8 +63,6 @@ class Workspace {
overlay = $state<'none' | 'help' | 'media'>('none')
media = $state<MediaFile[]>([])
theme = $state<Theme>((localStorage.getItem('diary:theme') as Theme) ?? 'mocha')
vimEnabled = $state(localStorage.getItem('diary:vim') !== 'off')
/** Mirror vim yanks and deletes into the system clipboard, like `set clipboard=unnamed`. */
@@ -747,11 +744,6 @@ class Workspace {
this.media = await api.listMedia()
}
setTheme(theme: Theme) {
this.theme = theme
localStorage.setItem('diary:theme', theme)
}
setVim(enabled: boolean) {
this.vimEnabled = enabled
localStorage.setItem('diary:vim', enabled ? 'on' : 'off')
+2
View File
@@ -1,3 +1,5 @@
import '@fontsource-variable/jetbrains-mono'
import '@fontsource-variable/noto-sans'
import { mount } from 'svelte'
import App from './App.svelte'
import './app.css'