diff --git a/preview/entry.ts b/preview/entry.ts index 1388536..bc08ecc 100644 --- a/preview/entry.ts +++ b/preview/entry.ts @@ -50,6 +50,46 @@ async function main(): Promise { component: new Component() as never, }, }); + + // Scenes for screenshots: open the page with a hash to land in a state. + const scene = location.hash.slice(1); + if (!scene) return; + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + await wait(50); + store.keyboard = true; + const first = store.spheres[0]; + if (scene.startsWith("rail")) { + store.toggleRail(); + store.railExpanded = new Set([first.path, store.spheres[1].path]); + if (scene === "rail-some") { + store.toggleSphere(store.spheres[2].path); + store.toggleLane(first.path, first.projects[0]); + store.cursorRail = `lane:${encodeURIComponent(first.path)} ${encodeURIComponent(first.projects[0])}`; + } + if (scene === "rail-none") store.filterNone(); + } + if (scene.startsWith("peek")) { + if (scene === "peek-cal") store.view = "calendar"; + if (scene === "peek-notes") store.showDone = true; + await wait(20); + const task = store.visible.find((item) => item.body.length > 0) ?? store.visible[0]; + if (scene === "peek-cal") { + const key = task.due ?? task.scheduled; + if (key) store.focusDay(key); + } + store.zone = "task"; + store.cursorTask = task.id; + await wait(50); + store.peek(); + } + if (scene === "date") { + store.write(null, "[data-composer='toolbar']"); + await wait(50); + for (let i = 0; i < 3; i += 1) { + document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + await wait(20); + } + } } void main(); diff --git a/src/lib/due.ts b/src/lib/due.ts index d65994a..b67fe67 100644 --- a/src/lib/due.ts +++ b/src/lib/due.ts @@ -9,6 +9,11 @@ export function localKey(date: Date): string { return `${date.getFullYear()}-${month}-${day}`; } +/** + * The wall clock, read now. Anything drawn on screen should take the key + * from the store's `today` instead, which is reactive and survives a view + * that stays open for weeks; this is for one-shot writes. + */ export function today(): string { return localKey(new Date()); } @@ -24,16 +29,19 @@ export function shiftDays(key: string, days: number): string { return localKey(date); } -export function daysFromToday(key: string): number { - const target = fromKey(key); - const now = new Date(); - now.setHours(0, 0, 0, 0); - return Math.round((target.getTime() - now.getTime()) / 86_400_000); +/** Milliseconds until the next local midnight, with a little slack past it. */ +export function untilMidnight(now = new Date()): number { + const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + return Math.max(1000, next.getTime() - now.getTime() + 500); } -export function dueLabel(key: string | null): string { +export function daysFromToday(key: string, from = today()): number { + return Math.round((fromKey(key).getTime() - fromKey(from).getTime()) / 86_400_000); +} + +export function dueLabel(key: string | null, from = today()): string { if (!key) return ""; - const diff = daysFromToday(key); + const diff = daysFromToday(key, from); if (diff === 0) return "today"; if (diff === 1) return "tomorrow"; if (diff === -1) return "yesterday"; @@ -42,16 +50,17 @@ export function dueLabel(key: string | null): string { export type DueTone = "overdue" | "soon" | "normal" | "none"; -export function dueTone(key: string | null): DueTone { +export function dueTone(key: string | null, from = today()): DueTone { if (!key) return "none"; - const diff = daysFromToday(key); + const diff = daysFromToday(key, from); if (diff < 0) return "overdue"; if (diff <= 1) return "soon"; return "normal"; } -export function nextFriday(): string { - const date = new Date(); +/** Strictly the next one, so on a Friday it means the week after. */ +export function nextFriday(from = today()): string { + const date = fromKey(from); date.setDate(date.getDate() + ((5 - date.getDay() + 7) % 7 || 7)); return localKey(date); } diff --git a/src/lib/keymap.ts b/src/lib/keymap.ts index 4df053f..43f67a4 100644 --- a/src/lib/keymap.ts +++ b/src/lib/keymap.ts @@ -19,6 +19,7 @@ export type Action = | "taskEdit" | "taskEditRaw" | "taskPriority" + | "taskPeek" | "taskBack" | "taskForward" | "taskWeekBack" @@ -32,6 +33,8 @@ export type Action = | "railFold" | "railUnfold" | "railChoose" + | "railToggleRow" + | "railLeave" | "trayLeft" | "trayRight" | "trayUp" @@ -190,7 +193,7 @@ export const SHORTCUTS: Shortcut[] = [ { combos: ["b"], keys: ["b"], - label: "To the spheres, and back", + label: "Spheres: open and in, or shut and out", action: "toggleRail", scopes: ["view"], group: "Moving", @@ -256,16 +259,24 @@ export const SHORTCUTS: Shortcut[] = [ { combos: ["enter"], keys: ["⏎"], - label: "Filter by the row, and step out", + label: "Only this row; again, everything", action: "railChoose", scopes: ["rail"], group: "Panels", }, + { + combos: ["space"], + keys: ["␣"], + label: "Flip the row, keep the rest", + action: "railToggleRow", + scopes: ["rail"], + group: "Panels", + }, { combos: ["escape"], keys: ["esc"], - label: "Leave the spheres", - action: "toggleRail", + label: "Back to the tasks", + action: "railLeave", scopes: ["rail"], group: "Panels", }, @@ -481,6 +492,14 @@ export const SHORTCUTS: Shortcut[] = [ scopes: ["task"], group: "The selected task", }, + { + combos: ["space"], + keys: ["␣"], + label: "Quick look", + action: "taskPeek", + scopes: ["task"], + group: "The selected task", + }, { combos: ["enter"], keys: ["⏎"], @@ -496,6 +515,7 @@ export const SHORTCUTS: Shortcut[] = [ * Cyrillic layout where `event.key` reports "н" instead of "n". */ const BY_CODE: Record = { + Space: "space", BracketLeft: "[", BracketRight: "]", Slash: "/", @@ -523,7 +543,7 @@ function withModifiers(event: KeyboardEvent, key: string, shift: boolean): strin export function combos(event: KeyboardEvent): string[] { const out: string[] = []; - const key = event.key.toLowerCase(); + const key = event.key === " " ? "space" : event.key.toLowerCase(); if (key !== "shift" && key !== "control" && key !== "meta" && key !== "alt") { // Punctuation already carries the shift in the character itself. out.push(withModifiers(event, key, event.shiftKey && /^[a-z]$/u.test(key))); diff --git a/src/tailwind.css b/src/tailwind.css index 5ec03f5..6c49ae2 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -243,6 +243,62 @@ vertical-align: text-bottom; } +/* The quick look reads the whole card, so here markdown keeps its blocks. */ +.bcal-root.bcal-root .bcal-prose > * + * { + margin-top: 0.45em; +} + +.bcal-root.bcal-root .bcal-prose :is(ul, ol) { + padding-left: 1.25em; + list-style: disc; +} + +.bcal-root.bcal-root .bcal-prose ol { + list-style: decimal; +} + +.bcal-root.bcal-root .bcal-prose li + li { + margin-top: 0.2em; +} + +.bcal-root.bcal-root .bcal-prose li.task-list-item { + list-style: none; + margin-left: -1.25em; +} + +.bcal-root.bcal-root .bcal-prose input.task-list-item-checkbox { + margin: 0 0.4em 0 0; + vertical-align: -0.15em; + pointer-events: none; +} + +.bcal-root.bcal-root .bcal-prose a { + color: var(--link-color, var(--color-accent)); + text-decoration: none; +} + +.bcal-root.bcal-root .bcal-prose a:hover { + text-decoration: underline; +} + +.bcal-root.bcal-root .bcal-prose code { + padding: 0 0.25em; + font-size: 0.9em; + background: var(--secondary); + border-radius: var(--radius-sm); +} + +.bcal-root.bcal-root .bcal-prose :is(img, .internal-embed) { + max-width: 100%; + border-radius: var(--radius-md); +} + +.bcal-root.bcal-root .bcal-prose blockquote { + padding-left: 0.75em; + border-left: 1px solid var(--border); + color: var(--muted-foreground); +} + .bcal-root.bcal-root .bcal-dragging { position: relative; z-index: 40; @@ -261,6 +317,18 @@ font-variant-numeric: tabular-nums; } +/* A lane's gate in the rail: the project's hue as a filled check. */ +@utility gate-hue { + color: white; + background: oklch(0.58 0.11 var(--hue)); + border-color: oklch(0.58 0.11 var(--hue)); + .theme-dark & { + background: oklch(0.66 0.11 var(--hue)); + border-color: oklch(0.66 0.11 var(--hue)); + color: oklch(0.2 0.02 var(--hue)); + } +} + @utility hue-chip { color: oklch(0.45 0.13 var(--hue)); background: oklch(0.6 0.09 var(--hue) / 0.12); diff --git a/src/ui/app.svelte b/src/ui/app.svelte index f6dd5e6..2ab97c5 100644 --- a/src/ui/app.svelte +++ b/src/ui/app.svelte @@ -10,6 +10,7 @@ import TaskCalendar from "./task-calendar.svelte"; import TaskEditor from "./task-editor.svelte"; import TaskList from "./task-list.svelte"; + import TaskPeek from "./task-peek.svelte"; import Toolbar from "./toolbar.svelte"; import { handleKey } from "./keyboard"; @@ -47,8 +48,12 @@ search = node; }, focusRoot: () => root?.focus({ preventScroll: true }), + rootBox: () => root?.getBoundingClientRect() ?? null, }); + // The view can stay open for weeks; the clock keeps "today" meaning today. + $effect(() => store.watchClock()); + // Bound by hand rather than with onkeydown so the container stays a plain // element: the view has just been opened, so it may as well answer keys. $effect(() => { @@ -92,6 +97,7 @@ + {#if store.helpOpen} diff --git a/src/ui/context.ts b/src/ui/context.ts index 336b69e..a38a3e1 100644 --- a/src/ui/context.ts +++ b/src/ui/context.ts @@ -14,6 +14,8 @@ export interface ViewContext { registerSearch: (node: HTMLInputElement | null) => void; /** Hands the keyboard back to the view after a layer above it closes. */ focusRoot: () => void; + /** Where the view sits on screen, for layers that centre on it. */ + rootBox: () => DOMRect | null; } const KEY = Symbol("beaver-calendar"); diff --git a/src/ui/day-grid.svelte b/src/ui/day-grid.svelte index ab730e2..1a6d016 100644 --- a/src/ui/day-grid.svelte +++ b/src/ui/day-grid.svelte @@ -13,14 +13,16 @@ anchor: string | null; onhover?: (key: string) => void; onpick: (key: string) => void; + /** The store's key for today, so the mark moves with the clock. */ + today?: string; tone: (key: string) => DayTone; } - let { anchor, tone, onpick, onhover }: Props = $props(); + let { anchor, tone, onpick, onhover, today }: Props = $props(); const DAY = 86_400_000; const monthName = new Intl.DateTimeFormat("en-US", { month: "long" }); - const todayKey = localKey(new Date()); + const todayKey = $derived(today ?? localKey(new Date())); let picked = $state(null); diff --git a/src/ui/keyboard.ts b/src/ui/keyboard.ts index 05a2bfd..90291c4 100644 --- a/src/ui/keyboard.ts +++ b/src/ui/keyboard.ts @@ -67,6 +67,14 @@ function stepTray(view: ViewContext, direction: Direction): void { if (direction === "up") store.toggleUndated(); } +/** + * Runs one action as if its key had been pressed, so a button drawn anywhere + * does exactly what the sheet says the key does. + */ +export function perform(view: ViewContext, action: Action): Promise { + return run(view, action); +} + async function run(view: ViewContext, action: Action): Promise { const { app, store } = view; const task = store.taskById(store.cursorTask); @@ -100,7 +108,10 @@ async function run(view: ViewContext, action: Action): Promise { else store.cursorTask = null; return; case "taskOpen": - if (task) await revealInFile(view, task); + if (task) { + store.peeking = false; + await revealInFile(view, task); + } return; case "taskToggle": if (task) await setStatus(app, task, isOpen(task.status) ? "x" : " "); @@ -109,11 +120,17 @@ async function run(view: ViewContext, action: Action): Promise { if (task) store.edit(task.id); return; case "taskEditRaw": - if (task) await editInTasks(view, task); + if (task) { + store.peeking = false; + await editInTasks(view, task); + } return; case "taskPriority": if (task) store.request = "priority"; return; + case "taskPeek": + store.peek(); + return; case "taskBack": if (task) await shift(view, task, -1); return; @@ -161,6 +178,12 @@ async function run(view: ViewContext, action: Action): Promise { case "railChoose": store.chooseRail(); return; + case "railToggleRow": + store.toggleRailRow(); + return; + case "railLeave": + store.leaveRail(); + return; case "create": openComposer(view); @@ -258,6 +281,15 @@ export function handleKey(view: ViewContext, event: KeyboardEvent): void { return; } + // The quick look is a lens, not a layer: every key still reaches the task + // underneath, and only Escape is taken to put the lens away. + if (store.peeking && event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + store.peeking = false; + return; + } + const action = resolve(event, scopesFor(view)); if (!action) return; event.preventDefault(); diff --git a/src/ui/sphere-rail.svelte b/src/ui/sphere-rail.svelte index 8a946e7..f948e11 100644 --- a/src/ui/sphere-rail.svelte +++ b/src/ui/sphere-rail.svelte @@ -1,5 +1,7 @@ +{#snippet gate(state: "all" | "some" | "none", hue: number | null, label: string, flip: () => void)} + +{/snippet} + diff --git a/src/ui/task-editor.svelte b/src/ui/task-editor.svelte index 95f9a10..c3801d0 100644 --- a/src/ui/task-editor.svelte +++ b/src/ui/task-editor.svelte @@ -5,7 +5,7 @@ import Repeat from "@lucide/svelte/icons/repeat"; import { slide } from "svelte/transition"; import { cubicOut } from "svelte/easing"; - import { dueLabel, dueTone, fromKey, nextFriday, shiftDays, today } from "../lib/due"; + import { dueLabel, dueTone, fromKey, nextFriday, shiftDays } from "../lib/due"; import { labelHue } from "../lib/hue"; import { listStep, MOD } from "../lib/keymap"; import { cn } from "../lib/utils"; @@ -51,6 +51,7 @@ day: "numeric", month: "long", }); + const monthDay = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short" }); // ── what is on offer in each field ─────────────────────────────────────── @@ -72,17 +73,23 @@ ), ); - const dateOptions = $derived([ - { value: "", label: "No date" }, - { value: today(), label: "Today", hint: dueLabel(today()) }, - { value: shiftDays(today(), 1), label: "Tomorrow" }, - { value: nextFriday(), label: "Friday", hint: dueLabel(nextFriday()) }, - { - value: shiftDays(today(), 7), - label: "In a week", - hint: dueLabel(shiftDays(today(), 7)), - }, - ]); + // Read off the store's clock, not the wall: a view that stays open for + // weeks would otherwise keep offering the day it was opened on. + const dateOptions = $derived.by(() => { + const now = store.today; + const on = (key: string, label: string): Option => ({ + value: key, + label, + hint: monthDay.format(fromKey(key)), + }); + return [ + { value: "", label: "No date" }, + on(now, "Today"), + on(shiftDays(now, 1), "Tomorrow"), + on(nextFriday(now), "Friday"), + on(shiftDays(now, 7), "In a week"), + ]; + }); const repeatOptions = $derived([ { value: "", label: "No repeat" }, @@ -95,7 +102,7 @@ /** What the words in the field add up to, shown before they are taken. */ const read = $derived.by(() => { if (field === "date") { - const key = parseWhen(query); + const key = parseWhen(query, fromKey(store.today)); return key ? { value: key, label: long.format(fromKey(key)) } : null; } if (field === "repeat") { @@ -409,7 +416,7 @@ @@ -419,8 +426,8 @@ CHIP, IDLE, "w-auto shrink-0", - due && dueTone(due) === "overdue" && "text-destructive", - due && dueTone(due) === "soon" && "text-status-snooze", + due && dueTone(due, store.today) === "overdue" && "text-destructive", + due && dueTone(due, store.today) === "soon" && "text-status-snooze", )} onclick={() => { field = "date"; @@ -432,7 +439,7 @@ {:else}