mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
9391b10af1
commit
ecfbab0eaa
14 files changed
+796
-382
No files matched your search
+18
-92
@@ -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 @@
|
||||
<div class="standby resolve">
|
||||
<h2 class="label rule">nothing open</h2>
|
||||
<dl>
|
||||
{#each offers as [key, what] (key)}
|
||||
<dt>{key}</dt>
|
||||
{#each offers as [keys, what] (what)}
|
||||
<dt>{#each keys as key (key)}<span class="kbd">{key}</span>{/each}</dt>
|
||||
<dd>{what}</dd>
|
||||
{/each}
|
||||
<dt>status</dt>
|
||||
@@ -564,78 +565,9 @@
|
||||
<style>
|
||||
.boot { display: grid; place-items: center; height: 100%; }
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* The panes tile the porthole like windows tile a workspace: a gap round
|
||||
* and between them, each rounded to sit concentric with the frame.
|
||||
*/
|
||||
.panes {
|
||||
--gap: 7px;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(32ch, 24vw) 1fr;
|
||||
gap: var(--gap);
|
||||
padding: var(--gap);
|
||||
}
|
||||
|
||||
.panes.board { grid-template-columns: 1fr; }
|
||||
|
||||
/*
|
||||
* A window: opaque, a hairline border in overlay. The focused one gets the
|
||||
* desktop's active border — a light gradient at 135° — and its shadow.
|
||||
*/
|
||||
.window {
|
||||
--pane-bg: color-mix(in oklab, var(--surface) 40%, var(--base));
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--frame-radius) - var(--gap));
|
||||
background:
|
||||
linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
|
||||
linear-gradient(var(--overlay), var(--overlay)) border-box;
|
||||
transition: box-shadow var(--t-base) var(--ease-glide);
|
||||
}
|
||||
.window.list { --pane-bg: color-mix(in oklab, var(--surface) 60%, var(--base)); }
|
||||
.window.focused {
|
||||
background:
|
||||
linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
|
||||
linear-gradient(135deg, var(--light), var(--light-dim)) border-box;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
/* Only one pane on screen: nothing to tell apart, so no active border. */
|
||||
.panes:not(.reading) .window.list,
|
||||
.panes.board .window {
|
||||
@@ -651,24 +583,18 @@
|
||||
.panes.full .window:not(.focused) { display: none; }
|
||||
.panes.full:not(.reading) .window.entry { display: none; }
|
||||
|
||||
/* hypr: workspaces, slidefadevert 18%, 550ms on the drift curve. */
|
||||
.switch { animation: slidefadevert 550ms var(--ease-drift) both; }
|
||||
@keyframes slidefadevert {
|
||||
from { opacity: 0; transform: translateY(calc(var(--dir, 1) * 18%)); }
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 8px 0;
|
||||
align-items: center;
|
||||
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 { display: flex; gap: 4px; align-items: center; }
|
||||
.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); }
|
||||
@@ -688,8 +614,8 @@
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.panes { grid-template-columns: 1fr; --gap: 4px; }
|
||||
@media (max-width: 767px) {
|
||||
.panes { grid-template-columns: 1fr; }
|
||||
.panes .entry { display: none; }
|
||||
.panes.reading .list { display: none; }
|
||||
.panes.reading .entry { display: block; }
|
||||
|
||||
+227
-2
@@ -133,8 +133,14 @@ input::placeholder { color: var(--muted); }
|
||||
|
||||
::selection { background: var(--light); color: var(--ink); }
|
||||
|
||||
/* thin, unobtrusive scrollbars everywhere */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--overlay) transparent; }
|
||||
/*
|
||||
* Thin, unobtrusive scrollbars everywhere. Chrome drops its ::-webkit-scrollbar
|
||||
* styling (and falls back to arrows) as soon as the standard properties are
|
||||
* set, so those are only for the browsers that have nothing else.
|
||||
*/
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--overlay) transparent; }
|
||||
}
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--overlay); border-radius: 4px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -186,9 +192,228 @@ input::placeholder { color: var(--muted); }
|
||||
color: var(--light);
|
||||
}
|
||||
|
||||
/*
|
||||
* A key, as a keycap: inside a `.label` everything is upper case, and `o` and
|
||||
* `O` are different keys, so a key is never set in a label's case.
|
||||
*/
|
||||
.kbd {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--rim);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--light);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The blinking block after "standing by". */
|
||||
.cursor { margin-left: 2px; animation: blink 1.1s steps(1) infinite; }
|
||||
|
||||
/* ── frame ───────────────────────────────────────────────────────────────── */
|
||||
/*
|
||||
* The shell every page sits in, the app and the shared view alike: a hull with
|
||||
* a bar on top, a band at the bottom, and a rounded porthole between them that
|
||||
* the windows are seen through.
|
||||
*/
|
||||
.shell {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
background: var(--hull);
|
||||
}
|
||||
|
||||
/* The rim is drawn on top by ::after, so the windows scroll underneath it the
|
||||
way they 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;
|
||||
}
|
||||
|
||||
/* Windows tile the porthole like a workspace: a gap round and between them. */
|
||||
.panes {
|
||||
--gap: 7px;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(32ch, 24vw) 1fr;
|
||||
gap: var(--gap);
|
||||
padding: var(--gap);
|
||||
}
|
||||
|
||||
/*
|
||||
* A window: opaque, a hairline border in overlay. The focused one gets the
|
||||
* desktop's active border — a light gradient at 135° — and its shadow.
|
||||
*/
|
||||
.window {
|
||||
--pane-bg: color-mix(in oklab, var(--surface) 40%, var(--base));
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--frame-radius) - var(--gap));
|
||||
background:
|
||||
linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
|
||||
linear-gradient(var(--overlay), var(--overlay)) border-box;
|
||||
transition: box-shadow var(--t-base) var(--ease-glide);
|
||||
}
|
||||
.window.focused {
|
||||
background:
|
||||
linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
|
||||
linear-gradient(135deg, var(--light), var(--light-dim)) border-box;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
/* hypr: workspaces, slidefadevert 18%, 550ms on the drift curve. */
|
||||
.switch { animation: slidefadevert 550ms var(--ease-drift) both; }
|
||||
@keyframes slidefadevert {
|
||||
from { opacity: 0; transform: translateY(calc(var(--dir, 1) * 18%)); }
|
||||
}
|
||||
|
||||
/* On a phone one window fills the porthole, so its rim is the window's edge. */
|
||||
@media (max-width: 767px) {
|
||||
.panes { --gap: 0px; }
|
||||
.window,
|
||||
.window.focused {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--pane-bg);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The bar along the top: mark, workspace pills, the clock. */
|
||||
.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. */
|
||||
.bar .mark {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: none;
|
||||
border: 1.5px solid var(--light);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bar .mark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--light);
|
||||
}
|
||||
|
||||
.bar .divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
flex: none;
|
||||
background: var(--overlay);
|
||||
}
|
||||
|
||||
/* Workspace pills: muted at rest, light on hover, inverted when current. The
|
||||
current one widens, as the desktop's does, over the slow duration. */
|
||||
.bar .pill {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 22px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
.bar .pill:hover { background: transparent; color: var(--light); }
|
||||
.bar .pill[aria-current='true'] { background: var(--light); color: var(--ink); padding: 0 12px; }
|
||||
.bar .pill:focus-visible { background: var(--light); color: var(--ink); }
|
||||
|
||||
.bar .spacer { flex: 1; }
|
||||
.bar .readout { display: flex; gap: 8px; font-variant-numeric: tabular-nums; }
|
||||
.bar .readout .key { color: var(--muted); }
|
||||
.bar .readout .value { color: var(--fg); text-transform: none; letter-spacing: 0.06em; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { padding-left: 12px; padding-right: 12px; gap: 10px; }
|
||||
}
|
||||
|
||||
/* The band along the bottom: the mode, the file, what just happened. */
|
||||
.status {
|
||||
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;
|
||||
}
|
||||
.status .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);
|
||||
}
|
||||
.status .readout { color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.status .unit { color: var(--muted); margin-left: 1px; }
|
||||
.status .hint { color: var(--muted); letter-spacing: 0.06em; }
|
||||
|
||||
/* ── motion ──────────────────────────────────────────────────────────────── */
|
||||
@keyframes resolve {
|
||||
from { opacity: 0; transform: translateY(6px) scale(0.985); }
|
||||
|
||||
+15
-93
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user