feat(ui,store): live today clock, rail as lane filter, b toggle, quick look on space

This commit is contained in:
hh committed 2026-08-23 16:44:57 +02:00
1 parent 83ca32d98e
commit 0a7742eb5e
19 files changed
+1236 -192

No files matched your search

+371
View File
@@ -0,0 +1,371 @@
<script lang="ts">
import { Portal } from "bits-ui";
import Archive from "@lucide/svelte/icons/archive";
import CalendarCheck from "@lucide/svelte/icons/calendar-check";
import CalendarClock from "@lucide/svelte/icons/calendar-clock";
import CalendarDays from "@lucide/svelte/icons/calendar-days";
import CalendarOff from "@lucide/svelte/icons/calendar-off";
import CalendarPlus from "@lucide/svelte/icons/calendar-plus";
import Hash from "@lucide/svelte/icons/hash";
import Play from "@lucide/svelte/icons/play";
import Repeat from "@lucide/svelte/icons/repeat";
import { cubicIn } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
import { dueLabel, dueTone, fromKey } from "../lib/due";
import { labelHue } from "../lib/hue";
import type { Action } from "../lib/keymap";
import { onScreen } from "../lib/place";
import type { Box } from "../lib/place";
import { cn } from "../lib/utils";
import { PRIORITY_LABEL, STATUS_META } from "../lib/vocab";
import { isOpen } from "../model/types";
import { handleKey, perform } from "./keyboard";
import { markdown } from "./markdown";
import PriorityBars from "./priority-bars.svelte";
import { useView } from "./context";
const view = useView();
const store = view.store;
const task = $derived(store.taskById(store.cursorTask));
const open = $derived(store.peeking && task !== null);
// The cursor walked off the last task, or the task was written away: a
// lens with nothing under it goes away too.
$effect(() => {
if (store.peeking && !task) store.peeking = false;
});
const meta = $derived(task ? STATUS_META[task.status] : null);
const long = new Intl.DateTimeFormat("en-US", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
});
const short = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short" });
/** The card's sub-lines, with the one tab of card indent taken off. */
const notes = $derived(
task ? task.body.map((line) => line.replace(/^\t/u, "")).join("\n").trim() : "",
);
/** Says the date twice, once as a day and once against today. */
function when(key: string): string {
const label = dueLabel(key, store.today);
const full = long.format(fromKey(key));
return /^[a-z]/u.test(label) ? `${full} · ${label}` : full;
}
interface Row {
action: Action;
keys: string[];
label: string;
}
const rows = $derived<Row[]>(
task
? [
{ action: "taskToggle", keys: ["x"], label: isOpen(task.status) ? "done" : "reopen" },
{ action: "taskEdit", keys: ["e"], label: "edit" },
{ action: "taskPriority", keys: ["p"], label: "priority" },
{ action: "taskBack", keys: ["⇧h"], label: "−day" },
{ action: "taskForward", keys: ["⇧l"], label: "+day" },
{ action: "taskToday", keys: ["⇧t"], label: "today" },
{ action: "taskOpen", keys: ["⏎"], label: "open note" },
]
: [],
);
// ── where it sits, and how it arrives ────────────────────────────────────
/**
* Centred on the view rather than the window: the view is one pane among
* several, and the lens belongs over the tasks it is reading. A touch above
* the middle, the way a window feels centred rather than measures it.
*/
function centre(node: HTMLElement): void {
const box = view.rootBox() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
const width = node.offsetWidth;
const height = node.offsetHeight;
const left = box.left + (box.width - width) / 2;
const top = box.top + (box.height - height) * 0.42;
node.style.left = `${Math.max(8, Math.min(left, window.innerWidth - width - 8))}px`;
node.style.top = `${Math.max(8, Math.min(top, window.innerHeight - height - 8))}px`;
}
/** The row or card under the cursor, wherever the current view draws it. */
function origin(): Box | null {
return onScreen(
[...document.querySelectorAll("[data-cursor='true']")].map((element) =>
element.getBoundingClientRect(),
),
);
}
/** A fast settle with the faintest overshoot, so it lands rather than stops. */
function land(t: number): number {
const s = 0.55;
const x = t - 1;
return x * x * ((s + 1) * x + s) + 1;
}
/**
* Grows out of the task under the cursor and shrinks back into it: the
* same gesture Quick Look makes from a Finder row, so the eye never has to
* find where the lens came from.
*/
function zoom(node: HTMLElement, { out = false } = {}): TransitionConfig {
centre(node);
const to = node.getBoundingClientRect();
const from = origin();
if (!from || to.width === 0 || to.height === 0) {
return {
duration: out ? 140 : 200,
easing: out ? cubicIn : land,
css: (t) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
};
}
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const sx = Math.max(0.04, from.width / to.width);
const sy = Math.max(0.04, from.height / to.height);
return {
duration: out ? 170 : 260,
easing: out ? cubicIn : land,
css: (t, u) =>
`transform: translate(${dx * u}px, ${dy * u}px) scale(${1 - (1 - sx) * u}, ${1 - (1 - sy) * u});` +
`opacity: ${Math.min(1, t * 2.2)};` +
(out ? "" : `filter: blur(${6 * u}px);`),
};
}
function mount(node: HTMLElement) {
centre(node);
node.focus({ preventScroll: true });
const watcher =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => centre(node));
watcher?.observe(node);
const reflow = (): void => centre(node);
window.addEventListener("resize", reflow);
// A click on another task moves the lens rather than closing it; a click
// anywhere else puts it away.
const outside = (event: PointerEvent): void => {
const target = event.target as HTMLElement | null;
if (!target || node.contains(target)) return;
if (target.closest("[data-cursor]")) return;
store.peeking = false;
};
document.addEventListener("pointerdown", outside, true);
return {
destroy() {
watcher?.disconnect();
window.removeEventListener("resize", reflow);
document.removeEventListener("pointerdown", outside, true);
// Only take the keyboard back if the lens had it; an editor that has
// just opened keeps its caret.
const active = document.activeElement;
if (!active || active === document.body || node.contains(active)) {
view.focusRoot();
}
},
};
}
/** Keys pressed with the lens focused go where they always go. */
function onkeydown(event: KeyboardEvent): void {
handleKey(view, event);
}
function act(action: Action): void {
void perform(view, action);
}
const CHIP =
"inline-flex h-[22px] max-w-40 shrink-0 items-center gap-1 truncate rounded-md border px-1.5 text-[11px]";
</script>
{#if open && task && meta}
<Portal to={view.portal()}>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
aria-label="Quick look"
class="fixed z-50 flex max-h-[min(72vh,40rem)] w-[36rem] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_24px_64px_-16px_rgb(0_0_0/55%),0_2px_8px_-2px_rgb(0_0_0/25%)] outline-none will-change-transform"
in:zoom
out:zoom={{ out: true }}
{onkeydown}
role="dialog"
tabindex="-1"
use:mount
>
<div class="flex items-start gap-2.5 px-4 pt-3.5 pb-2">
<button
aria-label={`Status: ${meta.label}`}
class="mt-px grid size-6 shrink-0 place-items-center rounded-md outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => act("taskToggle")}
title={meta.label}
type="button"
>
<meta.icon aria-hidden="true" class="size-[18px]" style="color: {meta.color}" />
</button>
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span class={cn(CHIP, "border-border bg-secondary/40 font-medium text-muted-foreground")}>
{task.sphere}
</span>
{#if task.inArchive}
<span class={cn(CHIP, "border-border border-dashed text-muted-foreground")}>
<Archive aria-hidden="true" class="size-3" />
{task.project}
</span>
{:else}
<span class={cn(CHIP, "hue-chip")} style="--hue: {labelHue(task.project)}">
{task.project}
</span>
{/if}
{#if task.priority !== 2}
<span
class={cn(CHIP, "border-transparent pl-0 text-muted-foreground")}
title={PRIORITY_LABEL[task.priority]}
>
<PriorityBars priority={task.priority} />
{PRIORITY_LABEL[task.priority]}
</span>
{/if}
</div>
<span
class="shrink-0 pt-1 text-[10px] text-muted-foreground/70 tabular"
title={task.path}
>
{task.path.slice(task.path.lastIndexOf("/") + 1)}:{task.line + 1}
</span>
</div>
<div class="min-h-0 overflow-y-auto px-4 pb-3">
<div
class={cn(
"bcal-prose font-medium text-[15px] leading-[1.5]",
!isOpen(task.status) && "text-muted-foreground line-through decoration-muted-foreground/50",
)}
onclick={(event) => {
const anchor = (event.target as HTMLElement).closest("a");
if (!anchor) return;
event.preventDefault();
const href = anchor.getAttribute("href") ?? "";
if (anchor.classList.contains("internal-link")) {
void view.app.workspace.openLinkText(href, task.path, event.metaKey || event.ctrlKey);
} else if (href) {
window.open(href, "_blank");
}
}}
onkeydown={() => {}}
role="presentation"
use:markdown={{
app: view.app,
component: view.component,
sourcePath: task.path,
text: task.text,
}}
></div>
{#if notes}
<div
class="bcal-prose mt-2.5 border-border border-l pl-3 text-[13px] text-foreground/85 leading-relaxed"
use:markdown={{
app: view.app,
component: view.component,
sourcePath: task.path,
text: notes,
}}
></div>
{/if}
</div>
<div
class="flex flex-wrap items-center gap-x-3 gap-y-1 border-border/70 border-t px-4 py-2 text-[11px] text-muted-foreground"
>
{#if task.due}
{@const tone = dueTone(task.due, store.today)}
<span
class={cn(
"inline-flex items-center gap-1",
tone === "overdue" && "text-destructive",
tone === "soon" && "text-status-snooze",
)}
>
<CalendarDays aria-hidden="true" class="size-3" />
<span>due {when(task.due)}</span>
</span>
{/if}
{#if task.scheduled}
<span class="inline-flex items-center gap-1">
<CalendarClock aria-hidden="true" class="size-3" />
<span>scheduled {when(task.scheduled)}</span>
</span>
{/if}
{#if task.start}
<span class="inline-flex items-center gap-1">
<Play aria-hidden="true" class="size-3" />
<span>from {short.format(fromKey(task.start))}</span>
</span>
{/if}
{#if task.recurrence}
<span class="inline-flex items-center gap-1">
<Repeat aria-hidden="true" class="size-3" />
<span>{task.recurrence}</span>
</span>
{/if}
{#if task.done}
<span class="inline-flex items-center gap-1 text-status-done">
<CalendarCheck aria-hidden="true" class="size-3" />
<span>done {short.format(fromKey(task.done))}</span>
</span>
{/if}
{#if task.created}
<span class="inline-flex items-center gap-1 text-muted-foreground/70">
<CalendarPlus aria-hidden="true" class="size-3" />
<span>added {short.format(fromKey(task.created))}</span>
</span>
{/if}
{#if !task.due && !task.scheduled && !task.start && !task.recurrence && !task.done}
<span class="inline-flex items-center gap-1">
<CalendarOff aria-hidden="true" class="size-3" />
<span>No date</span>
</span>
{/if}
{#if task.tags.length > 0}
<span class="inline-flex items-center gap-1">
<Hash aria-hidden="true" class="size-3" />
<span class="truncate">{task.tags.join(" ")}</span>
</span>
{/if}
</div>
<div
class="flex flex-wrap items-center gap-x-0.5 gap-y-0.5 border-border/70 border-t bg-secondary/25 px-2 py-1.5 text-[11px] text-muted-foreground"
>
{#each rows as row (row.action)}
<button
class="inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-md px-1.5 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => act(row.action)}
type="button"
>
{#each row.keys as key (key)}
<kbd class="kbd">{key}</kbd>
{/each}
{row.label}
</button>
{/each}
<span class="ml-auto whitespace-nowrap px-1.5">
<kbd class="kbd">␣</kbd> close
</span>
</div>
</div>
</Portal>
{/if}