feat(session): Obsidian-style live preview notes (#556)

* feat(session): add live-preview notes scratchpad

Add a Notes panel between chat and right nav with a CodeMirror-based markdown editor that hides heading and emphasis markers unless the cursor is on that line/segment.

* fix(notes): render live-preview decorations

Switch markdown live-preview styling to a StateField-backed decorations pipeline and use a zero-width widget for marker hiding. Update evidence screenshots to show headings/emphasis rendering.
This commit is contained in:
ben
2026-02-12 23:57:49 -08:00
committed by GitHub
parent 1ae676e469
commit e0c0cccd0d
10 changed files with 738 additions and 30 deletions

View File

@@ -27,6 +27,11 @@
"bump:set": "node scripts/bump-version.mjs --set"
},
"dependencies": {
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.0",
"@opencode-ai/sdk": "^1.1.31",
"@radix-ui/colors": "^3.0.0",
"@solid-primitives/event-bus": "^1.1.2",

View File

@@ -0,0 +1,341 @@
import { createEffect, onCleanup, onMount } from "solid-js";
import { EditorState, StateField } from "@codemirror/state";
import {
Decoration,
type DecorationSet,
EditorView,
WidgetType,
keymap,
placeholder as cmPlaceholder,
} from "@codemirror/view";
import { history, defaultKeymap, historyKeymap, indentWithTab } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
type Props = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
ariaLabel?: string;
autofocus?: boolean;
class?: string;
};
type EmphasisRange = {
kind: "em" | "strong";
openFrom: number;
openTo: number;
closeFrom: number;
closeTo: number;
contentFrom: number;
contentTo: number;
};
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
const findEmphasisRanges = (line: string): EmphasisRange[] => {
// Minimal, line-local emphasis parsing.
// - Strong: **text**
// - Emphasis: *text*
// Avoid matching markers that wrap whitespace.
const ranges: EmphasisRange[] = [];
const used = new Array(line.length).fill(false);
const markUsed = (from: number, to: number) => {
for (let i = clamp(from, 0, line.length); i < clamp(to, 0, line.length); i += 1) used[i] = true;
};
const isUsed = (from: number, to: number) => {
for (let i = clamp(from, 0, line.length); i < clamp(to, 0, line.length); i += 1) {
if (used[i]) return true;
}
return false;
};
// Strong first.
{
const re = /\*\*(?!\s)([^*\n]+?)(?<!\s)\*\*/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line))) {
const full = m[0] ?? "";
const inner = m[1] ?? "";
const from = m.index;
const to = from + full.length;
if (!full || !inner) continue;
if (isUsed(from, to)) continue;
ranges.push({
kind: "strong",
openFrom: from,
openTo: from + 2,
contentFrom: from + 2,
contentTo: to - 2,
closeFrom: to - 2,
closeTo: to,
});
markUsed(from, to);
}
}
// Emphasis.
{
const re = /\*(?!\s)([^*\n]+?)(?<!\s)\*/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line))) {
const full = m[0] ?? "";
const inner = m[1] ?? "";
const from = m.index;
const to = from + full.length;
if (!full || !inner) continue;
if (isUsed(from, to)) continue;
ranges.push({
kind: "em",
openFrom: from,
openTo: from + 1,
contentFrom: from + 1,
contentTo: to - 1,
closeFrom: to - 1,
closeTo: to,
});
markUsed(from, to);
}
}
return ranges;
};
class HiddenMarkerWidget extends WidgetType {
toDOM() {
// Zero-width widget that collapses the marker range.
const el = document.createElement("span");
el.setAttribute("aria-hidden", "true");
el.style.display = "inline-block";
el.style.width = "0";
el.style.overflow = "hidden";
return el;
}
}
const obsidianishLivePreview = () => {
const headingLine = (level: number) =>
Decoration.line({ attributes: { class: `cm-ow-heading cm-ow-heading-${level}` } });
const hide = Decoration.replace({ widget: new HiddenMarkerWidget() });
const emMark = Decoration.mark({ class: "cm-ow-em" });
const strongMark = Decoration.mark({ class: "cm-ow-strong" });
const compute = (state: EditorState): DecorationSet => {
const ranges: any[] = [];
const add = (from: number, to: number, deco: any) => {
ranges.push(deco.range(from, to));
};
const doc = state.doc;
const selections = state.selection.ranges;
const activeLines = new Set<number>();
for (const r of selections) {
const fromLine = doc.lineAt(r.from).number;
const toLine = doc.lineAt(r.to).number;
for (let n = fromLine; n <= toLine; n += 1) activeLines.add(n);
if (r.empty) activeLines.add(doc.lineAt(r.head).number);
}
const cursorPos = state.selection.main.head;
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
const line = doc.line(lineNumber);
const lineText = line.text;
const lineActive = activeLines.has(line.number);
// Headings: hide leading '#' when line is not active.
const headingMatch = /^(#{1,6})\s+/.exec(lineText);
if (headingMatch) {
const level = headingMatch[1]?.length ?? 1;
add(line.from, line.from, headingLine(level));
if (!lineActive) {
const markerLen = (headingMatch[0] ?? "").length;
add(line.from, line.from + markerLen, hide);
}
}
// Emphasis / strong emphasis: style inner text; hide markers unless cursor is inside.
for (const r of findEmphasisRanges(lineText)) {
const absOpenFrom = line.from + r.openFrom;
const absOpenTo = line.from + r.openTo;
const absCloseFrom = line.from + r.closeFrom;
const absCloseTo = line.from + r.closeTo;
const absContentFrom = line.from + r.contentFrom;
const absContentTo = line.from + r.contentTo;
if (absContentTo <= absContentFrom) continue;
const cursorInside = cursorPos >= absOpenFrom && cursorPos <= absCloseTo;
const mark = r.kind === "strong" ? strongMark : emMark;
if (!cursorInside) {
add(absOpenFrom, absOpenTo, hide);
}
add(absContentFrom, absContentTo, mark);
if (!cursorInside) {
add(absCloseFrom, absCloseTo, hide);
}
}
}
return Decoration.set(ranges, true);
};
const field = StateField.define<DecorationSet>({
create(state) {
return compute(state);
},
update(value, tr) {
if (tr.docChanged || tr.selection) return compute(tr.state);
return value;
},
provide: (f) => EditorView.decorations.from(f),
});
return field;
};
const editorTheme = EditorView.theme({
"&": {
fontSize: "14px",
},
".cm-scroller": {
fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial",
},
".cm-content": {
padding: "12px 14px",
caretColor: "var(--dls-text-primary)",
},
".cm-line": {
padding: "0 2px",
},
".cm-focused": {
outline: "none",
},
".cm-selectionBackground": {
backgroundColor: "rgba(var(--dls-accent-rgb) / 0.18)",
},
".cm-focused .cm-selectionBackground": {
backgroundColor: "rgba(var(--dls-accent-rgb) / 0.22)",
},
".cm-cursor": {
borderLeftColor: "var(--dls-text-primary)",
},
".cm-placeholder": {
color: "var(--dls-text-secondary)",
},
".cm-ow-em": {
fontStyle: "italic",
},
".cm-ow-strong": {
fontWeight: "650",
},
".cm-ow-heading": {
letterSpacing: "-0.01em",
},
".cm-line.cm-ow-heading-1": {
fontSize: "28px",
fontWeight: "750",
lineHeight: "1.15",
paddingTop: "6px",
paddingBottom: "6px",
},
".cm-line.cm-ow-heading-2": {
fontSize: "22px",
fontWeight: "720",
lineHeight: "1.2",
paddingTop: "6px",
paddingBottom: "6px",
},
".cm-line.cm-ow-heading-3": {
fontSize: "18px",
fontWeight: "700",
lineHeight: "1.25",
paddingTop: "5px",
paddingBottom: "5px",
},
".cm-line.cm-ow-heading-4": {
fontSize: "16px",
fontWeight: "680",
lineHeight: "1.3",
paddingTop: "4px",
paddingBottom: "4px",
},
".cm-line.cm-ow-heading-5": {
fontSize: "15px",
fontWeight: "660",
lineHeight: "1.35",
paddingTop: "3px",
paddingBottom: "3px",
},
".cm-line.cm-ow-heading-6": {
fontSize: "14px",
fontWeight: "650",
lineHeight: "1.4",
paddingTop: "2px",
paddingBottom: "2px",
},
});
export default function LiveMarkdownEditor(props: Props) {
let hostEl: HTMLDivElement | undefined;
let view: EditorView | undefined;
const createState = (doc: string) =>
EditorState.create({
doc,
extensions: [
history(),
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
markdown(),
EditorView.lineWrapping,
cmPlaceholder(props.placeholder ?? ""),
editorTheme,
obsidianishLivePreview(),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
props.onChange(update.state.doc.toString());
}),
],
});
onMount(() => {
if (!hostEl) return;
view = new EditorView({
state: createState(props.value ?? ""),
parent: hostEl,
});
if (props.autofocus) {
queueMicrotask(() => view?.focus());
}
});
createEffect(() => {
if (!view) return;
const next = props.value ?? "";
const current = view.state.doc.toString();
if (next === current) return;
view.dispatch({ changes: { from: 0, to: current.length, insert: next } });
});
onCleanup(() => {
view?.destroy();
view = undefined;
});
return (
<div
class={props.class}
aria-label={props.ariaLabel}
role="textbox"
ref={(el) => (hostEl = el)}
/>
);
}

View File

@@ -0,0 +1,62 @@
import { Show } from "solid-js";
import { FileText, Trash2, X } from "lucide-solid";
import LiveMarkdownEditor from "../live-markdown-editor";
type Props = {
value: string;
onChange: (value: string) => void;
onClose?: () => void;
onClear?: () => void;
title?: string;
};
export default function ScratchpadPanel(props: Props) {
const title = () => props.title ?? "Notes";
const canClear = () => typeof props.onClear === "function";
return (
<div class="flex flex-col h-full min-h-0">
<div class="h-14 px-4 border-b border-dls-border flex items-center justify-between bg-dls-sidebar">
<div class="flex items-center gap-2 min-w-0">
<FileText size={16} class="text-dls-secondary shrink-0" />
<div class="text-sm font-semibold text-dls-text truncate">{title()}</div>
</div>
<div class="flex items-center gap-1.5">
<Show when={canClear()}>
<button
type="button"
class="p-2 rounded-lg text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
onClick={() => props.onClear?.()}
title="Clear notes"
aria-label="Clear notes"
>
<Trash2 size={16} />
</button>
</Show>
<Show when={typeof props.onClose === "function"}>
<button
type="button"
class="p-2 rounded-lg text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
onClick={() => props.onClose?.()}
title="Close"
aria-label="Close"
>
<X size={16} />
</button>
</Show>
</div>
</div>
<div class="flex-1 min-h-0 overflow-hidden">
<LiveMarkdownEditor
value={props.value}
onChange={props.onChange}
placeholder="# Write notes like Obsidian\n\n- Use # headings\n- Use *italic* and **bold**\n"
ariaLabel="Scratchpad"
class="h-full"
/>
</div>
</div>
);
}

View File

@@ -27,6 +27,8 @@ import {
Check,
ChevronDown,
ChevronRight,
Cpu,
FileText,
HardDrive,
History,
ListTodo,
@@ -63,6 +65,7 @@ import soulSetupTemplate from "../data/commands/give-me-a-soul.md?raw";
import MessageList from "../components/session/message-list";
import Composer from "../components/session/composer";
import type { SidebarSectionState } from "../components/session/sidebar";
import ScratchpadPanel from "../components/session/scratchpad-panel";
import FlyoutItem from "../components/flyout-item";
import QuestionModal from "../components/question-modal";
import ArtifactsPanel from "../components/session/artifacts-panel";
@@ -238,6 +241,78 @@ export default function SessionView(props: SessionViewProps) {
const [markdownEditorOpen, setMarkdownEditorOpen] = createSignal(false);
const [markdownEditorPath, setMarkdownEditorPath] = createSignal<string | null>(null);
const SCRATCHPAD_OPEN_KEY = "openwork.scratchpad.open.v1";
const SCRATCHPAD_VALUE_PREFIX = "openwork.scratchpad.value.v1";
const readBool = (key: string, fallback: boolean) => {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
if (raw === "true") return true;
if (raw === "false") return false;
return fallback;
} catch {
return fallback;
}
};
const writeBool = (key: string, value: boolean) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, value ? "true" : "false");
} catch {
// ignore
}
};
const readString = (key: string) => {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem(key) ?? "";
} catch {
return "";
}
};
const writeString = (key: string, value: string) => {
if (typeof window === "undefined") return;
try {
const trimmed = value ?? "";
if (!trimmed.trim()) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, trimmed);
}
} catch {
// ignore
}
};
const [scratchpadOpen, setScratchpadOpen] = createSignal(readBool(SCRATCHPAD_OPEN_KEY, true));
createEffect(() => writeBool(SCRATCHPAD_OPEN_KEY, scratchpadOpen()));
const scratchpadStorageKey = createMemo(() => {
const workspaceId = props.activeWorkspaceId || "workspace";
const sessionId = props.selectedSessionId || "draft";
return `${SCRATCHPAD_VALUE_PREFIX}.${workspaceId}.${sessionId}`;
});
const [scratchpadValue, setScratchpadValue] = createSignal("");
createEffect(
on(scratchpadStorageKey, (key) => {
setScratchpadValue(readString(key));
})
);
createEffect(() => {
const key = scratchpadStorageKey();
const value = scratchpadValue();
let timer: number | undefined;
if (typeof window !== "undefined") {
timer = window.setTimeout(() => writeString(key, value), 150);
}
onCleanup(() => {
if (typeof window !== "undefined" && timer) window.clearTimeout(timer);
});
});
// When a session is selected (i.e. we are in SessionView), the right sidebar is
// navigation-only. Avoid showing any tab as "selected" to reduce confusion.
const showRightSidebarSelection = createMemo(() => !props.selectedSessionId);
@@ -2024,7 +2099,7 @@ export default function SessionView(props: SessionViewProps) {
<main class="flex-1 flex flex-col overflow-hidden bg-dls-surface">
<header class="h-14 border-b border-dls-border flex items-center justify-between px-6 bg-dls-surface z-10 shrink-0">
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 min-w-0">
<Show when={showUpdatePill()}>
<button
type="button"
@@ -2053,9 +2128,8 @@ export default function SessionView(props: SessionViewProps) {
</Show>
</button>
</Show>
<h1 class="text-sm font-semibold text-dls-text">
{selectedSessionTitle() || "New task"}
</h1>
<h1 class="text-sm font-semibold text-dls-text truncate">{selectedSessionTitle() || "New task"}</h1>
<Show when={props.developerMode}>
<span class="text-xs text-dls-secondary">{props.headerStatus}</span>
</Show>
@@ -2065,6 +2139,20 @@ export default function SessionView(props: SessionViewProps) {
</div>
<div class="flex items-center gap-2">
<button
type="button"
class={`hidden lg:flex h-9 items-center gap-2 rounded-xl border border-dls-border px-3 text-xs font-medium transition-colors ${scratchpadOpen()
? "bg-dls-active text-dls-text"
: "bg-dls-hover text-dls-secondary hover:text-dls-text hover:bg-dls-active"
}`}
onClick={() => setScratchpadOpen((v) => !v)}
title="Toggle notes"
aria-label="Toggle notes"
>
<FileText size={14} class="shrink-0" />
<span>Notes</span>
</button>
<button
type="button"
class={`h-9 w-9 flex items-center justify-center rounded-lg transition-colors ${
@@ -2215,17 +2303,18 @@ export default function SessionView(props: SessionViewProps) {
</div>
</Show>
<div class="flex-1 flex overflow-hidden relative">
<div
class="flex-1 overflow-y-auto px-12 py-10 scroll-smooth bg-dls-surface"
ref={(el) => (chatContainerEl = el)}
>
<div class="max-w-5xl mx-auto w-full">
<Show when={props.messages.length === 0}>
<div class="text-center py-16 px-6 space-y-6">
<div class="w-16 h-16 bg-dls-hover rounded-3xl mx-auto flex items-center justify-center border border-dls-border">
<Zap class="text-dls-secondary" />
</div>
<div class="flex-1 flex overflow-hidden">
<div class="flex-1 min-w-0 relative overflow-hidden">
<div
class="h-full overflow-y-auto px-12 py-10 scroll-smooth bg-dls-surface"
ref={(el) => (chatContainerEl = el)}
>
<div class="max-w-5xl mx-auto w-full">
<Show when={props.messages.length === 0}>
<div class="text-center py-16 px-6 space-y-6">
<div class="w-16 h-16 bg-dls-hover rounded-3xl mx-auto flex items-center justify-center border border-dls-border">
<Zap class="text-dls-secondary" />
</div>
<div class="space-y-2">
<h3 class="text-xl font-medium">What do you want to do?</h3>
<p class="text-dls-secondary text-sm max-w-sm mx-auto">
@@ -2295,22 +2384,34 @@ export default function SessionView(props: SessionViewProps) {
}
/>
<div ref={(el) => (messagesEndEl = el)} />
</div>
</div>
<div ref={(el) => (messagesEndEl = el)} />
</div>
</div>
<Show when={!autoScrollEnabled() && props.messages.length > 0}>
<div class="absolute bottom-4 left-0 right-0 z-20 flex justify-center pointer-events-none">
<button
type="button"
class="pointer-events-auto rounded-full border border-gray-6 bg-gray-1/90 px-4 py-2 text-xs text-gray-11 shadow-lg shadow-gray-12/5 backdrop-blur-md hover:bg-gray-2 transition-colors"
onClick={() => scrollToLatest("smooth")}
>
Jump to latest
</button>
</div>
</Show>
</div>
<Show when={!autoScrollEnabled() && props.messages.length > 0}>
<div class="absolute bottom-4 left-0 right-0 z-20 flex justify-center pointer-events-none">
<button
type="button"
class="pointer-events-auto rounded-full border border-gray-6 bg-gray-1/90 px-4 py-2 text-xs text-gray-11 shadow-lg shadow-gray-12/5 backdrop-blur-md hover:bg-gray-2 transition-colors"
onClick={() => scrollToLatest("smooth")}
>
Jump to latest
</button>
</div>
</Show>
</div>
<Show when={scratchpadOpen()}>
<aside class="hidden lg:flex w-[420px] shrink-0 border-l border-dls-border bg-dls-sidebar">
<ScratchpadPanel
value={scratchpadValue()}
onChange={setScratchpadValue}
onClose={() => setScratchpadOpen(false)}
onClear={() => setScratchpadValue("")}
/>
</aside>
</Show>
</div>
<Show when={todoCount() > 0}>
<div class="mx-auto w-full max-w-[68ch] px-4">

199
pnpm-lock.yaml generated
View File

@@ -27,6 +27,21 @@ importers:
packages/app:
dependencies:
'@codemirror/commands':
specifier: ^6.8.0
version: 6.10.2
'@codemirror/lang-markdown':
specifier: ^6.3.3
version: 6.5.0
'@codemirror/language':
specifier: ^6.11.0
version: 6.12.1
'@codemirror/state':
specifier: ^6.5.2
version: 6.5.4
'@codemirror/view':
specifier: ^6.38.0
version: 6.39.14
'@opencode-ai/sdk':
specifier: ^1.1.31
version: 1.1.39
@@ -384,6 +399,36 @@ packages:
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
engines: {node: '>=6.9.0'}
'@codemirror/autocomplete@6.20.0':
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
'@codemirror/commands@6.10.2':
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-javascript@6.2.4':
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
'@codemirror/language@6.12.1':
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
'@codemirror/lint@6.9.4':
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
'@codemirror/state@6.5.4':
resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
'@codemirror/view@6.39.14':
resolution: {integrity: sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==}
'@dimforge/rapier2d-simd-compat@0.17.3':
resolution: {integrity: sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg==}
@@ -838,6 +883,30 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.1':
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
'@lezer/css@1.3.0':
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
'@lezer/lr@1.4.8':
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
'@lezer/markdown@1.6.3':
resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@next/env@14.2.5':
resolution: {integrity: sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA==}
@@ -1512,6 +1581,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -2318,6 +2390,9 @@ packages:
resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==}
engines: {node: '>=10'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
styled-jsx@5.1.1:
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
engines: {node: '>= 12.0.0'}
@@ -2480,6 +2555,9 @@ packages:
vite:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
web-tree-sitter@0.25.10:
resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==}
peerDependencies:
@@ -2764,6 +2842,86 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@codemirror/autocomplete@6.20.0':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.14
'@lezer/common': 1.5.1
'@codemirror/commands@6.10.2':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.14
'@lezer/common': 1.5.1
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.1
'@lezer/css': 1.3.0
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.14
'@lezer/common': 1.5.1
'@lezer/css': 1.3.0
'@lezer/html': 1.3.13
'@codemirror/lang-javascript@6.2.4':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.4
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.14
'@lezer/common': 1.5.1
'@lezer/javascript': 1.5.4
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.14
'@lezer/common': 1.5.1
'@lezer/markdown': 1.6.3
'@codemirror/language@6.12.1':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.14
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
style-mod: 4.1.3
'@codemirror/lint@6.9.4':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.14
crelt: 1.0.6
'@codemirror/state@6.5.4':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/view@6.39.14':
dependencies:
'@codemirror/state': 6.5.4
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@dimforge/rapier2d-simd-compat@0.17.3':
optional: true
@@ -3139,6 +3297,41 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lezer/common@1.5.1': {}
'@lezer/css@1.3.0':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.1
'@lezer/html@1.3.13':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/lr@1.4.8':
dependencies:
'@lezer/common': 1.5.1
'@lezer/markdown@1.6.3':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@marijn/find-cluster-break@1.0.2': {}
'@next/env@14.2.5': {}
'@next/swc-darwin-arm64@14.2.5':
@@ -3744,6 +3937,8 @@ snapshots:
convert-source-map@2.0.0: {}
crelt@1.0.6: {}
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -4534,6 +4729,8 @@ snapshots:
'@tokenizer/token': 0.3.0
peek-readable: 4.1.0
style-mod@4.1.3: {}
styled-jsx@5.1.1(react@18.2.0):
dependencies:
client-only: 0.0.1
@@ -4680,6 +4877,8 @@ snapshots:
optionalDependencies:
vite: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
w3c-keyname@2.2.8: {}
web-tree-sitter@0.25.10: {}
webidl-conversions@3.0.1: {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB