diff --git a/web/src/App.svelte b/web/src/App.svelte
index 5647dc1..0c5be2f 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -91,15 +91,16 @@
}
// 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'],
- ['j / k', 'browse, l opens'],
- ['1 … 9', 'switch space, Tab cycles'],
- ['w', 'overview of every space'],
- ['b', "this space's board"],
- ['/', 'search this space'],
- ['?', 'help'],
+ // Keys are keycaps, so `o` and `O` cannot be read as the same key.
+ const offers: [string[], string][] = [
+ [['o'], 'write a new document'],
+ [['⇧ O'], 'make a folder'],
+ [['j', 'k'], 'browse, l opens'],
+ [['1…9'], 'switch space, Tab cycles'],
+ [['w'], 'overview of every space'],
+ [['b'], "this space's board"],
+ [['/'], 'search this space'],
+ [['?'], 'help'],
]
/*
@@ -520,8 +521,8 @@
nothing open
- {#each offers as [key, what] (key)}
- - {key}
+ {#each offers as [keys, what] (what)}
+ - {#each keys as key (key)}{key}{/each}
- {what}
{/each}
- status
@@ -564,78 +565,9 @@
diff --git a/web/src/lib/Clock.svelte b/web/src/lib/Clock.svelte
new file mode 100644
index 0000000..8d801ac
--- /dev/null
+++ b/web/src/lib/Clock.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte
index 991bba4..cb6e91f 100644
--- a/web/src/lib/DocumentPane.svelte
+++ b/web/src/lib/DocumentPane.svelte
@@ -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 @@
+
+
{workspace.here}/{doc.slug}
{#if workspace.dirty}
modified{/if}
@@ -86,13 +74,10 @@
- edited
- {relative(doc.updated_at)}
- words
-
- {words}
+
- {words}words
- {#if isTouchDevice}
-
- {/if}
{#if workspace.editing}
@@ -104,7 +89,7 @@
{doc.shared ? 'unshare' : 'share'}
-
+
@@ -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); }
diff --git a/web/src/lib/Icon.svelte b/web/src/lib/Icon.svelte
new file mode 100644
index 0000000..193c556
--- /dev/null
+++ b/web/src/lib/Icon.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
diff --git a/web/src/lib/Outline.svelte b/web/src/lib/Outline.svelte
index f307d70..a343059 100644
--- a/web/src/lib/Outline.svelte
+++ b/web/src/lib/Outline.svelte
@@ -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; }
diff --git a/web/src/lib/Overview.svelte b/web/src/lib/Overview.svelte
index e0d9706..75fde50 100644
--- a/web/src/lib/Overview.svelte
+++ b/web/src/lib/Overview.svelte
@@ -86,7 +86,13 @@
{/each}
-
+
diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte
index 1e28bbc..5c0dcff 100644
--- a/web/src/lib/Rail.svelte
+++ b/web/src/lib/Rail.svelte
@@ -1,49 +1,92 @@
-