@tiagojct/subsub 0.16.0 → 0.17.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/CHANGELOG.md +14 -0
- package/README.md +8 -12
- package/dist/bench.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +0 -6
- package/dist/doctor.js +1 -2
- package/dist/init.js +7 -20
- package/dist/look.js +2 -2
- package/dist/prompt.js +4 -6
- package/dist/read.js +88 -0
- package/dist/roles.js +4 -10
- package/dist/subsub.js +81 -47
- package/dist/web.js +122 -9
- package/package.json +1 -1
- package/prompts/lit.md +2 -2
- package/roles/researcher.md +3 -1
- package/src/bench.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/config.ts +1 -11
- package/src/doctor.ts +1 -2
- package/src/init.ts +6 -31
- package/src/look.ts +2 -4
- package/src/prompt.ts +6 -8
- package/src/read.ts +107 -0
- package/src/roles.ts +5 -12
- package/src/subsub.ts +71 -48
- package/src/usage.ts +1 -1
- package/src/web.ts +114 -9
- package/templates/Zotero agent.md +1 -1
- package/web/app.css +61 -2
- package/web/app.js +215 -18
- package/web/i18n.js +56 -25
- package/web/index.html +17 -16
- package/dist/profiles.js +0 -96
- package/src/profiles.ts +0 -118
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, 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,46 @@ 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
|
+
|
|
202
206
|
/** Files that the Open button may hand to the system (never programs or scripts). */
|
|
203
207
|
export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
|
|
204
208
|
|
|
209
|
+
/** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
|
|
210
|
+
export function allowedFile(raw: string, cwd: string, vault?: string): string | undefined {
|
|
211
|
+
// expand() resolves against the process folder: use it for "~" only.
|
|
212
|
+
const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
|
|
213
|
+
const real = realish(target);
|
|
214
|
+
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
215
|
+
return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
|
|
219
|
+
export function wikilinkTarget(name: string, root: string): string | undefined {
|
|
220
|
+
const file = name.endsWith(".md") ? name : `${name}.md`;
|
|
221
|
+
return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
|
|
225
|
+
export function listNotes(root: string, max = 300): Array<{ path: string; rel: string; modified: number }> {
|
|
226
|
+
const out: Array<{ path: string; rel: string; modified: number }> = [];
|
|
227
|
+
for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
|
|
228
|
+
let names: string[] = [];
|
|
229
|
+
try {
|
|
230
|
+
names = readdirSync(join(root, d), { recursive: true }) as string[];
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
for (const n of names) {
|
|
235
|
+
if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith("."))) continue;
|
|
236
|
+
const path = join(root, d, n);
|
|
237
|
+
out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out.sort((a, b) => b.modified - a.modified).slice(0, max);
|
|
241
|
+
}
|
|
242
|
+
|
|
205
243
|
const CSP = [
|
|
206
244
|
"default-src 'none'",
|
|
207
245
|
"script-src 'self'",
|
|
@@ -214,6 +252,14 @@ const CSP = [
|
|
|
214
252
|
"frame-ancestors 'none'",
|
|
215
253
|
].join("; ");
|
|
216
254
|
|
|
255
|
+
function readText(path: string): string {
|
|
256
|
+
try {
|
|
257
|
+
return readFileSync(path, "utf8");
|
|
258
|
+
} catch {
|
|
259
|
+
return "";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
217
263
|
function same(a: string, b: string): boolean {
|
|
218
264
|
const x = Buffer.from(a);
|
|
219
265
|
const y = Buffer.from(b);
|
|
@@ -300,6 +346,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
300
346
|
const agentDir = agentDirFor(env, home);
|
|
301
347
|
const cwd = chooseCwd(process.cwd(), home, cfg.vault, opts.here);
|
|
302
348
|
const lang = uiLanguage(cfg.language);
|
|
349
|
+
const notesRoot = cfg.vault ?? cwd;
|
|
303
350
|
const token = randomBytes(24).toString("base64url");
|
|
304
351
|
const webDir = join(PACKAGE_DIR, "web");
|
|
305
352
|
const bin = join(PACKAGE_DIR, "bin", "subsub.js");
|
|
@@ -313,6 +360,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
313
360
|
const clients = new Set<ServerResponse>();
|
|
314
361
|
const dialogs = new Map<string, any>();
|
|
315
362
|
let subsubState: unknown = null;
|
|
363
|
+
let lastFind: { q?: string } | null = null;
|
|
316
364
|
let busy = false;
|
|
317
365
|
let lastSeen = Date.now();
|
|
318
366
|
let restarting = false;
|
|
@@ -347,6 +395,12 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
347
395
|
subsubState = null;
|
|
348
396
|
}
|
|
349
397
|
broadcast({ type: "subsub_state", state: subsubState });
|
|
398
|
+
} else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
|
|
399
|
+
try {
|
|
400
|
+
lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
|
|
401
|
+
} catch {
|
|
402
|
+
lastFind = null;
|
|
403
|
+
}
|
|
350
404
|
} else if (ev.method === "notify") {
|
|
351
405
|
broadcast(ev);
|
|
352
406
|
}
|
|
@@ -388,13 +442,23 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
388
442
|
out.session = await agent.request({ type: "get_state" });
|
|
389
443
|
lastSession = (out.session as { sessionFile?: string }).sessionFile ?? lastSession;
|
|
390
444
|
out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
|
|
391
|
-
|
|
445
|
+
const commands = ((await agent.request({ type: "get_commands" })) as { commands: Array<Record<string, any>> }).commands;
|
|
446
|
+
// RPC has no argument hints: read them from the prompt files.
|
|
447
|
+
out.commands = commands.map((c) => {
|
|
448
|
+
const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
|
|
449
|
+
const hint = front && field(front, "argument-hint");
|
|
450
|
+
return hint ? { ...c, hint } : c;
|
|
451
|
+
});
|
|
392
452
|
} catch (err) {
|
|
393
453
|
out.error = (err as Error).message;
|
|
394
454
|
}
|
|
395
455
|
return out;
|
|
396
456
|
}
|
|
397
457
|
|
|
458
|
+
async function currentSession(): Promise<string | undefined> {
|
|
459
|
+
return ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile;
|
|
460
|
+
}
|
|
461
|
+
|
|
398
462
|
async function sessions(): Promise<unknown[]> {
|
|
399
463
|
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
400
464
|
const list = await SessionManager.list(cwd);
|
|
@@ -521,7 +585,8 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
521
585
|
const message = String(body.message ?? "").trim();
|
|
522
586
|
if (!message) return send(res, 400, { error: "empty message" });
|
|
523
587
|
try {
|
|
524
|
-
|
|
588
|
+
// A command answers only after its dialogs close (/read, /undo), so wait longer than the default.
|
|
589
|
+
await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) }, 60 * 60_000);
|
|
525
590
|
} catch (err) {
|
|
526
591
|
// No provider yet: the page opens the model dialog instead of showing pi's text.
|
|
527
592
|
if (/no api key|no model|no models available/i.test((err as Error).message)) return send(res, 409, { error: "nomodel" });
|
|
@@ -609,14 +674,54 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
609
674
|
if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
|
|
610
675
|
});
|
|
611
676
|
}
|
|
677
|
+
case "GET /api/notes":
|
|
678
|
+
return send(res, 200, { root: notesRoot, notes: listNotes(notesRoot) });
|
|
679
|
+
case "GET /api/note": {
|
|
680
|
+
const raw = url.searchParams.get("path") ?? "";
|
|
681
|
+
const wiki = !/[\\/]/.test(raw) && !/\.\w+$/.test(raw) ? wikilinkTarget(raw, notesRoot) : undefined;
|
|
682
|
+
// A relative path is tried in the working folder, then in the Sub-Sub folder (Literature/x.md).
|
|
683
|
+
const file = allowedFile(wiki ?? raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
|
|
684
|
+
if (!file) return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
685
|
+
if (!NOTE_TYPES.has(extname(file).toLowerCase())) return send(res, 415, { error: "not a note" });
|
|
686
|
+
if (statSync(file).size > 2_000_000) return send(res, 413, { error: "too large to show" });
|
|
687
|
+
return send(res, 200, { path: file, name: basename(file), text: readFileSync(file, "utf8") });
|
|
688
|
+
}
|
|
689
|
+
case "GET /api/library": {
|
|
690
|
+
const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
691
|
+
if (!q) return send(res, 200, { q, items: [] });
|
|
692
|
+
await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
|
|
693
|
+
return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
|
|
694
|
+
}
|
|
695
|
+
case "POST /api/session/name": {
|
|
696
|
+
const body = await readJson(req);
|
|
697
|
+
const name = String(body.name ?? "").replace(/\s+/g, " ").trim();
|
|
698
|
+
if (!name || name.length > 120) return send(res, 400, { error: "a name of 1 to 120 characters" });
|
|
699
|
+
const known = (await sessions()) as Array<{ path: string }>;
|
|
700
|
+
if (!known.some((x) => x.path === body.path)) return send(res, 404, { error: "no such session" });
|
|
701
|
+
if (body.path === (await currentSession())) await agent.request({ type: "set_session_name", name });
|
|
702
|
+
else {
|
|
703
|
+
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
704
|
+
SessionManager.open(String(body.path)).appendSessionInfo(name);
|
|
705
|
+
}
|
|
706
|
+
return send(res, 200, { ok: true });
|
|
707
|
+
}
|
|
708
|
+
case "POST /api/session/trash": {
|
|
709
|
+
const body = await readJson(req);
|
|
710
|
+
const known = (await sessions()) as Array<{ path: string }>;
|
|
711
|
+
if (!known.some((x) => x.path === body.path)) return send(res, 404, { error: "no such session" });
|
|
712
|
+
if (body.path === (await currentSession())) return send(res, 409, { error: "the open conversation" });
|
|
713
|
+
// Nothing is deleted: the file moves to a trash folder next to the agent folder.
|
|
714
|
+
const trash = join(dirname(agentDir), "trash");
|
|
715
|
+
mkdirSync(trash, { recursive: true });
|
|
716
|
+
renameSync(String(body.path), join(trash, basename(String(body.path))));
|
|
717
|
+
return send(res, 200, { ok: true });
|
|
718
|
+
}
|
|
612
719
|
case "POST /api/open": {
|
|
613
720
|
const body = await readJson(req);
|
|
614
|
-
const target = resolve(cwd, expand(String(body.path ?? "")));
|
|
615
721
|
const vault = cfg.vault;
|
|
616
722
|
// Judge the real file, so a link inside the Sub-Sub folder cannot open a document outside it.
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
if (!inside || !existsSync(target)) return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
723
|
+
const target = allowedFile(String(body.path ?? ""), cwd, vault);
|
|
724
|
+
if (!target) return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
620
725
|
// Documents only: a file that the model wrote must never be run.
|
|
621
726
|
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
727
|
if (vault && within(target, vault) && obsidianRoot(vault)) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
|
|
@@ -44,7 +44,7 @@ Shared rules for Sub-Sub. Sub-Sub reads this file at the start of every session.
|
|
|
44
44
|
- Do not copy the abstract in full. Give page numbers for quotes when the text shows them.
|
|
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
|
-
-
|
|
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;
|
|
@@ -736,7 +740,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
|
|
|
736
740
|
padding: 0.3rem;
|
|
737
741
|
z-index: 5;
|
|
738
742
|
}
|
|
739
|
-
.suggest button { display: flex; align-items: baseline; gap: 0.6rem; width: 100%; text-align: left; padding: 0.38rem 0.55rem; border-radius: 5px; }
|
|
743
|
+
.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
744
|
.suggest button[aria-selected="true"], .suggest button:hover { background: var(--accent-soft); }
|
|
741
745
|
.suggest .name { font-family: var(--mono); font-size: 0.8rem; color: var(--accent); white-space: nowrap; }
|
|
742
746
|
.suggest .desc { color: var(--muted); font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -792,6 +796,10 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
|
|
|
792
796
|
|
|
793
797
|
.modal-buttons { display: flex; justify-content: flex-end; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; }
|
|
794
798
|
.modal-buttons button { min-width: 5.5rem; }
|
|
799
|
+
/* Long choices (the reasons of /read) read better as a list than as a row of buttons. */
|
|
800
|
+
.modal-buttons.choices { flex-direction: column; align-items: stretch; }
|
|
801
|
+
.modal-buttons.choices button { text-align: left; }
|
|
802
|
+
.modal-buttons.choices .ghost { align-self: flex-end; }
|
|
795
803
|
.modal-shortcut-hint { font-size: 0.78rem; color: var(--faint); margin-right: auto; }
|
|
796
804
|
.modal-head { display: flex; justify-content: space-between; align-items: center; }
|
|
797
805
|
.modal input[type="text"], .modal input[type="password"], .modal textarea, .modal select {
|
|
@@ -845,9 +853,58 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
|
|
|
845
853
|
.side .phone-only { display: none; }
|
|
846
854
|
.settings { display: grid; gap: 0.6rem; padding: 0 0.3rem; }
|
|
847
855
|
.settings label { display: grid; gap: 0.25rem; font-size: 0.78rem; color: var(--muted); }
|
|
848
|
-
.settings
|
|
856
|
+
.settings button { width: 100%; text-align: left; color: var(--text); }
|
|
849
857
|
.settings .model-btn { font-family: var(--mono); font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
850
858
|
|
|
859
|
+
/* ---------------------------------------------------------------- library search and notes */
|
|
860
|
+
|
|
861
|
+
#notes-btn { font-family: var(--sans); }
|
|
862
|
+
#notes-btn[aria-expanded="true"] { color: var(--accent); border-color: var(--accent); }
|
|
863
|
+
|
|
864
|
+
.items { list-style: none; margin: 0; padding: 0; max-height: 18rem; overflow-y: auto; }
|
|
865
|
+
.items:empty { display: none; }
|
|
866
|
+
.items > li { padding: 0.4rem 0.5rem; border-radius: 5px; }
|
|
867
|
+
.items > li:hover { background: var(--bg); }
|
|
868
|
+
.items > li.muted { font-size: 0.82rem; }
|
|
869
|
+
.item-title { font-size: 0.86rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
870
|
+
.item-meta { font-size: 0.72rem; color: var(--faint); margin-top: 0.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
871
|
+
.item-actions { display: flex; gap: 0.6rem; margin-top: 0.2rem; }
|
|
872
|
+
.item-actions button, .item-actions a { font-size: 0.76rem; color: var(--accent); padding: 0; text-decoration: none; }
|
|
873
|
+
.item-actions button:hover, .item-actions a:hover { text-decoration: underline; text-underline-offset: 0.18em; }
|
|
874
|
+
|
|
875
|
+
.sessions li { position: relative; }
|
|
876
|
+
.session-tools { position: absolute; top: 0.3rem; right: 0.3rem; display: none; gap: 0.3rem; background: var(--raised); border-radius: 4px; }
|
|
877
|
+
.sessions li:hover .session-tools, .sessions li:focus-within .session-tools { display: flex; }
|
|
878
|
+
/* No hover on a touch screen: the buttons stay under the conversation. */
|
|
879
|
+
@media (hover: none) { .session-tools { display: flex; position: static; justify-content: flex-end; background: none; } }
|
|
880
|
+
.session-tools button { width: auto; padding: 0.1rem 0.35rem; font-size: 0.7rem; color: var(--muted); }
|
|
881
|
+
.session-tools button:hover { color: var(--text); background: var(--bg); }
|
|
882
|
+
|
|
883
|
+
.note-pane {
|
|
884
|
+
grid-row: 2;
|
|
885
|
+
grid-column: 3;
|
|
886
|
+
border-left: 1px solid var(--rule);
|
|
887
|
+
background: var(--surface);
|
|
888
|
+
display: flex;
|
|
889
|
+
flex-direction: column;
|
|
890
|
+
min-height: 0;
|
|
891
|
+
min-width: 0;
|
|
892
|
+
}
|
|
893
|
+
.note-pane[hidden] { display: none; }
|
|
894
|
+
.note-head { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--rule); }
|
|
895
|
+
.note-name { flex: 1; min-width: 0; font-size: 0.84rem; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
896
|
+
.note-pane > .search { margin: 0.6rem 0.9rem 0; width: auto; }
|
|
897
|
+
.note-body { flex: 1; overflow-y: auto; padding: 0.9rem 1.1rem 1.5rem; }
|
|
898
|
+
.note-body .front { font-size: 0.74rem; color: var(--muted); margin: 0 0 1rem; }
|
|
899
|
+
.note-body .front summary { cursor: pointer; }
|
|
900
|
+
.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; }
|
|
901
|
+
.note-list { list-style: none; margin: 0; padding: 0; }
|
|
902
|
+
.note-list button { width: 100%; text-align: left; padding: 0.4rem 0.5rem; border-radius: 5px; display: grid; gap: 0.1rem; }
|
|
903
|
+
.note-list button:hover { background: var(--bg); }
|
|
904
|
+
.note-list .rel { font-size: 0.84rem; overflow-wrap: anywhere; }
|
|
905
|
+
.note-list .when { font-size: 0.72rem; color: var(--faint); }
|
|
906
|
+
a[data-note] { color: var(--accent); text-decoration: underline; text-decoration-color: var(--accent-soft); text-underline-offset: 0.18em; cursor: pointer; }
|
|
907
|
+
|
|
851
908
|
@media (max-width: 64rem) {
|
|
852
909
|
.composer-hint { display: none; }
|
|
853
910
|
}
|
|
@@ -865,6 +922,8 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
|
|
|
865
922
|
box-shadow: var(--shadow);
|
|
866
923
|
}
|
|
867
924
|
.side.open { transform: none; }
|
|
925
|
+
.app.note-open, .app.note-open.side-collapsed { grid-template-columns: minmax(0, 1fr); }
|
|
926
|
+
.note-pane { position: fixed; z-index: 25; inset: 0; border-left: 0; }
|
|
868
927
|
.top { padding: 0.5rem 0.75rem; gap: 0.5rem; }
|
|
869
928
|
.wide-only { display: none !important; }
|
|
870
929
|
.library .library-text { display: none; }
|