mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +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.
|
// What the empty pane offers, in the key/value shape of a status readout.
|
||||||
const offers: [string, string][] = [
|
// Keys are keycaps, so `o` and `O` cannot be read as the same key.
|
||||||
['o', 'write a new document'],
|
const offers: [string[], string][] = [
|
||||||
['O', 'make a folder'],
|
[['o'], 'write a new document'],
|
||||||
['j / k', 'browse, l opens'],
|
[['⇧ O'], 'make a folder'],
|
||||||
['1 … 9', 'switch space, Tab cycles'],
|
[['j', 'k'], 'browse, l opens'],
|
||||||
['w', 'overview of every space'],
|
[['1…9'], 'switch space, Tab cycles'],
|
||||||
['b', "this space's board"],
|
[['w'], 'overview of every space'],
|
||||||
['/', 'search this space'],
|
[['b'], "this space's board"],
|
||||||
['?', 'help'],
|
[['/'], 'search this space'],
|
||||||
|
[['?'], 'help'],
|
||||||
]
|
]
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -520,8 +521,8 @@
|
|||||||
<div class="standby resolve">
|
<div class="standby resolve">
|
||||||
<h2 class="label rule">nothing open</h2>
|
<h2 class="label rule">nothing open</h2>
|
||||||
<dl>
|
<dl>
|
||||||
{#each offers as [key, what] (key)}
|
{#each offers as [keys, what] (what)}
|
||||||
<dt>{key}</dt>
|
<dt>{#each keys as key (key)}<span class="kbd">{key}</span>{/each}</dt>
|
||||||
<dd>{what}</dd>
|
<dd>{what}</dd>
|
||||||
{/each}
|
{/each}
|
||||||
<dt>status</dt>
|
<dt>status</dt>
|
||||||
@@ -564,78 +565,9 @@
|
|||||||
<style>
|
<style>
|
||||||
.boot { display: grid; place-items: center; height: 100%; }
|
.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; }
|
.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.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. */
|
/* Only one pane on screen: nothing to tell apart, so no active border. */
|
||||||
.panes:not(.reading) .window.list,
|
.panes:not(.reading) .window.list,
|
||||||
.panes.board .window {
|
.panes.board .window {
|
||||||
@@ -651,24 +583,18 @@
|
|||||||
.panes.full .window:not(.focused) { display: none; }
|
.panes.full .window:not(.focused) { display: none; }
|
||||||
.panes.full:not(.reading) .window.entry { 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; }
|
.placeholder { display: grid; place-items: center; height: 100%; padding: 24px; }
|
||||||
.standby { width: min(100%, 44ch); }
|
.standby { width: min(100%, 44ch); }
|
||||||
.standby h2 { margin: 0 0 18px; font-weight: 400; }
|
.standby h2 { margin: 0 0 18px; font-weight: 400; }
|
||||||
.standby dl {
|
.standby dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 7rem 1fr;
|
grid-template-columns: 7rem 1fr;
|
||||||
gap: 6px 0;
|
gap: 8px 0;
|
||||||
|
align-items: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
/* Keys keep their case: `o` and `O` are different keys. */
|
.standby dt { display: flex; gap: 4px; align-items: center; }
|
||||||
.standby dt { color: var(--light); letter-spacing: 0.08em; }
|
|
||||||
.standby dt:last-of-type { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
.standby dt:last-of-type { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||||
.standby dd { margin: 0; color: var(--fg); }
|
.standby dd { margin: 0; color: var(--fg); }
|
||||||
.standby dd.light { color: var(--light); }
|
.standby dd.light { color: var(--light); }
|
||||||
@@ -688,8 +614,8 @@
|
|||||||
|
|
||||||
.hidden { display: none; }
|
.hidden { display: none; }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 767px) {
|
||||||
.panes { grid-template-columns: 1fr; --gap: 4px; }
|
.panes { grid-template-columns: 1fr; }
|
||||||
.panes .entry { display: none; }
|
.panes .entry { display: none; }
|
||||||
.panes.reading .list { display: none; }
|
.panes.reading .list { display: none; }
|
||||||
.panes.reading .entry { display: block; }
|
.panes.reading .entry { display: block; }
|
||||||
|
|||||||
+227
-2
@@ -133,8 +133,14 @@ input::placeholder { color: var(--muted); }
|
|||||||
|
|
||||||
::selection { background: var(--light); color: var(--ink); }
|
::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 { width: 8px; height: 8px; }
|
||||||
*::-webkit-scrollbar-thumb { background: var(--overlay); border-radius: 4px; }
|
*::-webkit-scrollbar-thumb { background: var(--overlay); border-radius: 4px; }
|
||||||
*::-webkit-scrollbar-track { background: transparent; }
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
@@ -186,9 +192,228 @@ input::placeholder { color: var(--muted); }
|
|||||||
color: var(--light);
|
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". */
|
/* The blinking block after "standing by". */
|
||||||
.cursor { margin-left: 2px; animation: blink 1.1s steps(1) infinite; }
|
.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 ──────────────────────────────────────────────────────────────── */
|
/* ── motion ──────────────────────────────────────────────────────────────── */
|
||||||
@keyframes resolve {
|
@keyframes resolve {
|
||||||
from { opacity: 0; transform: translateY(6px) scale(0.985); }
|
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
|
* 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`.
|
* that is genuinely faster to walk with a pointer than with `:cd`.
|
||||||
*/
|
*/
|
||||||
|
import Clock from './Clock.svelte'
|
||||||
import { hooks } from './commands'
|
import { hooks } from './commands'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
|
|
||||||
// The space is the first crumb, and it is already named by its pill.
|
// The space is the first crumb, and it is already named by its pill.
|
||||||
const crumbs = $derived(workspace.path.slice(1))
|
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>
|
</script>
|
||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
@@ -33,7 +26,7 @@
|
|||||||
title="{space.name} ({index < 10 ? (index + 1) % 10 : ''})"
|
title="{space.name} ({index < 10 ? (index + 1) % 10 : ''})"
|
||||||
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
|
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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
|
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
|
||||||
@@ -74,50 +67,10 @@
|
|||||||
<span class="value">{workspace.user}</span>
|
<span class="value">{workspace.user}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="divider wide"></span>
|
<span class="divider wide"></span>
|
||||||
<time class="clock">{clock}</time>
|
<Clock />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.spaces, .crumbs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -129,35 +82,10 @@
|
|||||||
.spaces { flex: none; max-width: 50%; }
|
.spaces { flex: none; max-width: 50%; }
|
||||||
.crumbs { flex: 0 1 auto; }
|
.crumbs { flex: 0 1 auto; }
|
||||||
|
|
||||||
/* Workspace pills: muted at rest, light on hover, inverted when current. */
|
.n { color: var(--muted); font-size: 10px; }
|
||||||
/* The focused pill widens, as the desktop's does, over the slow duration. */
|
.pill:hover .n { color: var(--light-dim); }
|
||||||
.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); }
|
|
||||||
.pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; }
|
.pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; }
|
||||||
.pill:focus-visible { background: var(--light); color: var(--ink); }
|
.pill.add { color: var(--muted); }
|
||||||
.pill.add { color: var(--overlay); }
|
|
||||||
.pill.add:hover { color: var(--light); }
|
.pill.add:hover { color: var(--light); }
|
||||||
|
|
||||||
/* A crumb is a link along the path; a hairline grows under it on hover. */
|
/* A crumb is a link along the path; a hairline grows under it on hover. */
|
||||||
@@ -190,21 +118,15 @@
|
|||||||
.sep { color: var(--overlay); }
|
.sep { color: var(--overlay); }
|
||||||
span.here { letter-spacing: 0.06em; text-transform: none; }
|
span.here { letter-spacing: 0.06em; text-transform: none; }
|
||||||
|
|
||||||
.spacer { flex: 1; }
|
/*
|
||||||
|
* On a phone the other spaces are only their number, like the desktop's
|
||||||
.readout { display: flex; gap: 8px; font-variant-numeric: tabular-nums; }
|
* workspace dots, so all of them fit; the current one keeps its name.
|
||||||
.key { color: var(--muted); }
|
*/
|
||||||
.value { color: var(--fg); text-transform: none; letter-spacing: 0.06em; }
|
|
||||||
|
|
||||||
.clock {
|
|
||||||
color: var(--light);
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.bar { padding-left: 12px; padding-right: 12px; gap: 10px; }
|
.crumbs, .divider.path, .bar .user, .divider.wide { display: none; }
|
||||||
.crumbs, .divider.path, .user, .divider.wide { display: none; }
|
.spaces { max-width: none; flex: 0 1 auto; gap: 2px; }
|
||||||
.spaces { max-width: none; flex: 1; }
|
.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>
|
</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 { renderMarkdown } from './markdown'
|
||||||
import Outline from './Outline.svelte'
|
import Outline from './Outline.svelte'
|
||||||
import { workspace } from './store.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 doc = $derived(workspace.open!)
|
||||||
const html = $derived(renderMarkdown(workspace.editing ? workspace.draft.body : doc.body))
|
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.
|
// after the new one has set the hook — so it only clears its own.
|
||||||
const scroll = (amount: ScrollAmount) => {
|
const scroll = (amount: ScrollAmount) => {
|
||||||
if (!reader) return false
|
if (!reader) return false
|
||||||
const line = parseFloat(getComputedStyle(reader).lineHeight) || 24
|
scrollPage(reader, amount)
|
||||||
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 })
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
hooks.scrollReader = scroll
|
hooks.scrollReader = scroll
|
||||||
@@ -74,6 +58,10 @@
|
|||||||
<section class="pane">
|
<section class="pane">
|
||||||
<header>
|
<header>
|
||||||
<div class="crumb label rule" title="{workspace.here}/{doc.slug}">
|
<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="marker" class:live={workspace.editing}></span>
|
||||||
<span class="path">{workspace.here}/{doc.slug}</span>
|
<span class="path">{workspace.here}/{doc.slug}</span>
|
||||||
{#if workspace.dirty}<span class="dirty">modified</span>{/if}
|
{#if workspace.dirty}<span class="dirty">modified</span>{/if}
|
||||||
@@ -86,13 +74,10 @@
|
|||||||
<dt>edited</dt>
|
<dt>edited</dt>
|
||||||
<dd>{relative(doc.updated_at)}</dd>
|
<dd>{relative(doc.updated_at)}</dd>
|
||||||
<dt>words</dt>
|
<dt>words</dt>
|
||||||
<dd>{words}</dd>
|
<dd>{words}<span class="unit">words</span></dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if isTouchDevice}
|
|
||||||
<button onclick={() => runCommand('q')}>← list</button>
|
|
||||||
{/if}
|
|
||||||
{#if workspace.editing}
|
{#if workspace.editing}
|
||||||
<button onclick={() => runCommand('w')}>:w</button>
|
<button onclick={() => runCommand('w')}>:w</button>
|
||||||
<button onclick={() => runCommand('upload')}>attach</button>
|
<button onclick={() => runCommand('upload')}>attach</button>
|
||||||
@@ -104,7 +89,7 @@
|
|||||||
{doc.shared ? 'unshare' : 'share'}
|
{doc.shared ? 'unshare' : 'share'}
|
||||||
</button>
|
</button>
|
||||||
<button class="danger" onclick={() => runCommand('d')}>delete</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -277,7 +262,7 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
max-width: 30ch;
|
max-width: 30ch;
|
||||||
padding: 6px 0 0 44px;
|
padding: 6px 0 0 32px;
|
||||||
}
|
}
|
||||||
@container (max-width: 1000px) {
|
@container (max-width: 1000px) {
|
||||||
.side { display: none; }
|
.side { display: none; }
|
||||||
@@ -302,9 +287,36 @@
|
|||||||
}
|
}
|
||||||
.mobilebar button { flex: 1; padding: 8px; }
|
.mobilebar button { flex: 1; padding: 8px; }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
.back { display: none; }
|
||||||
header { padding: 14px 16px 10px; }
|
.unit { display: none; }
|
||||||
.row { flex-direction: column; align-items: flex-start; }
|
|
||||||
|
/*
|
||||||
|
* 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; }
|
.sharebar { margin: 0 16px 8px; }
|
||||||
.title { margin: 8px 16px 4px; }
|
.title { margin: 8px 16px 4px; }
|
||||||
.reading { padding: 14px 16px 30vh; grid-template-columns: minmax(0, 1fr); }
|
.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
|
headings = found
|
||||||
current = found[0]?.id ?? ''
|
current = found[0]?.id ?? ''
|
||||||
|
|
||||||
// The section whose heading last crossed the upper third is the one being
|
// The section being read is the last one whose heading has passed the
|
||||||
// read; a band rather than a line, so a short section still registers.
|
// upper third of the view — worked out from positions on every scroll,
|
||||||
const observer = new IntersectionObserver(
|
// so a jump with G or Ctrl-d lands on the right one too. Scrolled to the
|
||||||
(entries) => {
|
// very end, the last heading wins even if it never got that high.
|
||||||
for (const entry of entries) {
|
let frame = 0
|
||||||
if (entry.isIntersecting) current = entry.target.id
|
const spy = () => {
|
||||||
}
|
frame = 0
|
||||||
},
|
const top = scroller.getBoundingClientRect().top
|
||||||
{ root: scroller, rootMargin: '-10% 0px -70% 0px' },
|
const line = top + scroller.clientHeight / 3
|
||||||
)
|
const room = scroller.scrollHeight - scroller.clientHeight
|
||||||
for (const element of elements) observer.observe(element)
|
const end = room > 2 && scroller.scrollTop >= room - 2
|
||||||
return () => observer.disconnect()
|
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) {
|
function jump(id: string) {
|
||||||
@@ -105,5 +118,6 @@
|
|||||||
button:focus-visible { background: var(--light); color: var(--ink); }
|
button:focus-visible { background: var(--light); color: var(--ink); }
|
||||||
|
|
||||||
.marker { width: 5px; height: 5px; transform: translateY(-2px); }
|
.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>
|
</style>
|
||||||
@@ -86,7 +86,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -200,5 +206,15 @@
|
|||||||
.when { flex: none; font-size: 10px; color: var(--muted); }
|
.when { flex: none; font-size: 10px; color: var(--muted); }
|
||||||
.empty { padding: 4px 8px; font-size: 12px; 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>
|
</style>
|
||||||
+159
-63
@@ -1,49 +1,92 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/*
|
/*
|
||||||
* The right edge of the frame: the things a pointer needs a door for. Each
|
* The right edge of the frame, built like the desktop's rail: a glyph per
|
||||||
* door is labelled with the key that does the same thing, so the rail
|
* door, groups split by a hairline, the always-there things pinned to the
|
||||||
* teaches the keymap instead of replacing it. `compact` lays the same doors
|
* bottom. The name and the key that does the same thing live in a tooltip,
|
||||||
* out in a row, for the bottom band on a phone.
|
* 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 { hooks, runCommand } from './commands'
|
||||||
|
import Icon, { type IconName } from './Icon.svelte'
|
||||||
import { workspace } from './store.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') {
|
if (workspace.view === 'board') {
|
||||||
return [
|
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 ') },
|
{ icon: 'doc-plus', label: 'New card on this list', keys: ['o'], run: () => hooks.openCommandLine(':card ') },
|
||||||
{ key: 'b', caption: 'tree', title: 'back to the tree (b, q)', run: () => runCommand('board'), on: true },
|
{ icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') },
|
||||||
{ key: ':', caption: 'cmd', title: 'command line (:)', run: () => hooks.openCommandLine(':') },
|
{ icon: 'list', label: 'Back to the tree', keys: ['b', 'q'], run: () => runCommand('board'), on: true },
|
||||||
{ key: '?', caption: 'help', title: 'help (?)', run: () => runCommand('help') },
|
],
|
||||||
|
[cmd, help],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const board: Door = workspace.space?.has_board
|
const board: Door = workspace.space?.has_board
|
||||||
? { key: 'b', caption: 'board', title: 'board (b)', run: () => runCommand('board') }
|
? { icon: 'board', label: 'Board', keys: ['b'], run: () => runCommand('board') }
|
||||||
: { key: '+b', caption: 'board', title: 'give this space a board (:set board)', run: () => runCommand('set board') }
|
: { icon: 'board', label: 'Give this space a board', keys: [':set board'], run: () => runCommand('set board'), dim: true }
|
||||||
return [
|
return [
|
||||||
{ key: 'o', caption: 'new', title: 'new document (o)', run: () => runCommand('new') },
|
[
|
||||||
{ key: 'O', caption: 'dir', title: 'new folder (O)', run: () => hooks.openCommandLine(':mkdir ') },
|
{ icon: 'doc-plus', label: 'New document', keys: ['o'], run: () => runCommand('new') },
|
||||||
...(workspace.space ? [board] : []),
|
{ icon: 'folder-plus', label: 'New folder', keys: ['⇧ O'], run: () => hooks.openCommandLine(':mkdir ') },
|
||||||
{ key: 'w', caption: 'spaces', title: 'overview of every space (w)', run: () => hooks.openOverview() },
|
...(workspace.space ? [board] : []),
|
||||||
{ 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(':') },
|
{ icon: 'grid', label: 'Every space', keys: ['w'], run: () => hooks.openOverview() },
|
||||||
{ key: '?', caption: 'help', title: 'help (?)', run: () => runCommand('help') },
|
{
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<nav class="rail" class:compact aria-label="tools">
|
<nav class="rail" class:dock aria-label="tools">
|
||||||
{#each doors as door (door.caption)}
|
{#each shown as group, index (index)}
|
||||||
<button class="door" class:on={door.on} title={door.title} onclick={door.run}>
|
<div class="group" class:pinned={index === shown.length - 1 && !dock}>
|
||||||
<span class="key">{door.key}</span>
|
{#each group as door (door.label)}
|
||||||
<span class="caption">{door.caption}</span>
|
<button
|
||||||
</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}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -54,73 +97,126 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: var(--status-h);
|
bottom: var(--status-h);
|
||||||
width: var(--rail-w);
|
width: var(--rail-w);
|
||||||
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.door {
|
.group {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
}
|
||||||
width: 40px;
|
/* A 16px hairline between groups, as on the desktop rail. */
|
||||||
padding: 7px 0 6px;
|
.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: 0;
|
||||||
border-radius: var(--radius-s);
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--light-soft);
|
||||||
letter-spacing: 0;
|
|
||||||
text-transform: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 {
|
.door::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 5px;
|
||||||
top: 6px;
|
top: 50%;
|
||||||
bottom: 6px;
|
|
||||||
width: 2px;
|
width: 2px;
|
||||||
border-radius: 2px;
|
height: 16px;
|
||||||
|
margin-top: -8px;
|
||||||
|
border-radius: 1px;
|
||||||
background: var(--light);
|
background: var(--light);
|
||||||
transform: scaleY(0);
|
transform: scaleY(0);
|
||||||
transform-origin: top;
|
|
||||||
transition: transform var(--t-base) var(--ease-snap);
|
transition: transform var(--t-base) var(--ease-snap);
|
||||||
}
|
}
|
||||||
.door:hover { background: var(--surface-alt); color: var(--light); }
|
.door:hover { background: transparent; color: var(--light); }
|
||||||
.door:hover::before { transform: scaleY(1); }
|
.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.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;
|
* The tooltip opens into the porthole, to the left of the door: a small
|
||||||
line-height: 1;
|
* card with the name in the reading face and the key as a keycap. A short
|
||||||
transition: transform var(--t-base) var(--ease-snap);
|
* 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); }
|
.door:hover .tip, .door:focus-visible .tip {
|
||||||
.caption {
|
opacity: 1;
|
||||||
font-size: 9px;
|
transform: translate(0, -50%);
|
||||||
font-weight: 600;
|
transition-delay: 250ms;
|
||||||
letter-spacing: 0.14em;
|
transition-duration: var(--t-base);
|
||||||
text-transform: uppercase;
|
}
|
||||||
|
.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) {
|
@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;
|
position: static;
|
||||||
|
flex: 1;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 2px;
|
|
||||||
}
|
}
|
||||||
.compact .door { width: 38px; padding: 4px 0; }
|
.dock .group {
|
||||||
.compact .door::before { display: none; }
|
flex: 1;
|
||||||
.compact .caption { display: none; }
|
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>
|
</style>
|
||||||
+158
-47
@@ -1,16 +1,28 @@
|
|||||||
<script lang="ts">
|
<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 { api, type SharedDocument } from './api'
|
||||||
|
import Clock from './Clock.svelte'
|
||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
import Outline from './Outline.svelte'
|
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()
|
const { token, shareKey }: { token: string; shareKey: string } = $props()
|
||||||
|
|
||||||
let doc = $state<SharedDocument | null>(null)
|
let doc = $state<SharedDocument | null>(null)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let page = $state<HTMLElement>()
|
let scroller = $state<HTMLElement>()
|
||||||
let article = $state<HTMLElement>()
|
let article = $state<HTMLElement>()
|
||||||
|
let position = $state('All')
|
||||||
const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '')
|
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(() => {
|
$effect(() => {
|
||||||
if (!shareKey) {
|
if (!shareKey) {
|
||||||
@@ -25,67 +37,166 @@
|
|||||||
})
|
})
|
||||||
.catch(() => (error = 'this link is not valid, or the document is no longer shared.'))
|
.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>
|
</script>
|
||||||
|
|
||||||
<main bind:this={page}>
|
<svelte:window {onkeydown} />
|
||||||
{#if error}
|
|
||||||
<p class="alarm">{error}</p>
|
<div class="shell shared">
|
||||||
{:else if doc}
|
<header class="bar">
|
||||||
<article class="md resolve" bind:this={article}>
|
<span class="mark" aria-hidden="true"></span>
|
||||||
<div class="meta label rule">
|
<span class="divider"></span>
|
||||||
<span class="marker"></span>
|
<span class="pill" aria-current="true">shared</span>
|
||||||
<span>{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)}</span>
|
<span class="divider"></span>
|
||||||
<span class="tag">shared document</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>
|
</div>
|
||||||
{#if doc.name.trim()}<h1>{doc.name}</h1>{/if}
|
</div>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
</main>
|
||||||
{@html html}
|
|
||||||
</article>
|
<footer class="status">
|
||||||
<aside class="side">
|
<span class="mode">read</span>
|
||||||
<Outline root={article} scroller={page} source={html} />
|
<span class="file">shared/{slugify(title) || token.slice(0, 8)}</span>
|
||||||
</aside>
|
<span class="msg"></span>
|
||||||
<footer class="label">— written in ~/workspace</footer>
|
{#if doc}
|
||||||
{:else}
|
<span class="readout">{words}<span class="unit">W</span></span>
|
||||||
<p class="label">loading<span class="cursor">_</span></p>
|
<span class="readout">{position}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
<span class="hint">j k scroll · gg G ends · ^d ^u half</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* The same page as the reading pane: a centred column, the outline in the margin. */
|
/* No rail: the porthole and the bands run to the frame on the right too. */
|
||||||
main {
|
.shared { --rail-w: var(--frame); }
|
||||||
height: 100%;
|
|
||||||
|
.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;
|
overflow-y: auto;
|
||||||
padding: 56px 20px 20vh;
|
padding: 40px 28px 30vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 46rem) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 46rem) minmax(0, 1fr);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
align-items: start;
|
|
||||||
container-type: inline-size;
|
|
||||||
background:
|
|
||||||
radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%),
|
|
||||||
var(--base);
|
|
||||||
}
|
}
|
||||||
|
.body > * { grid-column: 2; }
|
||||||
article, footer, p { grid-column: 2; }
|
|
||||||
article { max-width: none; }
|
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 { margin-bottom: 28px; font-size: 10px; }
|
||||||
.meta .marker { background: var(--light); }
|
.meta .marker { background: var(--light); }
|
||||||
|
|
||||||
.tag { color: var(--light); }
|
.tag { color: var(--light); }
|
||||||
|
|
||||||
h1 { margin-top: 0 !important; font-size: 2.1em !important; }
|
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>
|
</style>
|
||||||
@@ -67,7 +67,11 @@
|
|||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li class="empty muted">
|
<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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null)
|
const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer>
|
<footer class="status">
|
||||||
<span
|
<span
|
||||||
class="mode"
|
class="mode"
|
||||||
|
class:editing={workspace.editing}
|
||||||
class:insert={mode === 'INSERT' || mode === 'EDIT'}
|
class:insert={mode === 'INSERT' || mode === 'EDIT'}
|
||||||
class:visual={mode.startsWith('VISUAL')}
|
class:visual={mode.startsWith('VISUAL')}
|
||||||
>
|
>
|
||||||
@@ -25,9 +26,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="file">{file}{#if workspace.dirty}<span class="dirty"> [+]</span>{/if}</span>
|
<span class="file">{file}{#if workspace.dirty}<span class="dirty"> [+]</span>{/if}</span>
|
||||||
|
|
||||||
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>
|
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>{workspace.flash?.text ?? ''}</span>
|
||||||
{workspace.flash?.text ?? ''}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
|
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
|
||||||
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></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'}
|
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="tools"><Rail compact /></div>
|
<div class="tools"><Rail dock /></div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<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.insert { background: var(--light); color: var(--ink); }
|
||||||
.mode.visual { background: var(--light-dim); color: var(--ink); }
|
.mode.visual { background: var(--light-dim); color: var(--ink); }
|
||||||
|
|
||||||
@@ -73,16 +45,34 @@
|
|||||||
.dirty { color: var(--light); }
|
.dirty { color: var(--light); }
|
||||||
.msg { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--fg); }
|
.msg { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--fg); }
|
||||||
.msg.alarm { color: var(--alarm); }
|
.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; }
|
.flag { color: var(--light); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||||
|
|
||||||
.tools { display: none; }
|
.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) {
|
@media (max-width: 767px) {
|
||||||
footer { padding-right: 6px; gap: 10px; }
|
.status { padding: 0 6px; gap: 6px; overflow: visible; }
|
||||||
.hint, .file, .readout { display: none; }
|
.hint, .file, .readout, .flag { display: none; }
|
||||||
.tools { display: flex; flex: 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>
|
</style>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { api, type BackupStatus } from './api'
|
import { api, type BackupStatus } from './api'
|
||||||
import { workspace } from './store.svelte'
|
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 }
|
||||||
export type ScrollAmount = number | 'top' | 'bottom' | 'halfdown' | 'halfup'
|
|
||||||
|
|
||||||
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
|
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
|
||||||
export const hooks = {
|
export const hooks = {
|
||||||
|
|||||||
@@ -74,3 +74,28 @@ export function slugify(text: string): string {
|
|||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
.slice(0, 80)
|
.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