@tiagojct/subsub 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/web.ts CHANGED
@@ -16,16 +16,17 @@
16
16
 
17
17
  import { spawn, type ChildProcess } from "node:child_process";
18
18
  import { randomBytes, timingSafeEqual } from "node:crypto";
19
- import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
19
+ import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
20
20
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
21
21
  import { homedir } from "node:os";
22
- import { extname, join, normalize, resolve, sep } from "node:path";
22
+ import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
23
23
  import { StringDecoder } from "node:string_decoder";
24
24
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
25
  import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
26
26
  import { TESTED_MODELS } from "./init.ts";
27
27
  import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
28
- import { obsidianRoot } from "./layout.ts";
28
+ import { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.ts";
29
+ import { field, frontMatter } from "./notes.ts";
29
30
  import { realish, within } from "./paths.ts";
30
31
 
31
32
  // ---------------------------------------------------------------- options
@@ -199,9 +200,49 @@ const TYPES: Record<string, string> = {
199
200
  ".ico": "image/x-icon",
200
201
  };
201
202
 
203
+ /** Files that the notes pane shows. */
204
+ const NOTE_TYPES = new Set([".md", ".markdown"]);
205
+
206
+ /** Images that a note shows (a figure cut out of a PDF, for example). */
207
+ const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
208
+
202
209
  /** Files that the Open button may hand to the system (never programs or scripts). */
203
210
  export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
204
211
 
212
+ /** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
213
+ export function allowedFile(raw: string, cwd: string, vault?: string): string | undefined {
214
+ // expand() resolves against the process folder: use it for "~" only.
215
+ const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
216
+ const real = realish(target);
217
+ const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
218
+ return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
219
+ }
220
+
221
+ /** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
222
+ export function wikilinkTarget(name: string, root: string): string | undefined {
223
+ const file = name.endsWith(".md") ? name : `${name}.md`;
224
+ return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
225
+ }
226
+
227
+ /** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
228
+ export function listNotes(root: string, max = 300): Array<{ path: string; rel: string; modified: number }> {
229
+ const out: Array<{ path: string; rel: string; modified: number }> = [];
230
+ for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
231
+ let names: string[] = [];
232
+ try {
233
+ names = readdirSync(join(root, d), { recursive: true }) as string[];
234
+ } catch {
235
+ continue;
236
+ }
237
+ for (const n of names) {
238
+ if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith("."))) continue;
239
+ const path = join(root, d, n);
240
+ out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
241
+ }
242
+ }
243
+ return out.sort((a, b) => b.modified - a.modified).slice(0, max);
244
+ }
245
+
205
246
  const CSP = [
206
247
  "default-src 'none'",
207
248
  "script-src 'self'",
@@ -214,6 +255,14 @@ const CSP = [
214
255
  "frame-ancestors 'none'",
215
256
  ].join("; ");
216
257
 
258
+ function readText(path: string): string {
259
+ try {
260
+ return readFileSync(path, "utf8");
261
+ } catch {
262
+ return "";
263
+ }
264
+ }
265
+
217
266
  function same(a: string, b: string): boolean {
218
267
  const x = Buffer.from(a);
219
268
  const y = Buffer.from(b);
@@ -300,6 +349,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
300
349
  const agentDir = agentDirFor(env, home);
301
350
  const cwd = chooseCwd(process.cwd(), home, cfg.vault, opts.here);
302
351
  const lang = uiLanguage(cfg.language);
352
+ const notesRoot = cfg.vault ?? cwd;
303
353
  const token = randomBytes(24).toString("base64url");
304
354
  const webDir = join(PACKAGE_DIR, "web");
305
355
  const bin = join(PACKAGE_DIR, "bin", "subsub.js");
@@ -313,6 +363,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
313
363
  const clients = new Set<ServerResponse>();
314
364
  const dialogs = new Map<string, any>();
315
365
  let subsubState: unknown = null;
366
+ let lastFind: { q?: string } | null = null;
316
367
  let busy = false;
317
368
  let lastSeen = Date.now();
318
369
  let restarting = false;
@@ -347,6 +398,12 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
347
398
  subsubState = null;
348
399
  }
349
400
  broadcast({ type: "subsub_state", state: subsubState });
401
+ } else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
402
+ try {
403
+ lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
404
+ } catch {
405
+ lastFind = null;
406
+ }
350
407
  } else if (ev.method === "notify") {
351
408
  broadcast(ev);
352
409
  }
@@ -388,13 +445,23 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
388
445
  out.session = await agent.request({ type: "get_state" });
389
446
  lastSession = (out.session as { sessionFile?: string }).sessionFile ?? lastSession;
390
447
  out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
391
- out.commands = ((await agent.request({ type: "get_commands" })) as { commands: unknown[] }).commands;
448
+ const commands = ((await agent.request({ type: "get_commands" })) as { commands: Array<Record<string, any>> }).commands;
449
+ // RPC has no argument hints: read them from the prompt files.
450
+ out.commands = commands.map((c) => {
451
+ const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
452
+ const hint = front && field(front, "argument-hint");
453
+ return hint ? { ...c, hint } : c;
454
+ });
392
455
  } catch (err) {
393
456
  out.error = (err as Error).message;
394
457
  }
395
458
  return out;
396
459
  }
397
460
 
461
+ async function currentSession(): Promise<string | undefined> {
462
+ return ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile;
463
+ }
464
+
398
465
  async function sessions(): Promise<unknown[]> {
399
466
  const { SessionManager } = await import("@earendil-works/pi-coding-agent");
400
467
  const list = await SessionManager.list(cwd);
@@ -521,7 +588,8 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
521
588
  const message = String(body.message ?? "").trim();
522
589
  if (!message) return send(res, 400, { error: "empty message" });
523
590
  try {
524
- await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
591
+ // A command answers only after its dialogs close (/read, /undo), so wait longer than the default.
592
+ await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) }, 60 * 60_000);
525
593
  } catch (err) {
526
594
  // No provider yet: the page opens the model dialog instead of showing pi's text.
527
595
  if (/no api key|no model|no models available/i.test((err as Error).message)) return send(res, 409, { error: "nomodel" });
@@ -609,14 +677,66 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
609
677
  if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
610
678
  });
611
679
  }
680
+ case "GET /api/notes":
681
+ return send(res, 200, { root: notesRoot, notes: listNotes(notesRoot) });
682
+ case "GET /api/note": {
683
+ const raw = url.searchParams.get("path") ?? "";
684
+ const wiki = !/[\\/]/.test(raw) && !/\.\w+$/.test(raw) ? wikilinkTarget(raw, notesRoot) : undefined;
685
+ // A relative path is tried in the working folder, then in the Sub-Sub folder (Literature/x.md).
686
+ const file = allowedFile(wiki ?? raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
687
+ if (!file) return send(res, 404, { error: "not found in the Sub-Sub folder" });
688
+ if (!NOTE_TYPES.has(extname(file).toLowerCase())) return send(res, 415, { error: "not a note" });
689
+ if (statSync(file).size > 2_000_000) return send(res, 413, { error: "too large to show" });
690
+ return send(res, 200, { path: file, name: basename(file), text: readFileSync(file, "utf8") });
691
+ }
692
+ case "GET /api/note-file": {
693
+ const raw = url.searchParams.get("path") ?? "";
694
+ const file = allowedFile(raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
695
+ if (!file) return send(res, 404, { error: "not found in the Sub-Sub folder" });
696
+ const type = IMAGE_TYPES[extname(file).toLowerCase()];
697
+ if (!type) return send(res, 415, { error: "not an image" });
698
+ if (statSync(file).size > 20_000_000) return send(res, 413, { error: "too large to show" });
699
+ res.writeHead(200, { ...common, "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
700
+ return void createReadStream(file).pipe(res);
701
+ }
702
+ case "GET /api/library": {
703
+ const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
704
+ if (!q) return send(res, 200, { q, items: [] });
705
+ await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
706
+ return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
707
+ }
708
+ case "POST /api/session/name": {
709
+ const body = await readJson(req);
710
+ const name = String(body.name ?? "").replace(/\s+/g, " ").trim();
711
+ if (!name || name.length > 120) return send(res, 400, { error: "a name of 1 to 120 characters" });
712
+ const known = (await sessions()) as Array<{ path: string }>;
713
+ if (!known.some((x) => x.path === body.path)) return send(res, 404, { error: "no such session" });
714
+ if (body.path === (await currentSession())) await agent.request({ type: "set_session_name", name });
715
+ else {
716
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
717
+ SessionManager.open(String(body.path)).appendSessionInfo(name);
718
+ }
719
+ return send(res, 200, { ok: true });
720
+ }
721
+ case "POST /api/session/trash": {
722
+ const body = await readJson(req);
723
+ const known = (await sessions()) as Array<{ path: string }>;
724
+ if (!known.some((x) => x.path === body.path)) return send(res, 404, { error: "no such session" });
725
+ if (body.path === (await currentSession())) return send(res, 409, { error: "the open conversation" });
726
+ // Nothing is deleted: the file moves to a trash folder next to the agent folder, in the same
727
+ // subfolder as under sessions/, so that moving it back is one step.
728
+ const rel = relative(join(agentDir, "sessions"), String(body.path));
729
+ const to = join(dirname(agentDir), "trash", rel.startsWith("..") ? basename(rel) : rel);
730
+ mkdirSync(dirname(to), { recursive: true });
731
+ renameSync(String(body.path), to);
732
+ return send(res, 200, { ok: true });
733
+ }
612
734
  case "POST /api/open": {
613
735
  const body = await readJson(req);
614
- const target = resolve(cwd, expand(String(body.path ?? "")));
615
736
  const vault = cfg.vault;
616
737
  // Judge the real file, so a link inside the Sub-Sub folder cannot open a document outside it.
617
- const real = realish(target);
618
- const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
619
- if (!inside || !existsSync(target)) return send(res, 404, { error: "not found in the Sub-Sub folder" });
738
+ const target = allowedFile(String(body.path ?? ""), cwd, vault);
739
+ if (!target) return send(res, 404, { error: "not found in the Sub-Sub folder" });
620
740
  // Documents only: a file that the model wrote must never be run.
621
741
  if (!OPENABLE.has(extname(target).toLowerCase())) return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
622
742
  if (vault && within(target, vault) && obsidianRoot(vault)) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
@@ -41,10 +41,10 @@ Shared rules for Sub-Sub. Sub-Sub reads this file at the start of every session.
41
41
  - Create a note only when the user asks. Ask before creating more than 10 notes.
42
42
  - Write from the full text (get_fulltext) when it exists. Otherwise use the abstract and the Zotero notes, and write "(from abstract)" after the summary.
43
43
  - Front matter field evidence: full text, abstract or metadata (what you read). Page numbers only with evidence: full text. Sub-Sub refuses a literature note without this field.
44
- - Do not copy the abstract in full. Give page numbers for quotes when the text shows them.
44
+ - Do not copy the abstract in full. Give the page of a quote as (p. N) right after it, when you read the full text; Sub-Sub links it to that page of the PDF in Zotero.
45
45
  - If a note for the citekey exists, do not overwrite it. Add a dated section or ask.
46
46
  - After the note is written, offer to link it in Zotero with attach_note.
47
- - The profile can change this format: the Reader profile writes quotes and questions instead of a summary.
47
+ - /read writes a reading note instead: quotes with their place and questions, which the user answers.
48
48
 
49
49
  Template:
50
50
 
package/web/app.css CHANGED
@@ -195,6 +195,8 @@ select:hover { border-color: var(--rule-strong); }
195
195
  }
196
196
  .app.side-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
197
197
  .app.side-collapsed .side { display: none; }
198
+ .app.note-open { grid-template-columns: var(--side-w) minmax(0, 1fr) minmax(20rem, 36%); }
199
+ .app.note-open.side-collapsed { grid-template-columns: 0 minmax(0, 1fr) minmax(20rem, 40%); }
198
200
 
199
201
  /* ---------------------------------------------------------------- top bar */
200
202
 
@@ -274,6 +276,8 @@ select:hover { border-color: var(--rule-strong); }
274
276
  /* ---------------------------------------------------------------- side panel */
275
277
 
276
278
  .side {
279
+ /* The containing block of the switch's hidden input: without it, the page itself scrolls. */
280
+ position: relative;
277
281
  border-right: 1px solid var(--rule);
278
282
  background: var(--surface);
279
283
  padding: 0.9rem 0.75rem 0.75rem;
@@ -558,6 +562,7 @@ body.layout-full { --measure: 72rem; }
558
562
  .msg-action-btn { font-size: 0.75rem; padding: 0.15rem 0.45rem; color: var(--faint); }
559
563
  .msg-action-btn:hover { color: var(--text); background: var(--surface); }
560
564
  .msg-action-btn.copied { color: var(--add); }
565
+ .table-actions { display: flex; justify-content: flex-end; margin: -0.35rem 0 0.9rem; }
561
566
 
562
567
  /* Tool calls: one quiet line each; the result opens below. */
563
568
  .tool { margin: 0.35rem 0; font-size: 0.82rem; color: var(--muted); }
@@ -736,7 +741,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
736
741
  padding: 0.3rem;
737
742
  z-index: 5;
738
743
  }
739
- .suggest button { display: flex; align-items: baseline; gap: 0.6rem; width: 100%; text-align: left; padding: 0.38rem 0.55rem; border-radius: 5px; }
744
+ .suggest button, .suggest .hint-row { display: flex; align-items: baseline; gap: 0.6rem; width: 100%; text-align: left; padding: 0.38rem 0.55rem; border-radius: 5px; }
740
745
  .suggest button[aria-selected="true"], .suggest button:hover { background: var(--accent-soft); }
741
746
  .suggest .name { font-family: var(--mono); font-size: 0.8rem; color: var(--accent); white-space: nowrap; }
742
747
  .suggest .desc { color: var(--muted); font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -792,6 +797,10 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
792
797
 
793
798
  .modal-buttons { display: flex; justify-content: flex-end; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; }
794
799
  .modal-buttons button { min-width: 5.5rem; }
800
+ /* Long choices (the reasons of /read) read better as a list than as a row of buttons. */
801
+ .modal-buttons.choices { flex-direction: column; align-items: stretch; }
802
+ .modal-buttons.choices button { text-align: left; }
803
+ .modal-buttons.choices .ghost { align-self: flex-end; }
795
804
  .modal-shortcut-hint { font-size: 0.78rem; color: var(--faint); margin-right: auto; }
796
805
  .modal-head { display: flex; justify-content: space-between; align-items: center; }
797
806
  .modal input[type="text"], .modal input[type="password"], .modal textarea, .modal select {
@@ -845,9 +854,68 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
845
854
  .side .phone-only { display: none; }
846
855
  .settings { display: grid; gap: 0.6rem; padding: 0 0.3rem; }
847
856
  .settings label { display: grid; gap: 0.25rem; font-size: 0.78rem; color: var(--muted); }
848
- .settings select, .settings button { width: 100%; text-align: left; color: var(--text); }
857
+ .settings button { width: 100%; text-align: left; color: var(--text); }
849
858
  .settings .model-btn { font-family: var(--mono); font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
850
859
 
860
+ /* ---------------------------------------------------------------- the flow of works (/lit) */
861
+
862
+ .flow { display: grid; gap: 1rem; margin: 1rem 0; font-family: var(--sans); }
863
+ .flow-row { display: grid; grid-template-columns: minmax(0, 15rem) repeat(auto-fit, minmax(0, 13rem)); gap: 1.4rem; align-items: center; }
864
+ .flow-box { position: relative; padding: 0.45rem 0.7rem; border: 1px solid var(--rule-strong); border-radius: var(--radius); background: var(--raised); font-size: 0.84rem; text-align: center; }
865
+ .flow-row + .flow-row .flow-box::before { content: ""; position: absolute; left: 50%; top: calc(-1rem - 1px); height: 1rem; border-left: 1px solid var(--muted); }
866
+ .flow-side { position: relative; padding: 0.35rem 0.6rem; border: 1px dashed var(--rule-strong); border-radius: var(--radius); color: var(--muted); font-size: 0.78rem; }
867
+ .flow-side::before { content: ""; position: absolute; right: 100%; top: 50%; width: 1.4rem; border-top: 1px dashed var(--muted); }
868
+
869
+ /* ---------------------------------------------------------------- library search and notes */
870
+
871
+ #notes-btn { font-family: var(--sans); }
872
+ #notes-btn[aria-expanded="true"] { color: var(--accent); border-color: var(--accent); }
873
+
874
+ .items { list-style: none; margin: 0; padding: 0; max-height: 18rem; overflow-y: auto; }
875
+ .items:empty { display: none; }
876
+ .items > li { padding: 0.4rem 0.5rem; border-radius: 5px; }
877
+ .items > li:hover { background: var(--bg); }
878
+ .items > li.muted { font-size: 0.82rem; }
879
+ .item-title { font-size: 0.86rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
880
+ .item-meta { font-size: 0.72rem; color: var(--faint); margin-top: 0.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
881
+ .item-actions { display: flex; gap: 0.6rem; margin-top: 0.2rem; }
882
+ .item-actions button, .item-actions a { font-size: 0.76rem; color: var(--accent); padding: 0; text-decoration: none; }
883
+ .item-actions button:hover, .item-actions a:hover { text-decoration: underline; text-underline-offset: 0.18em; }
884
+
885
+ .sessions li { position: relative; }
886
+ .session-tools { position: absolute; top: 0.3rem; right: 0.3rem; display: none; gap: 0.3rem; background: var(--raised); border-radius: 4px; }
887
+ .sessions li:hover .session-tools, .sessions li:focus-within .session-tools { display: flex; }
888
+ /* No hover on a touch screen: the buttons stay under the conversation. */
889
+ @media (hover: none) { .session-tools { display: flex; position: static; justify-content: flex-end; background: none; } }
890
+ .session-tools button { width: auto; padding: 0.1rem 0.35rem; font-size: 0.7rem; color: var(--muted); }
891
+ .session-tools button:hover { color: var(--text); background: var(--bg); }
892
+
893
+ .note-pane {
894
+ grid-row: 2;
895
+ grid-column: 3;
896
+ border-left: 1px solid var(--rule);
897
+ background: var(--surface);
898
+ display: flex;
899
+ flex-direction: column;
900
+ min-height: 0;
901
+ min-width: 0;
902
+ }
903
+ .note-pane[hidden] { display: none; }
904
+ .note-head { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--rule); }
905
+ .note-name { flex: 1; min-width: 0; font-size: 0.84rem; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
906
+ .note-pane > .search { margin: 0.6rem 0.9rem 0; width: auto; }
907
+ .note-body { flex: 1; overflow-y: auto; padding: 0.9rem 1.1rem 1.5rem; }
908
+ .note-body .front { font-size: 0.74rem; color: var(--muted); margin: 0 0 1rem; }
909
+ .note-body .front summary { cursor: pointer; }
910
+ .note-body .front pre { background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius); padding: 0.5rem 0.7rem; margin: 0.4rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
911
+ .note-list { list-style: none; margin: 0; padding: 0; }
912
+ .note-list button { width: 100%; text-align: left; padding: 0.4rem 0.5rem; border-radius: 5px; display: grid; gap: 0.1rem; }
913
+ .note-list button:hover { background: var(--bg); }
914
+ .note-list .rel { font-size: 0.84rem; overflow-wrap: anywhere; }
915
+ .note-list .when { font-size: 0.72rem; color: var(--faint); }
916
+ .note-body img.note-img { display: block; max-width: 100%; height: auto; margin: 0.8rem 0; border: 1px solid var(--rule); border-radius: var(--radius); background: #fff; }
917
+ a[data-note] { color: var(--accent); text-decoration: underline; text-decoration-color: var(--accent-soft); text-underline-offset: 0.18em; cursor: pointer; }
918
+
851
919
  @media (max-width: 64rem) {
852
920
  .composer-hint { display: none; }
853
921
  }
@@ -865,6 +933,8 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
865
933
  box-shadow: var(--shadow);
866
934
  }
867
935
  .side.open { transform: none; }
936
+ .app.note-open, .app.note-open.side-collapsed { grid-template-columns: minmax(0, 1fr); }
937
+ .note-pane { position: fixed; z-index: 25; inset: 0; border-left: 0; }
868
938
  .top { padding: 0.5rem 0.75rem; gap: 0.5rem; }
869
939
  .wide-only { display: none !important; }
870
940
  .library .library-text { display: none; }