ui: share view in the shell, icon rail, phone polish

- shared view uses the bar, porthole and status band; outline gets its own
  window on wide screens; j/k, gg/G, Ctrl-d/u scroll with a vim-style ruler
- rail doors are line icons in groups with hover tooltips and keycaps
- phone: number-only space pills, no nested rims, icon dock, toast messages,
  back button and one-line meta in the document header
- keycaps (.kbd) wherever keys are shown, styled scrollbars in Chrome,
  outline tracks position on jumps, breakpoints unified at 767px
This commit is contained in:
nvrl committed 2026-10-17 17:27:59 +02:00
1 parent 9391b10af1
commit ecfbab0eaa
14 files changed
+796 -382

No files matched your search

+15 -93
View File
@@ -5,20 +5,13 @@
* 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 Clock from './Clock.svelte'
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">
@@ -33,7 +26,7 @@
title="{space.name} ({index < 10 ? (index + 1) % 10 : ''})"
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
>
{#if index < 10}<span class="n">{(index + 1) % 10}</span>{/if}{space.slug}
{#if index < 10}<span class="n">{(index + 1) % 10}</span>{/if}<span class="slug">{space.slug}</span>
</button>
{/each}
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
@@ -74,50 +67,10 @@
<span class="value">{workspace.user}</span>
</span>
<span class="divider wide"></span>
<time class="clock">{clock}</time>
<Clock />
</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;
@@ -129,35 +82,10 @@
.spaces { flex: none; max-width: 50%; }
.crumbs { flex: 0 1 auto; }
/* Workspace pills: muted at rest, light on hover, inverted when current. */
/* The focused pill widens, as the desktop's does, over the slow duration. */
.pill {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-width: 22px;
height: 20px;
padding: 0 6px;
transition:
padding var(--t-slow) var(--ease-snap),
background-color var(--t-base) var(--ease-glide),
color var(--t-base) var(--ease-glide);
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); padding: 0 12px; }
.n { color: var(--overlay); font-size: 10px; }
.pill:hover .n { color: var(--muted); }
.n { color: var(--muted); font-size: 10px; }
.pill:hover .n { color: var(--light-dim); }
.pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; }
.pill:focus-visible { background: var(--light); color: var(--ink); }
.pill.add { color: var(--overlay); }
.pill.add { color: var(--muted); }
.pill.add:hover { color: var(--light); }
/* A crumb is a link along the path; a hairline grows under it on hover. */
@@ -190,21 +118,15 @@
.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;
}
/*
* On a phone the other spaces are only their number, like the desktop's
* workspace dots, so all of them fit; the current one keeps its name.
*/
@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; }
.crumbs, .divider.path, .bar .user, .divider.wide { display: none; }
.spaces { max-width: none; flex: 0 1 auto; gap: 2px; }
.pill:not([aria-current='true']) .slug { display: none; }
.pill:not([aria-current='true']) { min-width: 28px; height: 24px; }
.pill:not([aria-current='true']) .n { font-size: 11px; color: var(--subtle); }
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
// The bar's clock: hours and minutes, ticked often enough to turn on time.
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>
<time class="clock">{clock}</time>
<style>
.clock {
color: var(--light);
letter-spacing: 0.12em;
font-variant-numeric: tabular-nums;
}
</style>
+40 -28
View File
@@ -3,7 +3,8 @@
import { renderMarkdown } from './markdown'
import Outline from './Outline.svelte'
import { workspace } from './store.svelte'
import { formatStamp, formatWeekday, isTouchDevice, relative } from './util'
import Icon from './Icon.svelte'
import { formatStamp, formatWeekday, isTouchDevice, relative, scrollPage } from './util'
const doc = $derived(workspace.open!)
const html = $derived(renderMarkdown(workspace.editing ? workspace.draft.body : doc.body))
@@ -30,24 +31,7 @@
// after the new one has set the hook — so it only clears its own.
const scroll = (amount: ScrollAmount) => {
if (!reader) return false
const line = parseFloat(getComputedStyle(reader).lineHeight) || 24
const half = reader.clientHeight / 2
switch (amount) {
case 'top':
reader.scrollTo({ top: 0 })
break
case 'bottom':
reader.scrollTo({ top: reader.scrollHeight })
break
case 'halfdown':
reader.scrollBy({ top: half })
break
case 'halfup':
reader.scrollBy({ top: -half })
break
default:
reader.scrollBy({ top: amount * line * 3 })
}
scrollPage(reader, amount)
return true
}
hooks.scrollReader = scroll
@@ -74,6 +58,10 @@
<section class="pane">
<header>
<div class="crumb label rule" title="{workspace.here}/{doc.slug}">
<!-- A phone has no crumbs on the bar and no h key: this is the way back. -->
<button class="back" aria-label="back to the list" onclick={() => runCommand('q')}>
<Icon name="back" size={16} />
</button>
<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}
@@ -86,13 +74,10 @@
<dt>edited</dt>
<dd>{relative(doc.updated_at)}</dd>
<dt>words</dt>
<dd>{words}</dd>
<dd>{words}<span class="unit">words</span></dd>
</dl>
<div class="actions">
{#if isTouchDevice}
<button onclick={() => runCommand('q')}>← list</button>
{/if}
{#if workspace.editing}
<button onclick={() => runCommand('w')}>:w</button>
<button onclick={() => runCommand('upload')}>attach</button>
@@ -104,7 +89,7 @@
{doc.shared ? 'unshare' : 'share'}
</button>
<button class="danger" onclick={() => runCommand('d')}>delete</button>
<button onclick={() => runCommand('q')}>close</button>
<button class="close" onclick={() => runCommand('q')}>close</button>
</div>
</div>
</header>
@@ -277,7 +262,7 @@
position: sticky;
top: 0;
max-width: 30ch;
padding: 6px 0 0 44px;
padding: 6px 0 0 32px;
}
@container (max-width: 1000px) {
.side { display: none; }
@@ -302,9 +287,36 @@
}
.mobilebar button { flex: 1; padding: 8px; }
@media (max-width: 760px) {
header { padding: 14px 16px 10px; }
.row { flex-direction: column; align-items: flex-start; }
.back { display: none; }
.unit { display: none; }
/*
* A phone: a back button where the bar's crumbs would be, the readout on one
* line without its captions, and the actions as one row of equal buttons
* big enough for a thumb. Closing is the back button's job.
*/
@media (max-width: 767px) {
header { padding: 10px 14px 10px 8px; gap: 8px; }
.crumb { gap: 8px; }
.back {
display: grid;
place-items: center;
width: 32px;
height: 32px;
flex: none;
padding: 0;
border: 0;
color: var(--light);
}
.row { flex-direction: column; align-items: stretch; gap: 10px; padding-left: 6px; }
.meta { flex-wrap: nowrap; gap: 0; overflow: hidden; white-space: nowrap; }
.meta dt { display: none; }
.meta dd { margin: 0; }
.meta dd + dt + dd::before { content: '·'; margin: 0 8px; color: var(--muted); }
.unit { display: inline; margin-left: 0.5ch; }
.actions { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; }
.actions button { height: 36px; }
.actions .close { display: none; }
.sharebar { margin: 0 16px 8px; }
.title { margin: 8px 16px 4px; }
.reading { padding: 14px 16px 30vh; grid-template-columns: minmax(0, 1fr); }
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts" module>
/*
* Line icons for the rail and the phone dock, drawn on a 24-unit grid with
* one stroke weight, so they read as one set the way the desktop's Nerd Font
* glyphs do. Paths rather than a font: there are a dozen of them.
*/
const PATHS = {
'doc-plus': ['M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z', 'M14 3v5h5', 'M12 11v6', 'M9 14h6'],
'folder-plus': ['M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', 'M12 10v6', 'M9 13h6'],
board: [
'M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z',
'M14 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z',
],
'column-plus': ['M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z', 'M17 8v8', 'M13 12h8'],
list: ['M9 6h11', 'M9 12h11', 'M9 18h11', 'M4.5 6h.01', 'M4.5 12h.01', 'M4.5 18h.01'],
grid: ['M4 4h6v6H4z', 'M14 4h6v6h-6z', 'M4 14h6v6H4z', 'M14 14h6v6h-6z'],
expand: ['M4 9V4h5', 'M20 9V4h-5', 'M4 15v5h5', 'M20 15v5h-5'],
shrink: ['M9 4v5H4', 'M15 4v5h5', 'M9 20v-5H4', 'M15 20v-5h5'],
search: ['M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14z', 'M20 20l-4-4'],
prompt: ['M5 7l5 5-5 5', 'M12 18h7'],
help: [
'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z',
'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6',
'M12 17h.01',
],
back: ['M15 5l-7 7 7 7'],
} satisfies Record<string, string[]>
export type IconName = keyof typeof PATHS
</script>
<script lang="ts">
const { name, size = 18 }: { name: IconName; size?: number } = $props()
</script>
<svg
class="icon"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{#each PATHS[name] as d (d)}<path {d} />{/each}
</svg>
<style>
.icon { display: block; flex: none; }
</style>
+27 -13
View File
@@ -41,18 +41,31 @@
headings = found
current = found[0]?.id ?? ''
// The section whose heading last crossed the upper third is the one being
// read; a band rather than a line, so a short section still registers.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) current = entry.target.id
}
},
{ root: scroller, rootMargin: '-10% 0px -70% 0px' },
)
for (const element of elements) observer.observe(element)
return () => observer.disconnect()
// The section being read is the last one whose heading has passed the
// upper third of the view — worked out from positions on every scroll,
// so a jump with G or Ctrl-d lands on the right one too. Scrolled to the
// very end, the last heading wins even if it never got that high.
let frame = 0
const spy = () => {
frame = 0
const top = scroller.getBoundingClientRect().top
const line = top + scroller.clientHeight / 3
const room = scroller.scrollHeight - scroller.clientHeight
const end = room > 2 && scroller.scrollTop >= room - 2
let id = found[0]?.id ?? ''
for (const element of elements) {
if (end || element.getBoundingClientRect().top <= line) id = element.id
else break
}
current = id
}
const onscroll = () => (frame ||= requestAnimationFrame(spy))
scroller.addEventListener('scroll', onscroll, { passive: true })
spy()
return () => {
scroller.removeEventListener('scroll', onscroll)
cancelAnimationFrame(frame)
}
})
function jump(id: string) {
@@ -105,5 +118,6 @@
button:focus-visible { background: var(--light); color: var(--ink); }
.marker { width: 5px; height: 5px; transform: translateY(-2px); }
.text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A margin is narrow: long headings wrap rather than lose their ends. */
.text { min-width: 0; line-height: 1.4; overflow-wrap: anywhere; }
</style>
+18 -2
View File
@@ -86,7 +86,13 @@
{/each}
</ol>
<footer class="label">h j k l move · 1 … 9 or enter go · w closes</footer>
<footer class="label">
<span class="kbd">h</span><span class="kbd">j</span><span class="kbd">k</span><span class="kbd">l</span> move
<span class="gap"></span>
<span class="kbd">1…9</span><span class="kbd">↵</span> go
<span class="gap"></span>
<span class="kbd">w</span><span class="kbd">esc</span> close
</footer>
</div>
<style>
@@ -200,5 +206,15 @@
.when { flex: none; font-size: 10px; color: var(--muted); }
.empty { padding: 4px 8px; font-size: 12px; color: var(--muted); }
footer { font-size: 10px; }
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
footer .kbd { margin-right: 2px; }
footer .gap { width: 14px; }
/* Keys mean nothing to a thumb. */
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
@media (max-width: 767px) {
.sheet { padding: 14px; gap: 14px; }
}
</style>
+159 -63
View File
@@ -1,49 +1,92 @@
<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.
* The right edge of the frame, built like the desktop's rail: a glyph per
* door, groups split by a hairline, the always-there things pinned to the
* bottom. The name and the key that does the same thing live in a tooltip,
* so the rail stays narrow and still teaches the keymap. `dock` lays the
* same doors out in a row for the bottom band on a phone, without the ones
* that only make sense with a keyboard or two panes.
*/
import { hooks, runCommand } from './commands'
import Icon, { type IconName } from './Icon.svelte'
import { workspace } from './store.svelte'
const { compact = false }: { compact?: boolean } = $props()
const { dock = false }: { dock?: boolean } = $props()
type Door = { key: string; caption: string; title: string; run: () => unknown; on?: boolean }
type Door = {
icon: IconName
label: string
keys: string[]
run: () => unknown
on?: boolean
dim?: boolean
desk?: boolean // only on the desktop rail
}
const groups = $derived.by((): Door[][] => {
const cmd: Door = { icon: 'prompt', label: 'Command line', keys: [':'], run: () => hooks.openCommandLine(':') }
const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help'), desk: true }
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') },
[
{ icon: 'doc-plus', label: 'New card on this list', keys: ['o'], run: () => hooks.openCommandLine(':card ') },
{ icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') },
{ icon: 'list', label: 'Back to the tree', keys: ['b', 'q'], run: () => runCommand('board'), on: true },
],
[cmd, 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') }
? { icon: 'board', label: 'Board', keys: ['b'], run: () => runCommand('board') }
: { icon: 'board', label: 'Give this space a board', keys: [':set board'], run: () => runCommand('set board'), dim: true }
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: 'w', caption: 'spaces', title: 'overview of every space (w)', run: () => hooks.openOverview() },
{ key: 'f', caption: 'full', title: 'fullscreen the focused pane (f)', run: () => (workspace.fullscreen = !workspace.fullscreen), on: workspace.fullscreen },
{ 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') },
[
{ icon: 'doc-plus', label: 'New document', keys: ['o'], run: () => runCommand('new') },
{ icon: 'folder-plus', label: 'New folder', keys: ['⇧ O'], run: () => hooks.openCommandLine(':mkdir ') },
...(workspace.space ? [board] : []),
],
[
{ icon: 'grid', label: 'Every space', keys: ['w'], run: () => hooks.openOverview() },
{
icon: workspace.fullscreen ? 'shrink' : 'expand',
label: workspace.fullscreen ? 'Leave fullscreen' : 'Fullscreen the focused pane',
keys: ['f'],
run: () => (workspace.fullscreen = !workspace.fullscreen),
on: workspace.fullscreen,
desk: true,
},
{ icon: 'search', label: 'Search this space', keys: ['/'], run: () => hooks.openCommandLine('/') },
],
[cmd, help],
]
})
const shown = $derived(dock ? [groups.flat().filter((door) => !door.desk)] : groups)
</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>
<nav class="rail" class:dock aria-label="tools">
{#each shown as group, index (index)}
<div class="group" class:pinned={index === shown.length - 1 && !dock}>
{#each group as door (door.label)}
<button
class="door"
class:on={door.on}
class:dim={door.dim}
aria-label="{door.label} ({door.keys.join(', ')})"
onclick={door.run}
>
<Icon name={door.icon} />
{#if !dock}
<span class="tip" role="tooltip">
<span class="name">{door.label}</span>
{#each door.keys as key (key)}<span class="kbd">{key}</span>{/each}
</span>
{/if}
</button>
{/each}
</div>
{/each}
</nav>
@@ -54,73 +97,126 @@
right: 0;
bottom: var(--status-h);
width: var(--rail-w);
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 6px 0;
}
.door {
position: relative;
.group {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 40px;
padding: 7px 0 6px;
}
/* A 16px hairline between groups, as on the desktop rail. */
.group + .group::before {
content: '';
width: 16px;
height: 1px;
margin: 8px 0;
background: color-mix(in oklab, var(--outline) 60%, transparent);
}
.group.pinned { margin-top: auto; }
.group.pinned::before { display: none; }
.door {
position: relative;
display: grid;
place-items: center;
width: var(--rail-w);
height: 34px;
padding: 0;
border: 0;
border-radius: var(--radius-s);
border-radius: 0;
background: transparent;
color: var(--muted);
letter-spacing: 0;
text-transform: none;
color: var(--light-soft);
}
/* A light bar grows down the left edge on hover, as on the desktop rail. */
/* The wipe down the leading edge on hover, held off the edge as on the desktop. */
.door::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
left: 5px;
top: 50%;
width: 2px;
border-radius: 2px;
height: 16px;
margin-top: -8px;
border-radius: 1px;
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:hover { background: transparent; color: var(--light); }
.door:hover::before, .door:focus-visible::before { transform: scaleY(1); }
.door:active { transform: none; }
.door :global(.icon) { transition: transform var(--t-base) var(--ease-snap); }
.door:hover :global(.icon) { transform: scale(1.08); }
.door.on { color: var(--light); }
.door:focus-visible { background: var(--light); color: var(--ink); }
.door.on::before { transform: scaleY(1); background: var(--light-dim); }
.door.dim { color: var(--muted); }
.door:focus-visible { background: transparent; color: var(--light); }
.key {
font-size: 15px;
line-height: 1;
transition: transform var(--t-base) var(--ease-snap);
/*
* The tooltip opens into the porthole, to the left of the door: a small
* card with the name in the reading face and the key as a keycap. A short
* delay on the way in, so running the pointer down the rail does not flash
* every one of them.
*/
.tip {
position: absolute;
right: calc(100% + 4px);
top: 50%;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px 6px 10px;
border: 1px solid var(--overlay);
border-radius: var(--radius-s);
background: var(--surface);
box-shadow: 0 6px 24px rgb(0 0 0 / 0.45);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translate(4px, -50%);
transition:
opacity var(--t-fast) var(--ease-glide),
transform var(--t-fast) var(--ease-glide);
}
.door:hover .key { transform: scale(1.08); }
.caption {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
.door:hover .tip, .door:focus-visible .tip {
opacity: 1;
transform: translate(0, -50%);
transition-delay: 250ms;
transition-duration: var(--t-base);
}
.name {
margin-right: 4px;
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: 0;
text-transform: none;
color: var(--fg);
}
@media (max-width: 767px) {
.rail:not(.compact) { display: none; }
.rail:not(.dock) { display: none; }
}
.compact {
/* The phone dock: the same doors in a row, each a thumb wide. */
.dock {
position: static;
flex: 1;
width: auto;
height: 100%;
flex-direction: row;
padding: 0;
gap: 2px;
}
.compact .door { width: 38px; padding: 4px 0; }
.compact .door::before { display: none; }
.compact .caption { display: none; }
.dock .group {
flex: 1;
flex-direction: row;
justify-content: space-around;
}
.dock .door { width: 44px; height: 40px; border-radius: var(--radius-s); }
.dock .door::before { display: none; }
.dock .door:active { background: var(--surface-alt); }
</style>
+158 -47
View File
@@ -1,16 +1,28 @@
<script lang="ts">
/*
* A shared document, seen through the same frame as the workspace it was
* written in: the bar, a porthole with the outline and the document tiled
* in it as two windows, the band along the bottom. No rail — a reader has
* no tools — but the reading keys work: j/k, gg/G, Ctrl-d/u.
*/
import { api, type SharedDocument } from './api'
import Clock from './Clock.svelte'
import { renderMarkdown } from './markdown'
import Outline from './Outline.svelte'
import { formatStamp, formatWeekday } from './util'
import { formatStamp, formatWeekday, scrollPage, slugify, type ScrollAmount } from './util'
const { token, shareKey }: { token: string; shareKey: string } = $props()
let doc = $state<SharedDocument | null>(null)
let error = $state('')
let page = $state<HTMLElement>()
let scroller = $state<HTMLElement>()
let article = $state<HTMLElement>()
let position = $state('All')
const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '')
const title = $derived(doc?.name.trim() ?? '')
const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0)
// The outline gets a window of its own only when there is something to outline.
const sections = $derived((html.match(/<h[23][\s>]/g) ?? []).length)
$effect(() => {
if (!shareKey) {
@@ -25,67 +37,166 @@
})
.catch(() => (error = 'this link is not valid, or the document is no longer shared.'))
})
/** Where the view is, the way vim's ruler says it: Top, Bot, All or a percentage. */
function measure() {
if (!scroller) return
const room = scroller.scrollHeight - scroller.clientHeight
const top = scroller.scrollTop
position = room <= 1 ? 'All' : top <= 1 ? 'Top' : top >= room - 1 ? 'Bot' : `${Math.round((top / room) * 100)}%`
}
$effect(() => {
void html
requestAnimationFrame(measure)
})
let pending = ''
function onkeydown(event: KeyboardEvent) {
if (!scroller || event.altKey || event.metaKey) return
let amount: ScrollAmount | null = null
if (event.ctrlKey) {
if (event.key === 'd') amount = 'halfdown'
else if (event.key === 'u') amount = 'halfup'
} else if (event.key === 'j' || event.key === 'ArrowDown') amount = 1
else if (event.key === 'k' || event.key === 'ArrowUp') amount = -1
else if (event.key === 'G') amount = 'bottom'
else if (event.key === 'g' && pending === 'g') amount = 'top'
pending = event.key === 'g' && pending !== 'g' ? 'g' : ''
if (amount === null) return
event.preventDefault()
scrollPage(scroller, amount)
}
</script>
<main bind:this={page}>
{#if error}
<p class="alarm">{error}</p>
{:else if doc}
<article class="md resolve" bind:this={article}>
<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>
<svelte:window {onkeydown} />
<div class="shell shared">
<header class="bar">
<span class="mark" aria-hidden="true"></span>
<span class="divider"></span>
<span class="pill" aria-current="true">shared</span>
<span class="divider"></span>
<span class="title">{title}</span>
<span class="spacer"></span>
<span class="readout ro"><span class="key">mode</span><span class="value">read only</span></span>
<span class="divider ro"></span>
<Clock />
</header>
<main class="porthole">
<div class="panes switch" class:split={sections >= 2}>
{#if doc && sections >= 2}
<aside class="window toc">
<Outline root={article} {scroller} source={html} />
</aside>
{/if}
<div class="window doc" class:focused={sections >= 2}>
<div class="body" bind:this={scroller} onscroll={measure}>
{#if error}
<div class="standby">
<h2 class="label rule">link</h2>
<p class="alarm">{error}</p>
</div>
{:else if doc}
<article class="md resolve" bind:this={article}>
<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 title}<h1>{title}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
<footer class="label">— written in ~/workspace</footer>
</article>
{:else}
<div class="standby">
<p class="label">loading<span class="cursor">_</span></p>
</div>
{/if}
</div>
</div>
{#if doc.name.trim()}<h1>{doc.name}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
</article>
<aside class="side">
<Outline root={article} scroller={page} source={html} />
</aside>
<footer class="label">— written in ~/workspace</footer>
{:else}
<p class="label">loading<span class="cursor">_</span></p>
{/if}
</main>
</div>
</main>
<footer class="status">
<span class="mode">read</span>
<span class="file">shared/{slugify(title) || token.slice(0, 8)}</span>
<span class="msg"></span>
{#if doc}
<span class="readout">{words}<span class="unit">W</span></span>
<span class="readout">{position}</span>
{/if}
<span class="hint">j k scroll · gg G ends · ^d ^u half</span>
</footer>
</div>
<style>
/* The same page as the reading pane: a centred column, the outline in the margin. */
main {
height: 100%;
/* No rail: the porthole and the bands run to the frame on the right too. */
.shared { --rail-w: var(--frame); }
.title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.06em;
text-transform: none;
color: var(--light);
}
.panes { grid-template-columns: minmax(0, 1fr); }
.panes.split { grid-template-columns: minmax(26ch, 22vw) minmax(0, 1fr); }
.toc { --pane-bg: color-mix(in oklab, var(--surface) 60%, var(--base)); padding: 22px 16px 22px 20px; overflow-y: auto; }
.doc { display: flex; flex-direction: column; }
/* A lone window has nothing to stand out from, so it keeps the plain rim. */
.doc:not(.focused) { border-color: transparent; background: transparent; }
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 56px 20px 20vh;
padding: 40px 28px 30vh;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 46rem) minmax(0, 1fr);
align-content: start;
align-items: start;
container-type: inline-size;
background:
radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%),
var(--base);
}
article, footer, p { grid-column: 2; }
.body > * { grid-column: 2; }
article { max-width: none; }
.side {
grid-column: 3;
grid-row: 1;
position: sticky;
top: 0;
max-width: 30ch;
padding: 4px 0 0 44px;
}
@container (max-width: 1000px) {
.side { display: none; }
}
.meta { margin-bottom: 28px; font-size: 10px; }
.meta .marker { background: var(--light); }
.tag { color: var(--light); }
h1 { margin-top: 0 !important; font-size: 2.1em !important; }
footer { margin-top: 56px; font-size: 10px; }
article footer { margin-top: 56px; font-size: 10px; }
.standby { padding-top: 18vh; }
.standby h2 { margin: 0 0 14px; font-weight: 400; }
.status .mode { background: var(--light); color: var(--ink); }
.file { color: var(--subtle); }
.msg { flex: 1; }
@media (max-width: 1000px) {
.panes.split { grid-template-columns: minmax(0, 1fr); }
.toc { display: none; }
.doc.focused {
background: linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
linear-gradient(var(--overlay), var(--overlay)) border-box;
box-shadow: none;
}
}
@media (max-width: 767px) {
.bar .ro { display: none; }
.body { padding: 24px 18px 30vh; grid-template-columns: minmax(0, 1fr); }
.body > * { grid-column: 1; }
.status { padding: 0 12px; gap: 12px; }
.hint, .file { display: none; }
}
</style>
+5 -1
View File
@@ -67,7 +67,11 @@
</li>
{:else}
<li class="empty muted">
{workspace.query ? 'no matches' : 'nothing here yet — o writes, O makes a folder'}
{#if workspace.query}
no matches
{:else}
nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder
{/if}
</li>
{/each}
</ul>
+28 -38
View File
@@ -15,9 +15,10 @@
const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null)
</script>
<footer>
<footer class="status">
<span
class="mode"
class:editing={workspace.editing}
class:insert={mode === 'INSERT' || mode === 'EDIT'}
class:visual={mode.startsWith('VISUAL')}
>
@@ -25,9 +26,7 @@
</span>
<span class="file">{file}{#if workspace.dirty}<span class="dirty"> [+]</span>{/if}</span>
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>
{workspace.flash?.text ?? ''}
</span>
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>{workspace.flash?.text ?? ''}</span>
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
@@ -35,37 +34,10 @@
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
</span>
<div class="tools"><Rail compact /></div>
<div class="tools"><Rail dock /></div>
</footer>
<style>
footer {
position: absolute;
inset: auto 0 0 0;
height: var(--status-h);
display: flex;
align-items: center;
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 {
display: flex;
align-items: center;
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(--light); color: var(--ink); }
.mode.visual { background: var(--light-dim); color: var(--ink); }
@@ -73,16 +45,34 @@
.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; }
.flag { color: var(--light); letter-spacing: 0.18em; text-transform: uppercase; }
.tools { display: none; }
/*
* On a phone the band is the dock. The mode only shows while writing, and
* messages float just above the band as a toast, so they never squeeze the
* doors.
*/
@media (max-width: 767px) {
footer { padding-right: 6px; gap: 10px; }
.hint, .file, .readout { display: none; }
.tools { display: flex; flex: none; }
.status { padding: 0 6px; gap: 6px; overflow: visible; }
.hint, .file, .readout, .flag { display: none; }
.mode:not(.editing) { display: none; }
.tools { display: flex; flex: 1; height: 100%; }
.msg {
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
max-width: calc(100% - 24px);
padding: 6px 12px;
border: 1px solid var(--overlay);
border-radius: var(--radius-s);
background: var(--surface);
box-shadow: 0 6px 24px rgb(0 0 0 / 0.45);
transform: translateX(-50%);
z-index: 20;
}
.msg:empty { display: none; }
}
</style>
+2 -3
View File
@@ -1,9 +1,8 @@
import { api, type BackupStatus } from './api'
import { workspace } from './store.svelte'
import { formatDay, parseDay } from './util'
import { formatDay, parseDay, type ScrollAmount } from './util'
/** How far the reading pane is asked to scroll: lines, a half page, or an end. */
export type ScrollAmount = number | 'top' | 'bottom' | 'halfdown' | 'halfup'
export type { ScrollAmount }
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
export const hooks = {
+25
View File
@@ -74,3 +74,28 @@ export function slugify(text: string): string {
.replace(/^-+|-+$/g, '')
.slice(0, 80)
}
/** How far a reading view is asked to scroll: lines, a half page, or an end. */
export type ScrollAmount = number | 'top' | 'bottom' | 'halfdown' | 'halfup'
/** j/k, gg/G and Ctrl-d/u on a scrolling element, three lines to a step. */
export function scrollPage(el: HTMLElement, amount: ScrollAmount) {
const line = parseFloat(getComputedStyle(el).lineHeight) || 24
const half = el.clientHeight / 2
switch (amount) {
case 'top':
el.scrollTo({ top: 0 })
break
case 'bottom':
el.scrollTo({ top: el.scrollHeight })
break
case 'halfdown':
el.scrollBy({ top: half })
break
case 'halfup':
el.scrollBy({ top: -half })
break
default:
el.scrollBy({ top: amount * line * 3 })
}
}