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 +14 -12
- package/dist/cli/banner.js +1 -0
- package/dist/cli/banner.js.map +1 -1
- package/dist/server/renderers/markdown.js +37 -2
- package/dist/server/renderers/markdown.js.map +1 -1
- package/package.json +1 -1
- package/src/client/App.vue +104 -13
- package/src/client/components/SettingsMenu.vue +59 -0
- package/src/client/components/TreeNode.vue +15 -3
- package/src/client/favicon.svg +1 -1
- package/src/client/main.ts +8 -1
- package/src/client/router.ts +20 -0
- package/src/client/settings.ts +36 -0
- package/src/client/styles.css +161 -15
- package/src/client/theme.ts +51 -0
package/README.md
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# hmr-serve
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
[](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
|
|
9
|
-
change on disk
|
|
10
|
-
[HMR](https://vitejs.dev/guide/features.html#hot-module-replacement) client
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/dist/cli/banner.js
CHANGED
|
@@ -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. */
|
package/dist/cli/banner.js.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"markdown.js","sourceRoot":"","sources":["../../../src/server/renderers/markdown.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hmr-serve",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/client/App.vue
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="app">
|
|
3
|
-
<aside
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
<
|
|
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="
|
|
42
|
+
@click="onFileClick"
|
|
42
43
|
>
|
|
43
44
|
<file-icon :ext="node.ext || ''" />
|
|
44
45
|
<span class="name">{{ node.name }}</span>
|
|
45
|
-
</
|
|
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>
|
package/src/client/favicon.svg
CHANGED
|
@@ -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 -->
|
package/src/client/main.ts
CHANGED
|
@@ -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');
|
package/src/client/router.ts
CHANGED
|
@@ -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
|
package/src/client/settings.ts
CHANGED
|
@@ -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 {
|
package/src/client/styles.css
CHANGED
|
@@ -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:
|
|
277
|
-
border: 1px solid
|
|
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:
|
|
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:
|
|
301
|
-
background:
|
|
302
|
-
border-color:
|
|
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:
|
|
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
|
|
324
|
-
background:
|
|
325
|
-
color:
|
|
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:
|
|
331
|
-
border-color:
|
|
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:
|
|
336
|
-
color:
|
|
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:
|
|
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
|
+
}
|