md-copilot-viewer 1.0.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/.env.template ADDED
@@ -0,0 +1,12 @@
1
+ # Server port used by `npm run dev` / `npm start` (defaults to 3000)
2
+ PORT=3000
3
+
4
+ # true: include existing .md files at startup, false: only track newly created files after startup
5
+ LOAD_EXISTING_MD=true
6
+
7
+ # Comma-separated quoted path substrings to exclude from results
8
+ # Example: "checkpoints/index.md","tmp/","node_modules/"
9
+ EXCLUDE_PATTERN='"checkpoints/index.md"'
10
+
11
+ # Maximum number of most recently updated files sent to the frontend
12
+ FILE_MAX_LIMIT=200
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Markdown File Viewer for Copilot
2
+ Simple markdown file viewer for Copilot/session notes, with DOCX export.
3
+
4
+ ## Features
5
+
6
+ - Realtime monitoring of `.md` files, so new and updated notes appear automatically.
7
+ - Live markdown preview with a quick file list for fast context switching.
8
+ - Optional DOCX export for sharing notes outside the app.
9
+
10
+ ## How it works
11
+
12
+ 1. A Node watcher tracks markdown file changes (create/update) in realtime.
13
+ 2. The backend serves a capped, recent file list plus rendered markdown content.
14
+ 3. The web UI refreshes the list/preview automatically and supports DOCX export.
15
+
16
+ ## Screenshots
17
+
18
+ ![Screenshot 1 - file list and markdown preview](web/screenshots/screenshot-1.png)
19
+ ![Screenshot 2 - rendered markdown and export](web/screenshots/screenshot-2.png)
20
+
21
+ ## Run
22
+
23
+ ```bash
24
+ npm install
25
+ cp .env.template .env
26
+ npm run dev
27
+ ```
28
+
29
+ `npm run dev` uses `PORT` from `.env` (default `3000`), so open:
30
+ - `http://localhost:3000` (default), or
31
+ - `http://localhost:<your PORT value>`
32
+
33
+ ## `.env` config
34
+
35
+ `.env.template` includes:
36
+
37
+ ```env
38
+ PORT=3000
39
+ LOAD_EXISTING_MD=true
40
+ EXCLUDE_PATTERN='"checkpoints/index.md"'
41
+ FILE_MAX_LIMIT=200
42
+ ```
43
+
44
+ - `PORT`
45
+ Port used by `npm run dev` and `npm start` (default `3000`).
46
+ - `LOAD_EXISTING_MD`
47
+ - `true`: load markdown files that already exist when the app starts.
48
+ - `false`: only watch new markdown files created after startup.
49
+ - `EXCLUDE_PATTERN`
50
+ Comma-separated path substrings to ignore.
51
+ Example: `"checkpoints/index.md","tmp/","node_modules/"`
52
+ - `FILE_MAX_LIMIT`
53
+ Maximum number of most recently updated markdown files returned to the frontend (default `200`).
54
+
55
+ ## API endpoints
56
+
57
+ - `GET /api/files`
58
+ Returns recent markdown files (max `FILE_MAX_LIMIT`) with `id`, display `path`, and `mtimeMs`.
59
+ - `GET /api/files/:id`
60
+ Returns one file as `{ path, markdown, html }`.
61
+ - `GET /api/files/:id/docx`
62
+ Downloads the selected markdown file as `.docx`.
package/dist/cli.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "./index.js";
package/dist/index.js ADDED
@@ -0,0 +1,93 @@
1
+ import express from "express";
2
+ import dotenv from "dotenv";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { markdownToDocx, renderMarkdown } from "./markdown.js";
8
+ import { defaultRoots, MarkdownIndex } from "./watcher.js";
9
+ dotenv.config();
10
+ function parseExcludePatterns(rawValue) {
11
+ if (!rawValue) {
12
+ return [];
13
+ }
14
+ const matches = rawValue.matchAll(/"([^"]+)"/g);
15
+ return [...matches].map((match) => match[1]).filter((item) => item.length > 0);
16
+ }
17
+ function parseFileMaxLimit(rawValue) {
18
+ const parsed = Number(rawValue);
19
+ if (!Number.isFinite(parsed) || parsed < 1) {
20
+ return 200;
21
+ }
22
+ return Math.floor(parsed);
23
+ }
24
+ function toDisplayPath(filePath) {
25
+ const homePath = os.homedir();
26
+ if (filePath === homePath) {
27
+ return "~";
28
+ }
29
+ if (filePath.startsWith(`${homePath}/`)) {
30
+ return `~/${filePath.slice(homePath.length + 1)}`;
31
+ }
32
+ return filePath;
33
+ }
34
+ const app = express();
35
+ const port = Number(process.env.PORT || 3000);
36
+ const loadExistingMd = process.env.LOAD_EXISTING_MD !== "false";
37
+ const excludePatterns = parseExcludePatterns(process.env.EXCLUDE_PATTERN);
38
+ const fileMaxLimit = parseFileMaxLimit(process.env.FILE_MAX_LIMIT);
39
+ const cwd = process.cwd();
40
+ const roots = defaultRoots(cwd);
41
+ const index = new MarkdownIndex(roots, loadExistingMd, excludePatterns);
42
+ const currentFile = fileURLToPath(import.meta.url);
43
+ const webDir = path.resolve(path.dirname(currentFile), "../web");
44
+ app.use(express.static(webDir));
45
+ app.get("/api/files", (_req, res) => {
46
+ res.json(index.list().slice(0, fileMaxLimit).map((entry) => ({
47
+ ...entry,
48
+ path: toDisplayPath(entry.path)
49
+ })));
50
+ });
51
+ app.get("/api/files/:id", async (req, res) => {
52
+ const filePath = index.resolve(req.params.id);
53
+ if (!filePath) {
54
+ res.status(404).json({ error: "File not found" });
55
+ return;
56
+ }
57
+ try {
58
+ const markdown = await fs.readFile(filePath, "utf8");
59
+ res.json({ path: toDisplayPath(filePath), markdown, html: renderMarkdown(markdown) });
60
+ }
61
+ catch (error) {
62
+ res.status(500).json({ error: error.message });
63
+ }
64
+ });
65
+ app.get("/api/files/:id/docx", async (req, res) => {
66
+ const filePath = index.resolve(req.params.id);
67
+ if (!filePath) {
68
+ res.status(404).json({ error: "File not found" });
69
+ return;
70
+ }
71
+ try {
72
+ const markdown = await fs.readFile(filePath, "utf8");
73
+ const buffer = await markdownToDocx(markdown);
74
+ const fileName = `${path.basename(filePath, ".md")}.docx`;
75
+ res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
76
+ res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
77
+ res.send(buffer);
78
+ }
79
+ catch (error) {
80
+ res.status(500).json({ error: error.message });
81
+ }
82
+ });
83
+ async function start() {
84
+ await index.start();
85
+ app.listen(port, () => {
86
+ console.log(`Server running on http://localhost:${port}`);
87
+ console.log(`LOAD_EXISTING_MD=${String(loadExistingMd)}`);
88
+ console.log(`EXCLUDE_PATTERN=${excludePatterns.join(",") || "(none)"}`);
89
+ console.log(`FILE_MAX_LIMIT=${String(fileMaxLimit)}`);
90
+ console.log(`Watching roots:\n- ${roots.join("\n- ")}`);
91
+ });
92
+ }
93
+ void start();
@@ -0,0 +1,67 @@
1
+ import MarkdownIt from "markdown-it";
2
+ import texmath from "markdown-it-texmath";
3
+ import katex from "katex";
4
+ import HTMLtoDOCX from "html-to-docx";
5
+ const md = new MarkdownIt({ html: true, linkify: true, breaks: true })
6
+ .use(texmath, { engine: katex, delimiters: "dollars" });
7
+ const defaultFence = md.renderer.rules.fence;
8
+ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
9
+ const token = tokens[idx];
10
+ const info = (token.info || "").trim();
11
+ if (info === "mermaid") {
12
+ return `<pre class="mermaid">${md.utils.escapeHtml(token.content)}</pre>`;
13
+ }
14
+ if (defaultFence) {
15
+ return defaultFence(tokens, idx, options, env, self);
16
+ }
17
+ return self.renderToken(tokens, idx, options);
18
+ };
19
+ function extractTitle(markdown) {
20
+ const match = markdown.match(/^#\s+(.+)$/m);
21
+ return match ? match[1].trim() : "Document";
22
+ }
23
+ function generateStyledHtml(htmlContent, title) {
24
+ return `<!DOCTYPE html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="UTF-8">
28
+ <meta name="color-scheme" content="light">
29
+ <title>${title}</title>
30
+ <style>
31
+ :root { color-scheme: light; }
32
+ body { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: #333; background: #fff; }
33
+ h1 { font-size: 24pt; margin: 24pt 0 12pt; color: #2c3e50; }
34
+ h2 { font-size: 18pt; margin: 18pt 0 10pt; color: #34495e; }
35
+ h3 { font-size: 14pt; margin: 14pt 0 8pt; }
36
+ p { margin: 0 0 10pt; text-align: justify; }
37
+ ul, ol { margin: 0 0 10pt; padding-left: 20pt; }
38
+ blockquote { margin: 10pt 0; padding-left: 15pt; border-left: 3px solid #3498db; color: #666; font-style: italic; }
39
+ code { font-family: 'Courier New', Courier, monospace; font-size: 10pt; background-color: #f4f4f4; padding: 2pt 4pt; }
40
+ pre { font-family: 'Courier New', Courier, monospace; font-size: 10pt; background-color: #f4f4f4; padding: 10pt; margin: 10pt 0; white-space: pre-wrap; word-wrap: break-word; }
41
+ table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
42
+ th, td { border: 1px solid #ddd; padding: 8pt; text-align: left; }
43
+ th { background-color: #f5f5f5; }
44
+ a { color: #3498db; text-decoration: underline; }
45
+ img { max-width: 100%; height: auto; }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ ${htmlContent}
50
+ </body>
51
+ </html>`;
52
+ }
53
+ export function renderMarkdown(markdown) {
54
+ return md.render(markdown);
55
+ }
56
+ export async function markdownToDocx(markdown) {
57
+ const title = extractTitle(markdown);
58
+ const htmlContent = md.render(markdown);
59
+ const fullHtml = generateStyledHtml(htmlContent, title);
60
+ return HTMLtoDOCX(fullHtml, null, {
61
+ title,
62
+ subject: "Converted from Markdown",
63
+ creator: "Marky-compatible exporter",
64
+ font: "Times New Roman",
65
+ fontSize: 24
66
+ });
67
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,105 @@
1
+ import chokidar from "chokidar";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ function toId(filePath) {
6
+ return Buffer.from(filePath, "utf8").toString("base64url");
7
+ }
8
+ function isMarkdown(filePath) {
9
+ return filePath.toLowerCase().endsWith(".md");
10
+ }
11
+ export class MarkdownIndex {
12
+ roots;
13
+ loadExistingOnStart;
14
+ excludePatterns;
15
+ byPath = new Map();
16
+ byId = new Map();
17
+ watcher;
18
+ constructor(roots, loadExistingOnStart, excludePatterns) {
19
+ this.roots = roots;
20
+ this.loadExistingOnStart = loadExistingOnStart;
21
+ this.excludePatterns = excludePatterns;
22
+ }
23
+ async start() {
24
+ if (this.loadExistingOnStart) {
25
+ await this.seedExisting();
26
+ }
27
+ this.watcher = chokidar.watch(this.roots, {
28
+ ignored: (p) => p.includes("/node_modules/") || p.includes("/.git/"),
29
+ persistent: true,
30
+ ignoreInitial: true,
31
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 }
32
+ });
33
+ this.watcher.on("add", (filePath) => void this.upsert(filePath));
34
+ this.watcher.on("change", (filePath) => void this.upsert(filePath));
35
+ this.watcher.on("unlink", (filePath) => this.remove(filePath));
36
+ }
37
+ async seedExisting() {
38
+ await Promise.all(this.roots.map((root) => this.walk(root)));
39
+ }
40
+ async walk(dir) {
41
+ let entries;
42
+ try {
43
+ entries = await fs.readdir(dir, { withFileTypes: true });
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ await Promise.all(entries.map(async (entry) => {
49
+ const fullPath = path.join(dir, entry.name);
50
+ if (entry.isDirectory()) {
51
+ if (entry.name === "node_modules" || entry.name === ".git") {
52
+ return;
53
+ }
54
+ await this.walk(fullPath);
55
+ return;
56
+ }
57
+ if (entry.isFile() && isMarkdown(fullPath)) {
58
+ await this.upsert(fullPath);
59
+ }
60
+ }));
61
+ }
62
+ async upsert(filePath) {
63
+ if (!isMarkdown(filePath)) {
64
+ return;
65
+ }
66
+ if (this.isExcluded(filePath)) {
67
+ this.remove(filePath);
68
+ return;
69
+ }
70
+ try {
71
+ const stat = await fs.stat(filePath);
72
+ if (!stat.isFile()) {
73
+ return;
74
+ }
75
+ const id = toId(filePath);
76
+ const entry = { id, path: filePath, mtimeMs: stat.mtimeMs };
77
+ this.byPath.set(filePath, entry);
78
+ this.byId.set(id, filePath);
79
+ }
80
+ catch {
81
+ this.remove(filePath);
82
+ }
83
+ }
84
+ remove(filePath) {
85
+ const existing = this.byPath.get(filePath);
86
+ if (!existing) {
87
+ return;
88
+ }
89
+ this.byPath.delete(filePath);
90
+ this.byId.delete(existing.id);
91
+ }
92
+ list() {
93
+ return [...this.byPath.values()].sort((a, b) => b.mtimeMs - a.mtimeMs);
94
+ }
95
+ resolve(id) {
96
+ return this.byId.get(id);
97
+ }
98
+ isExcluded(filePath) {
99
+ const normalizedPath = filePath.replaceAll("\\", "/");
100
+ return this.excludePatterns.some((pattern) => normalizedPath.includes(pattern));
101
+ }
102
+ }
103
+ export function defaultRoots(cwd) {
104
+ return [cwd, path.join(os.homedir(), ".copilot")];
105
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "md-copilot-viewer",
3
+ "version": "1.0.0",
4
+ "description": "Watch markdown files and view them in a browser with DOCX export",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "md-copilot-viewer": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "web",
13
+ ".env.template",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "start": "node dist/index.js",
20
+ "dev": "tsx src/index.ts",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "chokidar": "^4.0.3",
25
+ "dotenv": "^16.4.7",
26
+ "express": "^4.21.2",
27
+ "html-to-docx": "^1.8.0",
28
+ "katex": "^0.16.25",
29
+ "markdown-it": "^14.1.0",
30
+ "markdown-it-texmath": "^1.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/express": "^4.17.21",
34
+ "@types/markdown-it": "^14.1.2",
35
+ "@types/node": "^22.13.10",
36
+ "tsx": "^4.19.3",
37
+ "typescript": "^5.8.2"
38
+ }
39
+ }
package/web/app.js ADDED
@@ -0,0 +1,109 @@
1
+ const fileListEl = document.getElementById("file-list");
2
+ const searchInputEl = document.getElementById("file-search");
3
+ const previewEl = document.getElementById("preview");
4
+ const currentFileEl = document.getElementById("current-file");
5
+ const downloadBtn = document.getElementById("download-docx");
6
+
7
+ let files = [];
8
+ let selectedId = null;
9
+ let searchQuery = "";
10
+ let initialized = false;
11
+ const knownIds = new Set();
12
+ const unreadIds = new Set();
13
+
14
+ function renderList() {
15
+ fileListEl.innerHTML = "";
16
+ const query = searchQuery.toLowerCase();
17
+ const visibleFiles = query ? files.filter((file) => file.path.toLowerCase().includes(query)) : files;
18
+ for (const file of visibleFiles) {
19
+ const li = document.createElement("li");
20
+ li.className = selectedId === file.id ? "active" : "";
21
+ const label = document.createElement("span");
22
+ label.className = "file-item-label";
23
+ label.textContent = `${file.path} (${new Date(file.mtimeMs).toLocaleString()})`;
24
+ li.appendChild(label);
25
+ if (unreadIds.has(file.id)) {
26
+ const dot = document.createElement("span");
27
+ dot.className = "unread-dot";
28
+ dot.setAttribute("aria-label", "New file");
29
+ li.appendChild(dot);
30
+ }
31
+ li.addEventListener("click", () => selectFile(file.id));
32
+ fileListEl.appendChild(li);
33
+ }
34
+ }
35
+
36
+ async function refreshFiles() {
37
+ const res = await fetch("/api/files");
38
+ files = await res.json();
39
+ const currentIds = new Set(files.map((f) => f.id));
40
+ if (!initialized) {
41
+ for (const file of files) {
42
+ knownIds.add(file.id);
43
+ }
44
+ initialized = true;
45
+ } else {
46
+ for (const file of files) {
47
+ if (!knownIds.has(file.id)) {
48
+ unreadIds.add(file.id);
49
+ knownIds.add(file.id);
50
+ }
51
+ }
52
+ }
53
+ for (const id of [...unreadIds]) {
54
+ if (!currentIds.has(id)) {
55
+ unreadIds.delete(id);
56
+ }
57
+ }
58
+ if (!selectedId && files.length > 0) {
59
+ selectedId = files[0].id;
60
+ }
61
+ if (selectedId && !files.some((f) => f.id === selectedId)) {
62
+ selectedId = files[0]?.id ?? null;
63
+ }
64
+ renderList();
65
+ if (selectedId) {
66
+ await loadPreview(selectedId);
67
+ } else {
68
+ previewEl.innerHTML = "<p>No markdown files found yet.</p>";
69
+ currentFileEl.textContent = "";
70
+ downloadBtn.disabled = true;
71
+ }
72
+ }
73
+
74
+ async function loadPreview(id) {
75
+ const res = await fetch(`/api/files/${encodeURIComponent(id)}`);
76
+ if (!res.ok) {
77
+ previewEl.textContent = "Failed to load file.";
78
+ return;
79
+ }
80
+ const data = await res.json();
81
+ selectedId = id;
82
+ unreadIds.delete(id);
83
+ renderList();
84
+ previewEl.innerHTML = data.html;
85
+ currentFileEl.textContent = data.path;
86
+ downloadBtn.disabled = false;
87
+ const mermaid = window.__mermaid;
88
+ if (mermaid) {
89
+ mermaid.initialize({ startOnLoad: false });
90
+ await mermaid.run({ nodes: previewEl.querySelectorAll(".mermaid") });
91
+ }
92
+ }
93
+
94
+ async function selectFile(id) {
95
+ await loadPreview(id);
96
+ }
97
+
98
+ downloadBtn.addEventListener("click", () => {
99
+ if (!selectedId) return;
100
+ window.location.href = `/api/files/${encodeURIComponent(selectedId)}/docx`;
101
+ });
102
+
103
+ searchInputEl.addEventListener("input", (event) => {
104
+ searchQuery = event.target.value;
105
+ renderList();
106
+ });
107
+
108
+ refreshFiles();
109
+ setInterval(refreshFiles, 2000);
package/web/index.html ADDED
@@ -0,0 +1,31 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Markdown Copilot Viewer</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.25/dist/katex.min.css" />
9
+ </head>
10
+ <body>
11
+ <div class="app">
12
+ <aside class="sidebar">
13
+ <h2>Markdown files</h2>
14
+ <input id="file-search" type="text" placeholder="Search files..." aria-label="Search markdown files" />
15
+ <ul id="file-list"></ul>
16
+ </aside>
17
+ <main class="content">
18
+ <div class="toolbar">
19
+ <button id="download-docx" disabled>Download as DOCX</button>
20
+ <span id="current-file"></span>
21
+ </div>
22
+ <article id="preview"></article>
23
+ </main>
24
+ </div>
25
+ <script type="module" src="/app.js"></script>
26
+ <script type="module">
27
+ import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
28
+ window.__mermaid = mermaid;
29
+ </script>
30
+ </body>
31
+ </html>
Binary file
Binary file
package/web/styles.css ADDED
@@ -0,0 +1,18 @@
1
+ * { box-sizing: border-box; }
2
+ body { margin: 0; font-family: Arial, sans-serif; color: #1a1a1a; background: #fff; }
3
+ .app { display: grid; grid-template-columns: 320px 1fr; height: 100vh; }
4
+ .sidebar { border-right: 1px solid #ddd; padding: 12px; overflow: auto; }
5
+ .sidebar h2 { margin: 0 0 12px; font-size: 18px; }
6
+ #file-search { width: 100%; margin: 0 0 10px; padding: 8px; border: 1px solid #ccc; border-radius: 6px; }
7
+ #file-list { list-style: none; padding: 0; margin: 0; }
8
+ #file-list li { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; }
9
+ #file-list li:hover, #file-list li.active { background: #eef4ff; }
10
+ .file-item-label { overflow-wrap: anywhere; flex: 1; }
11
+ .unread-dot { width: 9px; height: 9px; border-radius: 50%; background: #8be28b; flex: 0 0 auto; }
12
+ .content { display: flex; flex-direction: column; min-width: 0; }
13
+ .toolbar { display: flex; align-items: center; gap: 12px; padding: 12px; border-bottom: 1px solid #ddd; }
14
+ #preview { padding: 20px; overflow: auto; }
15
+ #preview pre { background: #f5f5f5; padding: 10px; border-radius: 6px; overflow: auto; }
16
+ #preview table { border-collapse: collapse; width: 100%; }
17
+ #preview th, #preview td { border: 1px solid #ccc; padding: 8px; }
18
+ #preview .mermaid { background: #fff; }