hmr-serve 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -1,14 +1,17 @@
1
1
  # hmr-serve
2
2
 
3
- <!-- Add once CI is green:
4
- [![CI](https://github.com/<owner>/hmr-serve/actions/workflows/ci.yml/badge.svg)](https://github.com/<owner>/hmr-serve/actions/workflows/ci.yml)
5
- -->
3
+ [![CI](https://github.com/floodtide/hmr-serve/actions/workflows/ci.yml/badge.svg)](https://github.com/floodtide/hmr-serve/actions/workflows/ci.yml)
4
+
5
+ <p align="center">
6
+ <img src="docs/screenshot.png" width="900" alt="hmr-serve in the browser: a VSCode-style file tree on the left and a live, rendered Markdown pane on the right" />
7
+ </p>
8
+
9
+ HMR-serve is inspired by `npm run dev` and by [`serve`](https://www.npmjs.com/package/serve). It turns any folder into a hot-loading web app, because sometimes it's nice to preview HTML, Markdown, images and other files in the browser.
6
10
 
7
11
  Point `hmr-serve` at any folder and review it live in the browser: a VSCode-style
8
- file tree on the left, a content pane on the right, and **instant updates as files
9
- change on disk** — powered by Vite's real dev server and
10
- [HMR](https://vitejs.dev/guide/features.html#hot-module-replacement) client, not a
11
- hand-rolled watcher/websocket.
12
+ file tree on the left, a content pane on the right and **instant updates as files
13
+ change on disk**, powered by Vite's real dev server and
14
+ [HMR](https://vitejs.dev/guide/features.html#hot-module-replacement) client. 🔥
12
15
 
13
16
  **HMR** (Hot Module Replacement) is the mechanism that pushes file changes into the
14
17
  browser over a WebSocket so the page updates without a full refresh. This project
@@ -24,8 +27,7 @@ npx hmr-serve ./some-folder
24
27
  # → opens http://localhost:5183 with a live tree + content pane
25
28
  ```
26
29
 
27
- Or run it like **[serve](https://www.npmjs.com/package/serve)** without the nav — serves directory
28
- listing or `index.html` if present, plus rendered Markdown, CSV/TSV, all with HMR:
30
+ Or run it like **[serve](https://www.npmjs.com/package/serve)** without the nav, which serves a directory listing or `index.html` if present, plus rendered Markdown, CSV/TSV, all with HMR:
29
31
 
30
32
  ```bash
31
33
  npx hmr-serve ./some-folder --no-nav
@@ -51,11 +53,11 @@ hmr [dir] Folder to serve (default: current directory)
51
53
  **Default (nav) mode** shows a VS Code-style file tree and content pane. Supported
52
54
  types: **Markdown** (rendered), **HTML** (sandboxed iframe), **code** (`.css`,
53
55
  `.js`, `.ts`, `.json`, … syntax-highlighted), **text**, **CSV/TSV** (tables),
54
- **images**, and **video**. A gear menu toggles which categories show in the tree,
56
+ **images** and **video**. A gear menu toggles which categories show in the tree,
55
57
  plus an **Other files** option. Choices are remembered per folder (localStorage).
56
58
 
57
59
  **`--no-nav`** skips the tree UI: if the folder has `index.html` (or `index.htm`),
58
- that is the root page; otherwise you get a directory listing. Markdown, CSV, and
60
+ that is the root page; otherwise you get a directory listing. Markdown, CSV and
59
61
  TSV are still rendered to HTML, and edits hot-reload via Vite.
60
62
 
61
63
  ## Architecture
@@ -83,7 +85,7 @@ so **relative** links and assets inside a framed doc (`href="contact.html"`,
83
85
  `<img src="logo.png">`) resolve against the real folder layout. The selected file
84
86
  is reflected in a clean, bookmarkable URL under the served folder's name — e.g.
85
87
  serving `./aurora-notes` gives `/aurora-notes/docs/intro.md`. These are real
86
- History-API paths (no `#`): shareable, reload-safe, and back/forward works.
88
+ History-API paths (no `#`): shareable, reload-safe and back/forward works.
87
89
  Unknown paths 404 rather than rendering the app, so a stray absolute link can't
88
90
  nest the shell inside the content pane.
89
91
 
@@ -21,6 +21,7 @@ function paint(code, text, enabled) {
21
21
  }
22
22
  /** Strip ANSI escapes so padding uses visible width. */
23
23
  function visibleLength(text) {
24
+ // eslint-disable-next-line no-control-regex -- ESC (\x1b) is the ANSI escape we strip
24
25
  return text.replace(/\x1b\[[0-9;]*m/g, '').length;
25
26
  }
26
27
  /** Vite-style URL: cyan, with the port bolded. */
@@ -1 +1 @@
1
- {"version":3,"file":"banner.js","sourceRoot":"","sources":["../../src/cli/banner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"banner.js","sourceRoot":"","sources":["../../src/cli/banner.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,41 @@
1
1
  import MarkdownIt from 'markdown-it';
2
2
  import hljs from 'highlight.js';
3
3
  import { createRequire } from 'node:module';
4
- import { basename } from 'node:path';
4
+ import { basename, posix } from 'node:path';
5
+ /** Where the server streams raw file bytes (mirror of the plugin's raw route). */
6
+ const RAW_BASE = '/__hmrserve/raw/';
7
+ /**
8
+ * Rendered markdown is injected into the app-shell DOM (not an iframe), so a
9
+ * relative `<img src="pic.png">` would resolve against the shell URL and 404.
10
+ * Rewrite relative asset URLs to the raw endpoint, resolved against the markdown
11
+ * file's own directory. Absolute/external/anchor/data URLs are left untouched,
12
+ * and `href` (links) is deliberately not rewritten so in-app navigation still works.
13
+ */
14
+ function resolveAssetUrl(mdDir, value) {
15
+ const v = value.trim();
16
+ if (!v)
17
+ return value;
18
+ // scheme: (http:, data:, …), protocol-relative //, root-absolute /, or #anchor.
19
+ if (/^[a-z][a-z0-9+.-]*:/i.test(v) || /^(\/\/|\/|#)/.test(v))
20
+ return value;
21
+ const cut = v.search(/[?#]/);
22
+ const pathPart = cut >= 0 ? v.slice(0, cut) : v;
23
+ const suffix = cut >= 0 ? v.slice(cut) : '';
24
+ const resolved = posix.normalize(posix.join(mdDir, pathPart));
25
+ // Never point outside the served root.
26
+ if (resolved.startsWith('..'))
27
+ return value;
28
+ const encoded = resolved.split('/').map(encodeURIComponent).join('/');
29
+ return `${RAW_BASE}${encoded}${suffix}`;
30
+ }
31
+ const ASSET_ATTR_RE = /\b(src|poster)=(?:"([^"]*)"|'([^']*)')/gi;
32
+ function rewriteRelativeAssets(html, mdDir) {
33
+ return html.replace(ASSET_ATTR_RE, (match, attr, dq, sq) => {
34
+ const value = dq ?? sq ?? '';
35
+ const next = resolveAssetUrl(mdDir, value);
36
+ return next === value ? match : `${attr}="${next}"`;
37
+ });
38
+ }
5
39
  const require = createRequire(import.meta.url);
6
40
  const taskLists = require('markdown-it-task-lists');
7
41
  const md = new MarkdownIt({
@@ -34,9 +68,10 @@ export const markdownRenderer = {
34
68
  extensions: ['md', 'markdown'],
35
69
  category: 'markdown',
36
70
  render({ raw, path }) {
71
+ const mdDir = posix.dirname(path); // '.' at the root
37
72
  return {
38
73
  kind: 'html',
39
- html: md.render(raw),
74
+ html: rewriteRelativeAssets(md.render(raw), mdDir === '.' ? '' : mdDir),
40
75
  title: deriveTitle(raw, path),
41
76
  };
42
77
  },
@@ -1 +1 @@
1
- {"version":3,"file":"markdown.js","sourceRoot":"","sources":["../../../src/server/renderers/markdown.ts"],"names":[],"mappings":"AAAA,OAAO,UAAU,MAAM,aAAa,CAAC;AACrC,OAAO,IAAI,MAAM,cAAc,CAAC;AAChC,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAGrC,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,wBAAwB,CAA4C,CAAC;AAE/F,MAAM,EAAE,GAAG,IAAI,UAAU,CAAC;IACxB,IAAI,EAAE,IAAI;IACV,OAAO,EAAE,IAAI;IACb,WAAW,EAAE,IAAI;IACjB,6EAA6E;IAC7E,8EAA8E;IAC9E,wDAAwD;IACxD,SAAS,CAAC,GAAG,EAAE,IAAI;QACjB,IAAI,IAAI,IAAI,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;YACnC,IAAI,CAAC;gBACH,OAAO,2BAA2B,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,eAAe,CAAC;YACjG,CAAC;YAAC,MAAM,CAAC;gBACP,mCAAmC;YACrC,CAAC;QACH,CAAC;QACD,OAAO,EAAE,CAAC;IACZ,CAAC;CACF,CAAC,CAAC,GAAG,CAAC,SAAS,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;AAEpD,yDAAyD;AACzD,SAAS,WAAW,CAAC,GAAW,EAAE,IAAY;IAC5C,MAAM,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,2BAA2B,CAAC,CAAC;IACnD,IAAI,GAAG,EAAE,CAAC,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACnC,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,MAAM,CAAC,MAAM,gBAAgB,GAAa;IACxC,UAAU,EAAE,CAAC,IAAI,EAAE,UAAU,CAAC;IAC9B,QAAQ,EAAE,UAAU;IACpB,MAAM,CAAC,EAAE,GAAG,EAAE,IAAI,EAAe;QAC/B,OAAO;YACL,IAAI,EAAE,MAAM;YACZ,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC;YACpB,KAAK,EAAE,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC;SAC9B,CAAC;IACJ,CAAC;CACF,CAAC"}
1
+ {"version":3,"file":"markdown.js","sourceRoot":"","sources":["../../../src/server/renderers/markdown.ts"],"names":[],"mappings":"AAAA,OAAO,UAAU,MAAM,aAAa,CAAC;AACrC,OAAO,IAAI,MAAM,cAAc,CAAC;AAChC,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAG5C,kFAAkF;AAClF,MAAM,QAAQ,GAAG,kBAAkB,CAAC;AAEpC;;;;;;GAMG;AACH,SAAS,eAAe,CAAC,KAAa,EAAE,KAAa;IACnD,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IACvB,IAAI,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACrB,gFAAgF;IAChF,IAAI,sBAAsB,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IAC3E,MAAM,GAAG,GAAG,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC7B,MAAM,QAAQ,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAChD,MAAM,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAC5C,MAAM,QAAQ,GAAG,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC9D,uCAAuC;IACvC,IAAI,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAC5C,MAAM,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACtE,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,EAAE,CAAC;AAC1C,CAAC;AAED,MAAM,aAAa,GAAG,0CAA0C,CAAC;AAEjE,SAAS,qBAAqB,CAAC,IAAY,EAAE,KAAa;IACxD,OAAO,IAAI,CAAC,OAAO,CAAC,aAAa,EAAE,CAAC,KAAK,EAAE,IAAY,EAAE,EAAW,EAAE,EAAW,EAAE,EAAE;QACnF,MAAM,KAAK,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;QAC7B,MAAM,IAAI,GAAG,eAAe,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAC3C,OAAO,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,KAAK,IAAI,GAAG,CAAC;IACtD,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,wBAAwB,CAA4C,CAAC;AAE/F,MAAM,EAAE,GAAG,IAAI,UAAU,CAAC;IACxB,IAAI,EAAE,IAAI;IACV,OAAO,EAAE,IAAI;IACb,WAAW,EAAE,IAAI;IACjB,6EAA6E;IAC7E,8EAA8E;IAC9E,wDAAwD;IACxD,SAAS,CAAC,GAAG,EAAE,IAAI;QACjB,IAAI,IAAI,IAAI,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;YACnC,IAAI,CAAC;gBACH,OAAO,2BAA2B,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,eAAe,CAAC;YACjG,CAAC;YAAC,MAAM,CAAC;gBACP,mCAAmC;YACrC,CAAC;QACH,CAAC;QACD,OAAO,EAAE,CAAC;IACZ,CAAC;CACF,CAAC,CAAC,GAAG,CAAC,SAAS,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;AAEpD,yDAAyD;AACzD,SAAS,WAAW,CAAC,GAAW,EAAE,IAAY;IAC5C,MAAM,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,2BAA2B,CAAC,CAAC;IACnD,IAAI,GAAG,EAAE,CAAC,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACnC,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,MAAM,CAAC,MAAM,gBAAgB,GAAa;IACxC,UAAU,EAAE,CAAC,IAAI,EAAE,UAAU,CAAC;IAC9B,QAAQ,EAAE,UAAU;IACpB,MAAM,CAAC,EAAE,GAAG,EAAE,IAAI,EAAe;QAC/B,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,kBAAkB;QACrD,OAAO;YACL,IAAI,EAAE,MAAM;YACZ,IAAI,EAAE,qBAAqB,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;YACvE,KAAK,EAAE,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC;SAC9B,CAAC;IACJ,CAAC;CACF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmr-serve",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Point at any folder and review it live in the browser — VSCode-style file tree + content pane, powered by Vite's dev server and HMR client.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,11 @@
1
1
  <template>
2
2
  <div class="app">
3
- <aside class="sidebar" :class="{ 'is-collapsed': sidebarCollapsed }" data-testid="sidebar">
3
+ <aside
4
+ class="sidebar"
5
+ :class="{ 'is-collapsed': sidebarCollapsed }"
6
+ :style="sidebarStyle"
7
+ data-testid="sidebar"
8
+ >
4
9
  <div class="sidebar-header">
5
10
  <span v-if="!sidebarCollapsed" class="brand">hmr-serve</span>
6
11
  <div class="sidebar-actions">
@@ -9,8 +14,10 @@
9
14
  :categories="categories"
10
15
  :visible="visibleCategories"
11
16
  :show-other-files="showOtherFiles"
17
+ :theme="theme"
12
18
  @change="onCategoriesChange"
13
19
  @update:show-other-files="onShowOtherFilesChange"
20
+ @update:theme="onThemeChange"
14
21
  />
15
22
  <button
16
23
  type="button"
@@ -50,17 +57,18 @@
50
57
  <li v-if="visibleHits.length === 0" class="empty" data-testid="search-empty">
51
58
  No matches
52
59
  </li>
53
- <li
54
- v-for="hit in visibleHits"
55
- :key="hit.path"
56
- class="search-hit"
57
- data-testid="search-hit"
58
- :data-path="hit.path"
59
- :class="{ selected: hit.path === selectedPath }"
60
- @click="select(hit.path)"
61
- >
62
- <span class="hit-title">{{ hit.title }}</span>
63
- <span class="hit-snippet">{{ hit.snippet }}</span>
60
+ <li v-for="hit in visibleHits" :key="hit.path" class="search-hit-item">
61
+ <a
62
+ class="search-hit"
63
+ data-testid="search-hit"
64
+ :data-path="hit.path"
65
+ :href="hrefFor(hit.path)"
66
+ :class="{ selected: hit.path === selectedPath }"
67
+ @click="onHitClick($event, hit.path)"
68
+ >
69
+ <span class="hit-title">{{ hit.title }}</span>
70
+ <span class="hit-snippet">{{ hit.snippet }}</span>
71
+ </a>
64
72
  </li>
65
73
  </ul>
66
74
  <file-tree
@@ -73,6 +81,19 @@
73
81
  </div>
74
82
  </template>
75
83
  </aside>
84
+ <div
85
+ v-if="!sidebarCollapsed"
86
+ class="sidebar-resizer"
87
+ :class="{ active: resizing }"
88
+ data-testid="sidebar-resizer"
89
+ role="separator"
90
+ aria-orientation="vertical"
91
+ aria-label="Resize sidebar"
92
+ tabindex="0"
93
+ :aria-valuenow="sidebarWidth"
94
+ @pointerdown="onResizeStart"
95
+ @keydown="onResizeKey"
96
+ ></div>
76
97
  <main class="main">
77
98
  <content-pane
78
99
  :path="selectedPath"
@@ -89,15 +110,20 @@ import { defineComponent } from 'vue';
89
110
  import type { CategoryInfo, FileCategory, FileNode, SearchHit } from '../shared/types';
90
111
  import { fetchConfig, fetchTree, search } from './api';
91
112
  import { onFileUpdate } from './hmr';
92
- import { readPath, writePath } from './router';
113
+ import { hrefFor, isPlainNavClick, readPath, writePath } from './router';
93
114
  import {
115
+ SIDEBAR_DEFAULT_WIDTH,
116
+ clampSidebarWidth,
94
117
  loadShowOtherFiles,
95
118
  loadSidebarCollapsed,
119
+ loadSidebarWidth,
96
120
  loadVisibleCategories,
97
121
  saveShowOtherFiles,
98
122
  saveSidebarCollapsed,
123
+ saveSidebarWidth,
99
124
  saveVisibleCategories,
100
125
  } from './settings';
126
+ import { applyTheme, loadTheme, saveTheme, type Theme } from './theme';
101
127
  import FileTree from './components/FileTree.vue';
102
128
  import ContentPane from './components/ContentPane.vue';
103
129
  import SearchBox from './components/SearchBox.vue';
@@ -111,7 +137,10 @@ interface AppData {
111
137
  categories: CategoryInfo[];
112
138
  visibleCategories: FileCategory[];
113
139
  showOtherFiles: boolean;
140
+ theme: Theme;
114
141
  sidebarCollapsed: boolean;
142
+ sidebarWidth: number;
143
+ resizing: boolean;
115
144
  mount: string;
116
145
  searchTimer: ReturnType<typeof setTimeout> | null;
117
146
  unsubscribe: (() => void) | null;
@@ -158,13 +187,20 @@ export default defineComponent({
158
187
  categories: [],
159
188
  visibleCategories: [],
160
189
  showOtherFiles: false,
190
+ theme: loadTheme(),
161
191
  sidebarCollapsed: false,
192
+ sidebarWidth: SIDEBAR_DEFAULT_WIDTH,
193
+ resizing: false,
162
194
  mount: window.__HMR_SERVE_MOUNT__ ?? '',
163
195
  searchTimer: null,
164
196
  unsubscribe: null,
165
197
  };
166
198
  },
167
199
  computed: {
200
+ // Inline width for the expanded sidebar; collapsed state is CSS-driven.
201
+ sidebarStyle(): Record<string, string> {
202
+ return this.sidebarCollapsed ? {} : { width: `${this.sidebarWidth}px` };
203
+ },
168
204
  // Every extension the server can preview — used to spot "other" files.
169
205
  supportedExtensions(): string[] {
170
206
  return this.categories.flatMap((c) => c.extensions);
@@ -206,6 +242,7 @@ export default defineComponent({
206
242
  );
207
243
  this.showOtherFiles = loadShowOtherFiles(this.mount);
208
244
  this.sidebarCollapsed = loadSidebarCollapsed(this.mount);
245
+ this.sidebarWidth = loadSidebarWidth(this.mount);
209
246
  // Structural changes (add/unlink) reshape the tree; a change may alter a
210
247
  // file's searchable text. Refresh both so the nav and results stay live.
211
248
  this.unsubscribe = onFileUpdate((update) => {
@@ -238,15 +275,69 @@ export default defineComponent({
238
275
  this.showOtherFiles = show;
239
276
  saveShowOtherFiles(this.mount, show);
240
277
  },
278
+ onThemeChange(theme: Theme): void {
279
+ this.theme = theme;
280
+ saveTheme(theme);
281
+ applyTheme(theme);
282
+ },
241
283
  toggleSidebar(): void {
242
284
  this.sidebarCollapsed = !this.sidebarCollapsed;
243
285
  saveSidebarCollapsed(this.mount, this.sidebarCollapsed);
244
286
  },
287
+ // Drag-to-resize the sidebar. Width tracks the pointer live; the final value
288
+ // is clamped and persisted on release. Pointer capture + a body class keep
289
+ // the drag smooth even when the cursor moves over the content iframe.
290
+ onResizeStart(event: PointerEvent): void {
291
+ if (this.sidebarCollapsed) return;
292
+ event.preventDefault();
293
+ this.resizing = true;
294
+ document.body.classList.add('is-resizing');
295
+ const handle = event.currentTarget as HTMLElement;
296
+ handle.setPointerCapture(event.pointerId);
297
+ handle.addEventListener('pointermove', this.onResizeMove);
298
+ handle.addEventListener('pointerup', this.onResizeEnd);
299
+ handle.addEventListener('pointercancel', this.onResizeEnd);
300
+ },
301
+ onResizeMove(event: PointerEvent): void {
302
+ if (!this.resizing) return;
303
+ // Sidebar hugs the left edge, so its width is the pointer's x position.
304
+ this.sidebarWidth = clampSidebarWidth(event.clientX);
305
+ },
306
+ onResizeEnd(event: PointerEvent): void {
307
+ if (!this.resizing) return;
308
+ this.resizing = false;
309
+ document.body.classList.remove('is-resizing');
310
+ const handle = event.currentTarget as HTMLElement;
311
+ handle.releasePointerCapture?.(event.pointerId);
312
+ handle.removeEventListener('pointermove', this.onResizeMove);
313
+ handle.removeEventListener('pointerup', this.onResizeEnd);
314
+ handle.removeEventListener('pointercancel', this.onResizeEnd);
315
+ saveSidebarWidth(this.mount, this.sidebarWidth);
316
+ },
317
+ // Keyboard resize for accessibility (left/right arrows on the handle).
318
+ onResizeKey(event: KeyboardEvent): void {
319
+ const step = event.shiftKey ? 32 : 8;
320
+ let next = this.sidebarWidth;
321
+ if (event.key === 'ArrowLeft') next -= step;
322
+ else if (event.key === 'ArrowRight') next += step;
323
+ else return;
324
+ event.preventDefault();
325
+ this.sidebarWidth = clampSidebarWidth(next);
326
+ saveSidebarWidth(this.mount, this.sidebarWidth);
327
+ },
328
+ hrefFor,
245
329
  // User-initiated selection (tree / search hit): a normal history push.
246
330
  select(path: string): void {
247
331
  this.selectedPath = path;
248
332
  writePath(path);
249
333
  },
334
+ // Search hits are anchors too — plain click navigates in-app; modified/middle
335
+ // clicks fall through so the browser opens a new tab/window.
336
+ onHitClick(event: MouseEvent, path: string): void {
337
+ if (!isPlainNavClick(event)) return;
338
+ event.preventDefault();
339
+ this.select(path);
340
+ },
250
341
  // Mirror a navigation the iframe made itself (an in-page link click): the
251
342
  // iframe already added a history entry, so replace rather than push to keep
252
343
  // one entry per navigation.
@@ -50,6 +50,59 @@
50
50
  <button type="button" @click="setAll(true)">All</button>
51
51
  <button type="button" @click="setAll(false)">None</button>
52
52
  </div>
53
+ <div class="settings-divider" role="separator"></div>
54
+ <div class="settings-title">Appearance</div>
55
+ <div class="settings-actions theme-actions">
56
+ <button
57
+ type="button"
58
+ data-testid="theme-dark"
59
+ aria-label="Dark mode"
60
+ title="Dark"
61
+ :aria-pressed="theme === 'dark'"
62
+ :class="{ active: theme === 'dark' }"
63
+ @click="setTheme('dark')"
64
+ >
65
+ <svg
66
+ viewBox="0 0 24 24"
67
+ width="16"
68
+ height="16"
69
+ fill="none"
70
+ stroke="currentColor"
71
+ stroke-width="2"
72
+ stroke-linecap="round"
73
+ stroke-linejoin="round"
74
+ aria-hidden="true"
75
+ >
76
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
77
+ </svg>
78
+ </button>
79
+ <button
80
+ type="button"
81
+ data-testid="theme-light"
82
+ aria-label="Light mode"
83
+ title="Light"
84
+ :aria-pressed="theme === 'light'"
85
+ :class="{ active: theme === 'light' }"
86
+ @click="setTheme('light')"
87
+ >
88
+ <svg
89
+ viewBox="0 0 24 24"
90
+ width="16"
91
+ height="16"
92
+ fill="none"
93
+ stroke="currentColor"
94
+ stroke-width="2"
95
+ stroke-linecap="round"
96
+ stroke-linejoin="round"
97
+ aria-hidden="true"
98
+ >
99
+ <circle cx="12" cy="12" r="4" />
100
+ <path
101
+ d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
102
+ />
103
+ </svg>
104
+ </button>
105
+ </div>
53
106
  </div>
54
107
  </div>
55
108
  </template>
@@ -57,6 +110,7 @@
57
110
  <script lang="ts">
58
111
  import { defineComponent, type PropType } from 'vue';
59
112
  import type { CategoryInfo, FileCategory } from '../../shared/types';
113
+ import type { Theme } from '../theme';
60
114
 
61
115
  export default defineComponent({
62
116
  name: 'SettingsMenu',
@@ -64,10 +118,12 @@ export default defineComponent({
64
118
  categories: { type: Array as PropType<CategoryInfo[]>, required: true },
65
119
  visible: { type: Array as PropType<FileCategory[]>, required: true },
66
120
  showOtherFiles: { type: Boolean, default: false },
121
+ theme: { type: String as PropType<Theme>, default: 'dark' },
67
122
  },
68
123
  emits: {
69
124
  change: (value: FileCategory[]) => Array.isArray(value),
70
125
  'update:showOtherFiles': (value: boolean) => typeof value === 'boolean',
126
+ 'update:theme': (value: Theme) => value === 'dark' || value === 'light',
71
127
  },
72
128
  data() {
73
129
  return { open: false };
@@ -92,6 +148,9 @@ export default defineComponent({
92
148
  this.$emit('change', on ? this.categories.map((c) => c.id) : []);
93
149
  this.$emit('update:showOtherFiles', on);
94
150
  },
151
+ setTheme(theme: Theme): void {
152
+ this.$emit('update:theme', theme);
153
+ },
95
154
  toggleOpen(): void {
96
155
  this.open = !this.open;
97
156
  if (this.open) document.addEventListener('mousedown', this.onDocMouseDown);
@@ -31,24 +31,26 @@
31
31
  @toggle="$emit('toggle', $event)"
32
32
  />
33
33
  </ul>
34
- <div
34
+ <a
35
35
  v-else-if="node.type === 'file'"
36
36
  class="node file"
37
37
  role="treeitem"
38
38
  data-testid="file-node"
39
39
  :data-path="node.path"
40
+ :href="hrefFor(node.path)"
40
41
  :class="{ selected: node.path === selectedPath }"
41
- @click="$emit('select', node.path)"
42
+ @click="onFileClick"
42
43
  >
43
44
  <file-icon :ext="node.ext || ''" />
44
45
  <span class="name">{{ node.name }}</span>
45
- </div>
46
+ </a>
46
47
  </li>
47
48
  </template>
48
49
 
49
50
  <script lang="ts">
50
51
  import { defineComponent, type PropType } from 'vue';
51
52
  import type { FileNode } from '../../shared/types';
53
+ import { hrefFor, isPlainNavClick } from '../router';
52
54
  import FileIcon from './FileIcon.vue';
53
55
 
54
56
  export default defineComponent({
@@ -72,5 +74,15 @@ export default defineComponent({
72
74
  return this.forceExpanded || this.expandedPaths.includes(this.node.path);
73
75
  },
74
76
  },
77
+ methods: {
78
+ hrefFor,
79
+ // Plain left-click navigates in-app; modified/middle clicks fall through so
80
+ // the browser can open the anchor in a new tab/window.
81
+ onFileClick(event: MouseEvent): void {
82
+ if (!isPlainNavClick(event)) return;
83
+ event.preventDefault();
84
+ this.$emit('select', this.node.path);
85
+ },
86
+ },
75
87
  });
76
88
  </script>
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
2
2
  <!-- App tile  matches shell chrome -->
3
3
  <rect width="32" height="32" rx="7" fill="#1e1e1e"/>
4
4
  <!-- Page body -->
@@ -1,6 +1,13 @@
1
1
  import { createApp } from 'vue';
2
2
  import App from './App.vue';
3
- import 'github-markdown-css/github-markdown-dark.css';
3
+ import darkMarkdownCss from 'github-markdown-css/github-markdown-dark.css?url';
4
+ import lightMarkdownCss from 'github-markdown-css/github-markdown-light.css?url';
5
+ import { applyTheme, loadTheme, setMarkdownThemes } from './theme';
4
6
  import './styles.css';
5
7
 
8
+ // Same GitHub markdown stylesheets as before — swapped by theme so MD/CSV/TSV
9
+ // keep github-markdown-css table/typography styling in both modes.
10
+ setMarkdownThemes({ dark: darkMarkdownCss, light: lightMarkdownCss });
11
+ applyTheme(loadTheme());
12
+
6
13
  createApp(App).mount('#app');
@@ -27,6 +27,26 @@ function toUrl(prefix: string, path: string): string {
27
27
  return `${prefix}/${path.split('/').map(encodeURIComponent).join('/')}`;
28
28
  }
29
29
 
30
+ /** The bookmarkable URL for a file path — used as the `href` on nav links so
31
+ * they're real anchors (right-click → open in new tab, hover preview, etc.). */
32
+ export function hrefFor(path: string): string {
33
+ return toUrl(mount(), path);
34
+ }
35
+
36
+ /** Whether a click on an anchor should be handled as in-app navigation rather
37
+ * than left to the browser. False for modified/middle clicks so the browser
38
+ * can open a new tab/window natively. */
39
+ export function isPlainNavClick(event: MouseEvent): boolean {
40
+ return (
41
+ event.button === 0 &&
42
+ !event.metaKey &&
43
+ !event.ctrlKey &&
44
+ !event.shiftKey &&
45
+ !event.altKey &&
46
+ !event.defaultPrevented
47
+ );
48
+ }
49
+
30
50
  /** Reflect the selected path into the URL. Pushes a new history entry by default
31
51
  * (so Back works); pass `replace` when mirroring a navigation that already
32
52
  * created its own history entry (e.g. an in-iframe link click) to avoid a
@@ -7,6 +7,12 @@ const CATEGORIES_KEY_PREFIX = 'hmr-serve:visible-categories:';
7
7
  const OTHER_FILES_KEY_PREFIX = 'hmr-serve:show-other-files:';
8
8
  const EXPANDED_DIRS_KEY_PREFIX = 'hmr-serve:expanded-dirs:';
9
9
  const SIDEBAR_COLLAPSED_KEY_PREFIX = 'hmr-serve:sidebar-collapsed:';
10
+ const SIDEBAR_WIDTH_KEY_PREFIX = 'hmr-serve:sidebar-width:';
11
+
12
+ /** Sidebar width bounds (px). The default matches the CSS `.sidebar` width. */
13
+ export const SIDEBAR_MIN_WIDTH = 180;
14
+ export const SIDEBAR_MAX_WIDTH = 600;
15
+ export const SIDEBAR_DEFAULT_WIDTH = 280;
10
16
 
11
17
  function categoriesKey(mount: string): string {
12
18
  return `${CATEGORIES_KEY_PREFIX}${mount || '/'}`;
@@ -24,6 +30,15 @@ function sidebarCollapsedKey(mount: string): string {
24
30
  return `${SIDEBAR_COLLAPSED_KEY_PREFIX}${mount || '/'}`;
25
31
  }
26
32
 
33
+ function sidebarWidthKey(mount: string): string {
34
+ return `${SIDEBAR_WIDTH_KEY_PREFIX}${mount || '/'}`;
35
+ }
36
+
37
+ /** Clamp a width to the allowed range. */
38
+ export function clampSidebarWidth(px: number): number {
39
+ return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(px)));
40
+ }
41
+
27
42
  /** Load saved visible categories, defaulting to all when unset or unreadable.
28
43
  * Saved values are intersected with `all` so removing a category later can't
29
44
  * leave a stale id in the set. */
@@ -87,6 +102,27 @@ export function saveSidebarCollapsed(mount: string, collapsed: boolean): void {
87
102
  }
88
103
  }
89
104
 
105
+ /** Saved sidebar width in px, clamped to bounds. Defaults when unset/unreadable. */
106
+ export function loadSidebarWidth(mount: string): number {
107
+ try {
108
+ const raw = localStorage.getItem(sidebarWidthKey(mount));
109
+ if (raw == null) return SIDEBAR_DEFAULT_WIDTH;
110
+ const px = Number(raw);
111
+ if (!Number.isFinite(px)) return SIDEBAR_DEFAULT_WIDTH;
112
+ return clampSidebarWidth(px);
113
+ } catch {
114
+ return SIDEBAR_DEFAULT_WIDTH;
115
+ }
116
+ }
117
+
118
+ export function saveSidebarWidth(mount: string, px: number): void {
119
+ try {
120
+ localStorage.setItem(sidebarWidthKey(mount), String(clampSidebarWidth(px)));
121
+ } catch {
122
+ // Ignore storage failures.
123
+ }
124
+ }
125
+
90
126
  /** Directory paths that should start expanded. Missing/corrupt storage → []. */
91
127
  export function loadExpandedDirs(mount: string): string[] {
92
128
  try {
@@ -1,4 +1,5 @@
1
1
  :root {
2
+ color-scheme: dark;
2
3
  --bg: #1e1e1e;
3
4
  --bg-side: #252526;
4
5
  --bg-hover: #2a2d2e;
@@ -9,10 +10,34 @@
9
10
  --fg-nav-active: #ffffff;
10
11
  --border: #333333;
11
12
  --accent: #4daafc;
13
+ --chrome-surface: #2a2a2a;
14
+ --chrome-border: #3a3a3a;
15
+ --chrome-fg: #c8c8c8;
16
+ --chrome-fg-active: #ffffff;
17
+ --danger: #f48771;
12
18
  --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
13
19
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
14
20
  }
15
21
 
22
+ [data-theme='light'] {
23
+ color-scheme: light;
24
+ --bg: #ffffff;
25
+ --bg-side: #f3f3f3;
26
+ --bg-hover: #e8e8e8;
27
+ --bg-sel: #cce8ff;
28
+ --fg: #1e1e1e;
29
+ --fg-dim: #6e6e6e;
30
+ --fg-nav: #3c3c3c;
31
+ --fg-nav-active: #1e1e1e;
32
+ --border: #e0e0e0;
33
+ --accent: #0066bf;
34
+ --chrome-surface: #ececec;
35
+ --chrome-border: #d0d0d0;
36
+ --chrome-fg: #3c3c3c;
37
+ --chrome-fg-active: #1e1e1e;
38
+ --danger: #c72e0f;
39
+ }
40
+
16
41
  * {
17
42
  box-sizing: border-box;
18
43
  }
@@ -41,6 +66,7 @@ body {
41
66
  .sidebar {
42
67
  display: flex;
43
68
  flex-direction: column;
69
+ flex-shrink: 0;
44
70
  width: 280px;
45
71
  min-width: 180px;
46
72
  min-height: 0;
@@ -48,6 +74,31 @@ body {
48
74
  border-right: 1px solid var(--border);
49
75
  }
50
76
 
77
+ /* Drag handle between the sidebar and content. Sits on the sidebar's right edge
78
+ * and widens its hit area without shifting layout. */
79
+ .sidebar-resizer {
80
+ flex-shrink: 0;
81
+ width: 5px;
82
+ cursor: col-resize;
83
+ background: transparent;
84
+ transition: background 0.12s ease;
85
+ touch-action: none;
86
+ }
87
+
88
+ .sidebar-resizer:hover,
89
+ .sidebar-resizer:focus-visible,
90
+ .sidebar-resizer.active {
91
+ background: var(--accent);
92
+ outline: none;
93
+ }
94
+
95
+ /* While dragging, force the resize cursor everywhere and suppress selection so
96
+ * moving over the content iframe stays smooth. */
97
+ body.is-resizing {
98
+ cursor: col-resize;
99
+ user-select: none;
100
+ }
101
+
51
102
  .sidebar-header {
52
103
  display: flex;
53
104
  align-items: center;
@@ -160,6 +211,7 @@ body {
160
211
  font-weight: 400;
161
212
  line-height: 1.5;
162
213
  color: var(--fg-nav);
214
+ text-decoration: none;
163
215
  }
164
216
 
165
217
  .node:hover {
@@ -211,9 +263,12 @@ body {
211
263
  }
212
264
 
213
265
  .search-hit {
266
+ display: block;
214
267
  padding: 6px 12px;
215
268
  cursor: pointer;
216
269
  border-bottom: 1px solid var(--border);
270
+ color: var(--fg);
271
+ text-decoration: none;
217
272
  }
218
273
 
219
274
  .search-hit:hover {
@@ -273,8 +328,8 @@ body {
273
328
  align-items: center;
274
329
  padding: 2px;
275
330
  border-radius: 8px;
276
- background: #2a2a2a;
277
- border: 1px solid #3a3a3a;
331
+ background: var(--chrome-surface);
332
+ border: 1px solid var(--chrome-border);
278
333
  flex-shrink: 0;
279
334
  }
280
335
 
@@ -288,7 +343,7 @@ body {
288
343
  border: 1px solid transparent;
289
344
  border-radius: 6px;
290
345
  background: transparent;
291
- color: #8a8a8a;
346
+ color: var(--fg-dim);
292
347
  cursor: pointer;
293
348
  }
294
349
 
@@ -297,15 +352,15 @@ body {
297
352
  }
298
353
 
299
354
  .view-toggle-btn.active {
300
- color: #fff;
301
- background: #3a3a3a;
302
- border-color: #4daafc;
355
+ color: var(--chrome-fg-active);
356
+ background: var(--bg-hover);
357
+ border-color: var(--accent);
303
358
  }
304
359
 
305
360
  .pane-label {
306
361
  font-size: 13px;
307
362
  font-weight: 500;
308
- color: #c8c8c8;
363
+ color: var(--chrome-fg);
309
364
  white-space: nowrap;
310
365
  overflow: hidden;
311
366
  text-overflow: ellipsis;
@@ -320,20 +375,20 @@ body {
320
375
  padding: 0;
321
376
  flex-shrink: 0;
322
377
  border-radius: 8px;
323
- border: 1px solid #3a3a3a;
324
- background: #2a2a2a;
325
- color: #c8c8c8;
378
+ border: 1px solid var(--chrome-border);
379
+ background: var(--chrome-surface);
380
+ color: var(--chrome-fg);
326
381
  cursor: pointer;
327
382
  }
328
383
 
329
384
  .fullscreen-btn:hover {
330
- color: #fff;
331
- border-color: #4daafc;
385
+ color: var(--chrome-fg-active);
386
+ border-color: var(--accent);
332
387
  }
333
388
 
334
389
  .fullscreen-btn[aria-pressed='true'] {
335
- border-color: #4daafc;
336
- color: #fff;
390
+ border-color: var(--accent);
391
+ color: var(--chrome-fg-active);
337
392
  }
338
393
 
339
394
  .pane-body {
@@ -348,7 +403,7 @@ body {
348
403
  }
349
404
 
350
405
  .status.error {
351
- color: #f48771;
406
+ color: var(--danger);
352
407
  }
353
408
 
354
409
  .raw-frame {
@@ -473,6 +528,75 @@ body {
473
528
  font-weight: 600;
474
529
  }
475
530
 
531
+ /* Light-mode syntax colors (VS Code Light+ inspired). */
532
+ [data-theme='light'] .hljs-comment,
533
+ [data-theme='light'] .hljs-quote {
534
+ color: #008000;
535
+ }
536
+
537
+ [data-theme='light'] .hljs-keyword,
538
+ [data-theme='light'] .hljs-selector-tag,
539
+ [data-theme='light'] .hljs-literal,
540
+ [data-theme='light'] .hljs-tag,
541
+ [data-theme='light'] .hljs-name,
542
+ [data-theme='light'] .hljs-doctag {
543
+ color: #0000ff;
544
+ }
545
+
546
+ [data-theme='light'] .hljs-string,
547
+ [data-theme='light'] .hljs-regexp,
548
+ [data-theme='light'] .hljs-meta .hljs-string {
549
+ color: #a31515;
550
+ }
551
+
552
+ [data-theme='light'] .hljs-number,
553
+ [data-theme='light'] .hljs-symbol,
554
+ [data-theme='light'] .hljs-bullet {
555
+ color: #098658;
556
+ }
557
+
558
+ [data-theme='light'] .hljs-title,
559
+ [data-theme='light'] .hljs-title.function_,
560
+ [data-theme='light'] .hljs-section {
561
+ color: #795e26;
562
+ }
563
+
564
+ [data-theme='light'] .hljs-built_in,
565
+ [data-theme='light'] .hljs-type,
566
+ [data-theme='light'] .hljs-title.class_,
567
+ [data-theme='light'] .hljs-class .hljs-title {
568
+ color: #267f99;
569
+ }
570
+
571
+ [data-theme='light'] .hljs-attr,
572
+ [data-theme='light'] .hljs-attribute,
573
+ [data-theme='light'] .hljs-variable,
574
+ [data-theme='light'] .hljs-template-variable,
575
+ [data-theme='light'] .hljs-property,
576
+ [data-theme='light'] .hljs-params {
577
+ color: #001080;
578
+ }
579
+
580
+ [data-theme='light'] .hljs-selector-class,
581
+ [data-theme='light'] .hljs-selector-id,
582
+ [data-theme='light'] .hljs-selector-attr,
583
+ [data-theme='light'] .hljs-selector-pseudo {
584
+ color: #800000;
585
+ }
586
+
587
+ [data-theme='light'] .hljs-meta,
588
+ [data-theme='light'] .hljs-meta .hljs-keyword {
589
+ color: #0000ff;
590
+ }
591
+
592
+ [data-theme='light'] .hljs-deletion {
593
+ color: #a31515;
594
+ }
595
+
596
+ [data-theme='light'] .hljs-addition {
597
+ color: #098658;
598
+ }
599
+
476
600
  /* File-type filter (gear) */
477
601
  .settings {
478
602
  position: relative;
@@ -512,6 +636,10 @@ body {
512
636
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
513
637
  }
514
638
 
639
+ [data-theme='light'] .settings-popover {
640
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
641
+ }
642
+
515
643
  .settings-title {
516
644
  padding: 2px 6px 6px;
517
645
  font-size: 12px;
@@ -562,6 +690,24 @@ body {
562
690
  background: var(--bg-hover);
563
691
  }
564
692
 
693
+ .settings-actions button.active {
694
+ color: var(--fg);
695
+ border-color: var(--accent);
696
+ background: var(--bg-hover);
697
+ }
698
+
699
+ .theme-actions button {
700
+ display: inline-flex;
701
+ align-items: center;
702
+ justify-content: center;
703
+ min-height: 30px;
704
+ padding: 6px;
705
+ }
706
+
707
+ .theme-actions button svg {
708
+ display: block;
709
+ }
710
+
565
711
  /* Image / video preview */
566
712
  .media-wrap {
567
713
  display: flex;
@@ -0,0 +1,51 @@
1
+ export type Theme = 'dark' | 'light';
2
+
3
+ const THEME_KEY = 'hmr-serve:theme';
4
+ const MARKDOWN_LINK_ID = 'hmr-markdown-theme';
5
+
6
+ /** Vite-resolved markdown stylesheet URLs, registered from `main.ts`. */
7
+ let markdownCss: Record<Theme, string> | null = null;
8
+
9
+ /** Register the GitHub markdown CSS URLs (from Vite `?url` imports). */
10
+ export function setMarkdownThemes(urls: Record<Theme, string>): void {
11
+ markdownCss = urls;
12
+ }
13
+
14
+ /** Load the saved UI theme. Defaults to dark (matches the original chrome). */
15
+ export function loadTheme(): Theme {
16
+ try {
17
+ const raw = localStorage.getItem(THEME_KEY);
18
+ if (raw === 'light' || raw === 'dark') return raw;
19
+ } catch {
20
+ // Ignore storage failures.
21
+ }
22
+ return 'dark';
23
+ }
24
+
25
+ export function saveTheme(theme: Theme): void {
26
+ try {
27
+ localStorage.setItem(THEME_KEY, theme);
28
+ } catch {
29
+ // Ignore storage failures — theme still applies in-session.
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Apply the theme to the app shell (and markdown/code/csv previews).
35
+ * Sandboxed HTML iframes keep their own styles — they are not themed.
36
+ */
37
+ export function applyTheme(theme: Theme): void {
38
+ document.documentElement.dataset.theme = theme;
39
+ document.documentElement.style.colorScheme = theme;
40
+
41
+ if (!markdownCss) return;
42
+
43
+ let link = document.getElementById(MARKDOWN_LINK_ID) as HTMLLinkElement | null;
44
+ if (!link) {
45
+ link = document.createElement('link');
46
+ link.id = MARKDOWN_LINK_ID;
47
+ link.rel = 'stylesheet';
48
+ document.head.appendChild(link);
49
+ }
50
+ link.href = markdownCss[theme];
51
+ }