ompchamber 3.2.0 → 3.3.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.
Files changed (52) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +239 -230
  3. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  4. package/dist/client/static/js/{index-1q6165r3.js → index-qkt4z7x0.js} +640 -640
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  8. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -0
  9. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  10. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +14 -5
  11. package/src/client/components/settings/Modal.tsx +10 -17
  12. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  13. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  14. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +17 -11
  15. package/src/client/components/workspace/editor/index.tsx +15 -7
  16. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  17. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  18. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  19. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  20. package/src/client/data/theme/terminal.ts +25 -8
  21. package/src/client/hooks/editor/font.ts +53 -0
  22. package/src/client/hooks/ui/clipboard.ts +17 -0
  23. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  24. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  25. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  26. package/src/client/hooks/ui/text-overflow.ts +78 -0
  27. package/src/client/hooks/ui/touch-device.ts +46 -0
  28. package/src/client/main.tsx +1 -0
  29. package/src/server/index.ts +4 -3
  30. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  31. package/src/server/lib/assets/conditional.server.ts +54 -0
  32. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  33. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  34. package/src/server/lib/assets/fonts.server.ts +9 -4
  35. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  36. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  37. package/src/server/lib/bundler/dependencies.test.ts +134 -0
  38. package/src/server/lib/lifecycle/listener.ts +29 -0
  39. package/src/server/plugins/shell.server.ts +52 -13
  40. package/src/server/plugins/ssr.ts +43 -7
  41. package/src/server/plugins/static.ts +51 -18
  42. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  43. package/src/shared/lib/code/editor/typography.ts +86 -0
  44. package/src/shared/lib/fonts/NOTICE.md +44 -0
  45. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  46. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  47. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  48. package/src/shared/lib/settings/diff.test.ts +1 -1
  49. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  50. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  51. package/src/shared/types/settings/state.ts +7 -16
  52. package/dist/client/static/js/index-01dv383b.css +0 -1
@@ -1,26 +1,47 @@
1
1
  import { Elysia } from 'elysia';
2
2
  import { getDb } from '@/server/db.server';
3
3
  import { tryServeStatic } from '@/server/plugins/static';
4
- import { renderShell } from '@/server/plugins/shell.server';
4
+ import { renderShell, SHELL_MARKER } from '@/server/plugins/shell.server';
5
+ import {
6
+ DEV_ASSETS_ENABLED,
7
+ devAssetUpstreamPath,
8
+ rewriteDevAssetUrls,
9
+ serveDevAsset,
10
+ } from '@/server/lib/assets/dev-assets.server';
11
+ import { listenerUrl } from '@/server/lib/lifecycle/listener';
5
12
  import { FONT_STYLESHEET_ROUTE } from '@/server/lib/assets/font-css.server';
6
13
  import { resolveTheme } from '@/shared/lib/theme/catalog';
7
14
  import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
8
15
 
16
+ /** HTML-escape a string for interpolation into the failure page's `<pre>`. */
17
+ function escapeHtmlText(value: string): string {
18
+ return value
19
+ .replace(/&/g, '&amp;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;');
23
+ }
24
+
9
25
  /**
10
26
  * Actionable message instead of a bare 500 when the shell cannot be rendered.
11
27
  *
12
28
  * The shell is a Bun HTML route now, not a file on disk, so "not found" is no
13
29
  * longer the failure mode — a bundle error is, and Bun renders its own error
14
- * page for that. This covers the remaining path: the bundle is fine but the
15
- * markup could not be read back.
30
+ * page for that, with the file and line locked inside a payload only the
31
+ * browser can decode. `plugins/shell.server.ts` rebuilds the entrypoint to get
32
+ * those facts in readable form and passes them here as the reason, so this page
33
+ * names the edit that broke the bundle rather than the status it produced.
16
34
  */
17
35
  function shellUnavailableResponse(reason: string): Response {
36
+ // Escaped, not interpolated raw: the reason carries the failed build's own
37
+ // source lines, which are arbitrary code — a `<` in one would otherwise
38
+ // truncate the page at the first tag.
18
39
  return new Response(
19
40
  `<!doctype html><meta charset="utf-8"><title>OMPChamber</title>
20
41
  <body style="font:14px/1.6 ui-monospace,monospace;padding:2rem">
21
42
  <h1 style="font-size:1.1rem">Shell not available</h1>
22
43
  <p>The server could not render its HTML shell.</p>
23
- <pre style="background:#f4f1ea;padding:1rem;border-radius:6px">${reason}</pre>
44
+ <pre style="background:#f4f1ea;padding:1rem;border-radius:6px">${escapeHtmlText(reason)}</pre>
24
45
  </body>`,
25
46
  { status: 503, headers: HTML_HEADERS },
26
47
  );
@@ -83,7 +104,17 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
83
104
  });
84
105
  }
85
106
 
86
- const asset = await tryServeStatic(pathname);
107
+ // The dev asset proxy, before the static lookup: its prefix is this server's
108
+ // own, and Bun's table never sees it. See `lib/assets/dev-assets.server`.
109
+ if (DEV_ASSETS_ENABLED && devAssetUpstreamPath(pathname)) {
110
+ const base = listenerUrl();
111
+ if (base) {
112
+ const proxied = await serveDevAsset(request, pathname, base);
113
+ if (proxied) return proxied;
114
+ }
115
+ }
116
+
117
+ const asset = await tryServeStatic(pathname, request);
87
118
  if (asset) return asset;
88
119
 
89
120
  const settings = await readSettings();
@@ -110,13 +141,18 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
110
141
  // loader and fail the same way.
111
142
  const fontLink = `<link rel="stylesheet" href="${FONT_STYLESHEET_ROUTE}">`;
112
143
 
144
+ // Bun's dev asset URLs move to this server's prefix so the proxy above can add
145
+ // the caching headers Bun omits. Rewritten BEFORE the bootstrap is injected,
146
+ // so a `/_bun/` string inside the settings payload is left alone.
147
+ const shellHtml = DEV_ASSETS_ENABLED ? rewriteDevAssetUrls(rendered.html) : rendered.html;
148
+
113
149
  return new Response(
114
- rendered.html
150
+ shellHtml
115
151
  .replace('data-theme="paper"', `data-theme="${theme.id}"`)
116
152
  .replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
117
153
  .replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${theme.canvas}" />`)
118
154
  .replace('<!--app-head-->', themeStyle + fontLink)
119
- .replace('<!--app-bootstrap-->', `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
155
+ .replace(SHELL_MARKER, `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
120
156
  { headers: HTML_HEADERS },
121
157
  );
122
158
  });
@@ -1,15 +1,20 @@
1
1
  import { extname, join, normalize, resolve, sep } from 'path';
2
2
 
3
+ import { notModified } from '@/server/lib/assets/conditional.server';
3
4
  import { FONT_STYLESHEET_ROUTE, serveFontFile, serveFontStylesheet } from '@/server/lib/assets/font-css.server';
4
5
  import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
5
6
 
6
7
  /**
7
8
  * File serving for `public/` and the font routes.
8
9
  *
9
- * The client bundle is NOT served from here any more. Bun's own routing owns
10
- * the assets it emits — `/_bun/asset/*` and `/_bun/client/*` in development,
11
- * `/chunk-*` in production — and answers them from its bundle table before a
12
- * request reaches Elysia. `dist/client` no longer exists, so the `/static/`
10
+ * The client bundle is NOT served from here. Bun's own routing owns the assets
11
+ * it emits — `/_bun/asset/*` and `/_bun/client/*` in development, `/chunk-*` in
12
+ * production — and answers them from its bundle table before a request reaches
13
+ * Elysia. In development those responses are reached a second way, through
14
+ * `lib/assets/dev-assets.server.ts`: Bun's table wins for the paths it declares,
15
+ * so the shell points at `/_dev-assets/*` and that module fetches the bytes back
16
+ * from the listener to add the `Cache-Control` and `ETag`/`304` handling Bun's
17
+ * dev asset routes lack. `dist/client` no longer exists, so the `/static/`
13
18
  * branch and its content-hashed caching went with it.
14
19
  *
15
20
  * Hand-rolled rather than `@elysiajs/static`: that plugin registers its own
@@ -61,7 +66,17 @@ function resolveWithin(root: string, pathname: string): string | null {
61
66
  return candidate;
62
67
  }
63
68
 
64
- async function serveFile(filePath: string, cacheControl: string): Promise<Response | null> {
69
+ /**
70
+ * The file's own bytes, or null when it cannot be read.
71
+ *
72
+ * `If-None-Match` is answered from a weak validator derived from the file's
73
+ * size and mtime. A content hash would be stronger, but it would mean reading
74
+ * every asset on every request to compute it — the opposite of the point.
75
+ * Size+mtime is what `plugins/static.ts` can produce from the `stat` it already
76
+ * performs, and a wrong `304` from it needs both a same-size edit and a
77
+ * preserved mtime, which is not something a build or an editor does.
78
+ */
79
+ async function serveFile(filePath: string, cacheControl: string, request?: Request): Promise<Response | null> {
65
80
  let stat;
66
81
  try {
67
82
  stat = await Bun.file(filePath).stat();
@@ -71,14 +86,24 @@ async function serveFile(filePath: string, cacheControl: string): Promise<Respon
71
86
  if (!stat.isFile()) return null;
72
87
 
73
88
  const type = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
74
- const stream = Bun.file(filePath).stream();
75
- return new Response(stream, {
76
- headers: {
77
- 'content-type': type,
78
- 'content-length': String(stat.size),
79
- 'cache-control': cacheControl,
80
- 'last-modified': stat.mtime.toUTCString(),
81
- },
89
+ const lastModified = stat.mtime.toUTCString();
90
+ // Weak on purpose: the validator identifies the file as the same version, and
91
+ // byte equality is not what it promises.
92
+ const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
93
+ const headers = {
94
+ 'content-type': type,
95
+ 'cache-control': cacheControl,
96
+ 'last-modified': lastModified,
97
+ etag,
98
+ };
99
+
100
+ if (request && notModified(request, etag, stat.mtimeMs)) {
101
+ // No `content-length`: a 304 describes the validator, not a body.
102
+ return new Response(null, { status: 304, headers });
103
+ }
104
+
105
+ return new Response(Bun.file(filePath).stream(), {
106
+ headers: { ...headers, 'content-length': String(stat.size) },
82
107
  });
83
108
  }
84
109
 
@@ -90,18 +115,22 @@ async function serveFile(filePath: string, cacheControl: string): Promise<Respon
90
115
  * going through the SSR route that injects the theme and bootstrap, and the
91
116
  * cache stays short because these filenames carry no content hash.
92
117
  */
93
- export async function servePublicAsset(pathname: string, roots: readonly string[]): Promise<Response | null> {
118
+ export async function servePublicAsset(
119
+ pathname: string,
120
+ roots: readonly string[],
121
+ request?: Request,
122
+ ): Promise<Response | null> {
94
123
  for (const root of roots) {
95
124
  const filePath = resolveWithin(root, pathname);
96
125
  if (!filePath || filePath.endsWith('.html')) continue;
97
- const response = await serveFile(filePath, SHORT);
126
+ const response = await serveFile(filePath, SHORT, request);
98
127
  if (response) return response;
99
128
  }
100
129
  return null;
101
130
  }
102
131
 
103
132
  /** A file for `pathname`, or null when the request should reach the app. */
104
- export async function tryServeStatic(pathname: string): Promise<Response | null> {
133
+ export async function tryServeStatic(pathname: string, request?: Request): Promise<Response | null> {
105
134
  // The production bundle's own assets. `scripts/build-client.ts` emits the
106
135
  // server into `dist/client` with its chunks under `dist/client/static`, and
107
136
  // the generated HTML references them as `./static/...` — a page URL of `/`
@@ -110,7 +139,7 @@ export async function tryServeStatic(pathname: string): Promise<Response | null>
110
139
  // `/_bun/client/*`) and never reach here.
111
140
  if (pathname.startsWith('/static/')) {
112
141
  const filePath = resolveWithin(CLIENT_ROOT, pathname);
113
- return filePath ? serveFile(filePath, ASSET_CACHE) : null;
142
+ return filePath ? serveFile(filePath, ASSET_CACHE, request) : null;
114
143
  }
115
144
  // The extracted `@font-face` stylesheet and the files it names. Bun's CSS
116
145
  // loader cannot carry either — every local `url()` is resolved, so a bundled
@@ -118,9 +147,13 @@ export async function tryServeStatic(pathname: string): Promise<Response | null>
118
147
  // than emitted. Both live outside `public/`: the stylesheet is assembled from
119
148
  // the faces `lib/bundler/css.ts` wrote, and the files come from the installed
120
149
  // packages.
150
+ //
151
+ // Neither is given a conditional here: the stylesheet is `no-store` (nothing
152
+ // is ever stored to revalidate) and a font file is `immutable` (the browser
153
+ // does not ask again). A validator on either would be dead code.
121
154
  if (pathname === FONT_STYLESHEET_ROUTE) return serveFontStylesheet();
122
155
  if (pathname.startsWith(FONT_ROUTE_PREFIX)) {
123
156
  return serveFontFile(decodeURIComponent(pathname.slice(FONT_ROUTE_PREFIX.length)));
124
157
  }
125
- return servePublicAsset(pathname, [PUBLIC_ROOT]);
158
+ return servePublicAsset(pathname, [PUBLIC_ROOT], request);
126
159
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor typography contract.
8
+ *
9
+ * These are the properties that make one set of constants serve two layouts at
10
+ * any zoom level. Each one has a silent failure mode:
11
+ *
12
+ * - A px line-height does not scale with the font size, so zooming the editor
13
+ * leaves the rows at the size they were chosen for at 12px — cramped at 24,
14
+ * loose at 8. A ratio cannot.
15
+ * - The stack must name the bundled face first: `Fira Code` is the only entry
16
+ * the app ships, so anything ahead of it would either be a font nobody has or
17
+ * silently change the ligatures the whole console is typeset in.
18
+ * - The bounds must bracket the default, or the editor opens at a size its own
19
+ * zoom controls cannot return to (the first click jumps).
20
+ */
21
+
22
+ import { describe, expect, test } from 'bun:test';
23
+
24
+ import {
25
+ EDITOR_DEFAULT_FONT_FAMILY,
26
+ EDITOR_DEFAULT_FONT_SIZE,
27
+ EDITOR_FONT_CHOICES,
28
+ EDITOR_FONT_FAMILY,
29
+ EDITOR_LINE_HEIGHT,
30
+ EDITOR_MAX_FONT_SIZE,
31
+ EDITOR_MIN_FONT_SIZE,
32
+ editorFontStack,
33
+ } from '@/shared/lib/code/editor/typography';
34
+
35
+ describe('editor typography', () => {
36
+ test('the line box is a ratio, so it scales with the font size', () => {
37
+ expect(typeof EDITOR_LINE_HEIGHT).toBe('number');
38
+ expect(EDITOR_LINE_HEIGHT).toBeGreaterThan(1);
39
+ expect(EDITOR_LINE_HEIGHT).toBeLessThan(3);
40
+ });
41
+
42
+ test('the stack leads with the bundled face and ends in a generic', () => {
43
+ expect(EDITOR_FONT_FAMILY.startsWith('"Fira Code"')).toBe(true);
44
+ expect(EDITOR_FONT_FAMILY.trimEnd().endsWith('monospace')).toBe(true);
45
+ });
46
+
47
+ test('the zoom bounds bracket the size the editor opens at', () => {
48
+ expect(EDITOR_MIN_FONT_SIZE).toBeLessThan(EDITOR_DEFAULT_FONT_SIZE);
49
+ expect(EDITOR_MAX_FONT_SIZE).toBeGreaterThan(EDITOR_DEFAULT_FONT_SIZE);
50
+ });
51
+ });
52
+
53
+ describe('editorFontStack', () => {
54
+ test('the default and an empty choice both yield the shared stack', () => {
55
+ expect(editorFontStack(EDITOR_DEFAULT_FONT_FAMILY)).toBe(EDITOR_FONT_FAMILY);
56
+ expect(editorFontStack('')).toBe(EDITOR_FONT_FAMILY);
57
+ expect(editorFontStack(undefined)).toBe(EDITOR_FONT_FAMILY);
58
+ expect(editorFontStack(' ')).toBe(EDITOR_FONT_FAMILY);
59
+ });
60
+
61
+ test('a chosen face is preferred, with the bundled stack still behind it', () => {
62
+ const stack = editorFontStack('JetBrains Mono');
63
+ expect(stack.startsWith('"JetBrains Mono"')).toBe(true);
64
+ // The fallback is what keeps an uninstalled choice from breaking the editor.
65
+ expect(stack).toContain(EDITOR_FONT_FAMILY);
66
+ expect(stack.trimEnd().endsWith('monospace')).toBe(true);
67
+ });
68
+
69
+ test('a multi-word choice is quoted, so it stays one family name', () => {
70
+ expect(editorFontStack('Courier New').startsWith('"Courier New"')).toBe(true);
71
+ });
72
+
73
+ test('the default choice is one the picker offers', () => {
74
+ expect(EDITOR_FONT_CHOICES.map((choice) => choice.id)).toContain(EDITOR_DEFAULT_FONT_FAMILY);
75
+ });
76
+
77
+ test('exactly one offered face is the bundled one, and it is the default', () => {
78
+ const bundled = EDITOR_FONT_CHOICES.filter((choice) => choice.bundled);
79
+ expect(bundled.map((choice) => choice.id)).toEqual([EDITOR_DEFAULT_FONT_FAMILY]);
80
+ });
81
+
82
+ test('every choice resolves to a stack that still ends in the bundled face', () => {
83
+ // The picker offers faces the app does not ship, so the guarantee that makes
84
+ // that safe is per-choice: a device without the chosen face must land on
85
+ // Fira Code rather than on a system serif or a blank cell.
86
+ for (const choice of EDITOR_FONT_CHOICES) {
87
+ const stack = editorFontStack(choice.id);
88
+ expect(stack.startsWith(`"${choice.id}"`)).toBe(true);
89
+ expect(stack).toContain(EDITOR_FONT_FAMILY);
90
+ expect(stack.trimEnd().endsWith('monospace')).toBe(true);
91
+ }
92
+ });
93
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's typography, in one place.
8
+ *
9
+ * The desktop editor and the phone's full-screen editor are the same surface on
10
+ * two layouts, so their text has to be the same text — the same face and the
11
+ * same line box. It was not: the stack was written out as a literal twice in
12
+ * the desktop editor and replaced by a bare `monospace` on the phone, which
13
+ * dropped Fira Code (and the ligatures the whole console is typeset in) and put
14
+ * the phone's line numbers in a different face from the code beside them — the
15
+ * gutter was already `font-mono`.
16
+ *
17
+ * `EDITOR_LINE_HEIGHT` is a ratio rather than a pixel value for the reason the
18
+ * desktop editor already used one: the zoom control changes the font size, and
19
+ * a fixed pixel line box keeps the rows at the size they were chosen for at
20
+ * 12px — cramped at 18px, loose at 10px. It has to be applied to the gutter as
21
+ * well as the editor, because with word wrap off the gutter rows carry no
22
+ * explicit height and take their spacing from their own line-height; a gutter
23
+ * pinned to a different value drifts out of register with the lines it counts.
24
+ *
25
+ * The two layouts keep their own padding and gutter width — a phone has less
26
+ * room, and the padding is chrome, not type. Everything that decides how a
27
+ * glyph is drawn lives here.
28
+ */
29
+
30
+ /** Fira Code first (bundled — see `fira-code-fonts.css`), then faces a system may already have. */
31
+ export const EDITOR_FONT_FAMILY = '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace';
32
+
33
+ /**
34
+ * The faces the editor font picker offers, in the order it lists them.
35
+ *
36
+ * Each entry is a CSS family name. The editor puts the chosen one FIRST in its
37
+ * stack and keeps the rest of `EDITOR_FONT_FAMILY` behind it, so a face the
38
+ * device does not have falls through to the bundled Fira Code rather than
39
+ * rendering a blank or a system serif — which is what makes this setting safe
40
+ * to offer without shipping every font in the list. Only Fira Code is bundled;
41
+ * the others are names a system may or may not have, and the picker says so.
42
+ *
43
+ * `id` is the stored value and is deliberately the CSS name itself: a mapping
44
+ * table would add a second thing to keep in sync, and the name is already the
45
+ * only thing the renderer needs.
46
+ */
47
+ export const EDITOR_FONT_CHOICES = [
48
+ { id: 'Fira Code', label: 'Fira Code', bundled: true },
49
+ { id: 'JetBrains Mono', label: 'JetBrains Mono', bundled: false },
50
+ { id: 'SF Mono', label: 'SF Mono', bundled: false },
51
+ { id: 'Menlo', label: 'Menlo', bundled: false },
52
+ { id: 'Consolas', label: 'Consolas', bundled: false },
53
+ { id: 'Monaco', label: 'Monaco', bundled: false },
54
+ { id: 'Courier New', label: 'Courier New', bundled: false },
55
+ ] as const;
56
+
57
+ /** The stored setting's default; also the first entry the picker lists. */
58
+ export const EDITOR_DEFAULT_FONT_FAMILY = 'Fira Code';
59
+
60
+ /**
61
+ * The editor's font stack with `family` preferred.
62
+ *
63
+ * The chosen face is quoted (the list carries multi-word names) and placed
64
+ * ahead of the shared stack, so an unknown or uninstalled name degrades to the
65
+ * bundled Fira Code instead of replacing it. An empty or absent choice yields
66
+ * the shared stack unchanged, which is what a settings row that predates this
67
+ * option holds.
68
+ */
69
+ export function editorFontStack(family?: string | null): string {
70
+ const chosen = (family ?? '').trim();
71
+ if (!chosen || chosen === EDITOR_DEFAULT_FONT_FAMILY) return EDITOR_FONT_FAMILY;
72
+ return `"${chosen}", ${EDITOR_FONT_FAMILY}`;
73
+ }
74
+
75
+ /** Line box as a multiple of the font size; apply to the editor AND its gutter. */
76
+ export const EDITOR_LINE_HEIGHT = 1.5;
77
+
78
+ /** Font size in px both editors open at. */
79
+ export const EDITOR_DEFAULT_FONT_SIZE = 12;
80
+
81
+ /**
82
+ * Zoom bounds, in px. Shared so the two layouts offer the same range — a phone
83
+ * clamped to a narrower one is a different editor, not a smaller one.
84
+ */
85
+ export const EDITOR_MIN_FONT_SIZE = 8;
86
+ export const EDITOR_MAX_FONT_SIZE = 24;
@@ -0,0 +1,44 @@
1
+ # Bundled font: Symbols Nerd Font Mono
2
+
3
+ `symbols-nerd-font-mono.woff2` is a subset of **SymbolsNerdFontMono-Regular.ttf**
4
+ from [Nerd Fonts](https://github.com/ryanoasis/nerd-fonts) **v3.5.1**, built by
5
+ `scripts/nerd-symbols-font.mjs`. It is the fallback face for the terminal's
6
+ private-use glyphs (powerline separators, devicons, Font Awesome, Material
7
+ Design icons); see `nerd-symbols.css` for why it is needed and how it is scoped.
8
+
9
+ Subset coverage — the private-use areas plus the handful of Nerd-specific
10
+ codepoints outside them:
11
+
12
+ U+E000-F8FF BMP private use
13
+ U+F0000-FFFFD Supplementary private use area-A (Material Design Icons)
14
+ U+23FB-23FE, U+2630, U+2665, U+26A1, U+276C-2771, U+2B58
15
+
16
+ ## Licences
17
+
18
+ The font is a patched build combining several upstream icon sets. Each is
19
+ redistributed here under its own licence, unchanged in substance (the subset
20
+ keeps the glyph outlines verbatim; only `layout-features` and hinting are
21
+ dropped, which are not licence terms).
22
+
23
+ | Icon set | Upstream | Licence |
24
+ |--------------------------|-------------------------------------------------------|-------------|
25
+ | Codicons | https://github.com/microsoft/vscode-codicons | CC BY 4.0 |
26
+ | Devicons | https://github.com/devicons/devicon | MIT |
27
+ | extraglyphs | https://github.com/source-foundry/Hack | MIT |
28
+ | Font Awesome | https://github.com/FortAwesome/Font-Awesome | CC BY 4.0 |
29
+ | Font Awesome Extension | https://github.com/AndreLZGava/font-awesome-extension | MIT |
30
+ | Font Logos | https://github.com/lukas-w/font-logos | unlicensed |
31
+ | Material Design Icons | https://github.com/Templarian/MaterialDesign-Font | Apache 2.0 |
32
+ | Octicons | https://github.com/primer/octicons | MIT |
33
+ | Seti and original | https://github.com/jesseweed/seti-ui | MIT |
34
+ | Pomicons | https://github.com/gabrielelana/pomicons | OFL 1.1 RFN |
35
+ | Powerline Extra Symbols | https://github.com/ryanoasis/powerline-extra-symbols | MIT |
36
+ | Powerline Symbols | https://github.com/powerline/powerline | MIT |
37
+
38
+ The Nerd Fonts project itself (the patching, the font metadata and the
39
+ non-icon glyphs) is MIT, Copyright (c) 2014 Ryan L McIntyre. Its licence text
40
+ accompanies the upstream release at
41
+ https://github.com/ryanoasis/nerd-fonts/blob/master/LICENSE.
42
+
43
+ "Font Logos" is marked unlicensed upstream; it ships inside the Nerd Fonts
44
+ release that this subset is taken from, and is redistributed on that basis.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /*
7
+ * The bundled Nerd Font symbols face.
8
+ *
9
+ * A shell prompt is drawn with private-use symbols — powerline separators,
10
+ * devicons, Font Awesome, Material Design icons — that no coding webfont
11
+ * contains. `@fontsource/fira-code`'s latin subset maps 226 codepoints and not
12
+ * one of them is above U+FFFF, so the terminal's stack can only reach them
13
+ * through a Nerd Font. Until now the stack named installed families only
14
+ * (`XTERM_GLYPH_FALLBACKS`), which means the glyphs appeared on the machine
15
+ * that had one installed and were hollow tofu boxes on every other device — a
16
+ * phone, a second laptop, a colleague's browser.
17
+ *
18
+ * This face closes that gap without taking the choice away: it is declared LAST
19
+ * in the stack, after every installed Nerd Font family, so a machine that
20
+ * already has one renders from it and never fetches these bytes.
21
+ *
22
+ * `unicode-range` is what makes "never fetches" true rather than aspirational.
23
+ * A face whose range no laid-out glyph falls in is never downloaded, so a page
24
+ * that renders only Latin text (or a desktop where an installed family wins)
25
+ * issues no request at all. Verified: a latin-only document with this
26
+ * stylesheet loaded fetched only `fira-code-latin-400-normal.woff2` and left
27
+ * this face `unloaded`; a document laying out U+E0B0 with an installed family
28
+ * ahead of it fetched nothing.
29
+ *
30
+ * `font-display: block` rather than `swap`, unlike the text faces beside it: a
31
+ * swapped symbol paints a tofu box first and repaints, and in a terminal that
32
+ * box is exactly the artifact this face exists to remove. `mount.ts` awaits the
33
+ * face before opening xterm for the same reason — see `ensureNerdGlyphs`.
34
+ *
35
+ * Generated by `bun run scripts/nerd-symbols-font.mjs` from
36
+ * `SymbolsNerdFontMono-Regular.ttf` (Nerd Fonts v3.5.1). See NOTICE.md in this
37
+ * directory for the icon-set licences.
38
+ */
39
+
40
+ @font-face {
41
+ font-family: 'OMPChamber Nerd Symbols';
42
+ font-style: normal;
43
+ font-weight: 400;
44
+ font-display: block;
45
+ unicode-range:
46
+ U+E000-F8FF,
47
+ U+F0000-FFFFD,
48
+ U+23FB-23FE,
49
+ U+2630,
50
+ U+2665,
51
+ U+26A1,
52
+ U+276C-2771,
53
+ U+2B58;
54
+ src: url('./symbols-nerd-font-mono.woff2') format('woff2');
55
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The terminal's Nerd Font coverage is one contract split across three files,
8
+ * and every way it can break is silent at runtime:
9
+ *
10
+ * - The bundled face's family name is written twice — in the stack
11
+ * (`XTERM_FONT_FAMILY`) and in the `@font-face` (`nerd-symbols.css`). If they
12
+ * drift, the face loads and nothing uses it: prompts render as tofu boxes
13
+ * with no error anywhere.
14
+ * - The face must stay LAST in the stack. Moved ahead of the installed families
15
+ * it would win on every machine, which both changes the glyph shapes a user
16
+ * with a Nerd Font already sees AND makes every desktop download 1.1 MB it
17
+ * never needed. That is a bandwidth regression with no visible symptom on the
18
+ * developer's own laptop.
19
+ * - The font file and its `unicode-range` must agree. A range that names
20
+ * codepoints the subset dropped leaves those glyphs as tofu even though the
21
+ * face is loaded and "working".
22
+ *
23
+ * These assertions are cheap and each one pins a failure this change was made
24
+ * to prevent, so they are permanent rather than throwaway.
25
+ */
26
+
27
+ import { describe, expect, test } from 'bun:test';
28
+ import { readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+
31
+ import {
32
+ NERD_SYMBOLS_FAMILY,
33
+ XTERM_FONT_FAMILY,
34
+ XTERM_GLYPH_FALLBACKS,
35
+ } from '@/client/data/theme/terminal';
36
+
37
+ const FONT_DIR = import.meta.dir;
38
+ const CSS = readFileSync(join(FONT_DIR, 'nerd-symbols.css'), 'utf8');
39
+ const WOFF2 = join(FONT_DIR, 'symbols-nerd-font-mono.woff2');
40
+
41
+ /** Parse `unicode-range: a, b, c;` into codepoint intervals. */
42
+ function declaredRanges(css: string): Array<[number, number]> {
43
+ const block = css.match(/unicode-range:\s*([^;]+);/);
44
+ if (!block) throw new Error('nerd-symbols.css declares no unicode-range');
45
+ return block[1]
46
+ .split(',')
47
+ .map((entry) => entry.trim())
48
+ .filter(Boolean)
49
+ .map((entry) => {
50
+ const [start, end] = entry.split('-');
51
+ const lo = Number.parseInt(start.replace(/^U\+/i, ''), 16);
52
+ const hi = end ? Number.parseInt(end.replace(/^U\+/i, ''), 16) : lo;
53
+ return [lo, hi] as [number, number];
54
+ });
55
+ }
56
+
57
+ describe('bundled Nerd Font symbols face', () => {
58
+ test('the stylesheet declares the family the terminal stack names', () => {
59
+ const declared = CSS.match(/font-family:\s*'([^']+)'/)?.[1];
60
+ expect(declared).toBe(NERD_SYMBOLS_FAMILY);
61
+ });
62
+
63
+ test('the face is LAST in the stack, after every installed family', () => {
64
+ // A local Nerd Font must win, so the bundled face is only reached on a
65
+ // machine that has none — which is also what keeps the desktop from
66
+ // downloading it (its unicode-range is PUA-only, so it is never fetched
67
+ // when another family supplies the glyph).
68
+ expect(XTERM_FONT_FAMILY.indexOf(NERD_SYMBOLS_FAMILY)).toBeGreaterThan(
69
+ XTERM_FONT_FAMILY.indexOf('PowerlineSymbols'),
70
+ );
71
+ // Only the generic fallbacks may follow it.
72
+ const after = XTERM_FONT_FAMILY.slice(XTERM_FONT_FAMILY.indexOf(NERD_SYMBOLS_FAMILY));
73
+ expect(after).not.toMatch(/Nerd Font Mono'/);
74
+ });
75
+
76
+ test('every installed family is still offered before the bundled one', () => {
77
+ for (const family of ['FiraCode Nerd Font Mono', 'JetBrainsMono Nerd Font', 'Symbols Nerd Font Mono']) {
78
+ expect(XTERM_GLYPH_FALLBACKS).toContain(family);
79
+ expect(XTERM_FONT_FAMILY.indexOf(family)).toBeLessThan(
80
+ XTERM_FONT_FAMILY.indexOf(NERD_SYMBOLS_FAMILY),
81
+ );
82
+ }
83
+ });
84
+
85
+ test('the subset actually covers the ranges the stylesheet claims', () => {
86
+ // Reading the cmap needs fonttools, which is not a project dependency. The
87
+ // absence is a legitimate skip; a *failure* of the probe is not, so the two
88
+ // are told apart rather than both being swallowed as "no fonttools".
89
+ const available = Bun.spawnSync([
90
+ 'python3',
91
+ '-c',
92
+ 'import fontTools, brotli',
93
+ ]);
94
+ if (available.exitCode !== 0) {
95
+ console.warn('skipping font coverage check: python3 fonttools/brotli unavailable');
96
+ return;
97
+ }
98
+
99
+ const probe = Bun.spawnSync([
100
+ 'python3',
101
+ '-c',
102
+ `
103
+ import json
104
+ from fontTools.ttLib import TTFont
105
+
106
+ cmap = set(TTFont(${JSON.stringify(WOFF2)}).getBestCmap())
107
+ ranges = ${JSON.stringify(declaredRanges(CSS))}
108
+ uncovered = [r for r in ranges if not any(cp in cmap for cp in range(r[0], r[1] + 1))]
109
+ # The prompt glyphs this face exists for.
110
+ required = [0xE0B0, 0xE0B4, 0xE0B6, 0xE606, 0xE7A8, 0xE718, 0xF015, 0xF03E, 0xF121B, 0xF0001]
111
+ print(json.dumps({ "codepoints": len(cmap), "uncovered": uncovered, "missing": [c for c in required if c not in cmap] }))
112
+ `,
113
+ ]);
114
+
115
+ // fonttools is known to be importable here, so a non-zero exit is a real
116
+ // failure (a corrupt subset, a bad path) and must not read as a skip.
117
+ expect(probe.stderr.toString()).toBe('');
118
+
119
+ const result = JSON.parse(probe.stdout.toString()) as {
120
+ codepoints: number;
121
+ uncovered: Array<[number, number]>;
122
+ missing: number[];
123
+ };
124
+ expect(result.uncovered).toEqual([]);
125
+ expect(result.missing).toEqual([]);
126
+ expect(result.codepoints).toBeGreaterThan(10_000);
127
+ });
128
+
129
+ test('the face file is present and non-trivial', async () => {
130
+ const file = Bun.file(WOFF2);
131
+ expect(await file.exists()).toBe(true);
132
+ expect(file.size).toBeGreaterThan(100_000);
133
+ });
134
+ });
@@ -4,7 +4,7 @@ import { diffSettings } from '@/shared/lib/settings/diff';
4
4
 
5
5
  describe('diffSettings', () => {
6
6
  test('an object patch persists only the keys it actually changed', () => {
7
- const prev = { theme: 'paper', soundAlerts: true, binaryPath: '/bin/omp' };
7
+ const prev = { theme: 'paper', soundAlerts: true, editorFont: 'Fira Code' };
8
8
  const next = { ...prev, theme: 'nord-dark' };
9
9
  expect(diffSettings(prev, next, { theme: 'nord-dark' })).toEqual({ theme: 'nord-dark' });
10
10
  });