@soleone/showcase-terminal 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,9 +20,9 @@ Keep the recipe folder in the project itself (`demo/browser/` by default), so th
20
20
  - `start`: a command typed into the shell once setup is done, to open the program right away (e.g. a TUI). The visitor sees it run and can run it again after quitting.
21
21
  - `onTheme`: a command run whenever the visitor picks a theme. Before it runs, the page writes `~/.config/showcase/theme.json` with the theme's colors and the full terminal palette (`palette`, as hex), so a program that draws its own colors can follow the page. The file is also written once before `setup`.
22
22
  - `welcome`, `hints` (buttons that type a command), `seed` (a browser module that adds files computed at visit time, such as dates relative to today), and overrides for `title`, `pitch`, `repo`, `disclosure`, `defaultTheme` (a name from `themes.json`, default the first).
23
- - `skin`: a folder or URL with the page's look, `themes.json` (palettes, same shape as `page/themes.json`) and `skin.css` (fonts and other touches, loaded after the page's styles). Either may be left out. Without a skin the page is plain: a dark and a light theme and system fonts.
23
+ - `skin`: a folder or URL with the page's look, `themes.json` (palettes, same shape as `page/themes.json`) `skin.css` (fonts and other touches, loaded after the page's styles) and `skin.js` (a theme picker, below). Any of them may be left out. Without a skin the page is plain: a dark and a light theme and system fonts.
24
24
 
25
- The output is static: `index.html`, `files.json`, `terminal.json`, `themes.json`, `skin.css`, `fonts/` and a `vercel.json` with the COOP/COEP headers the sandbox needs.
25
+ The output is static: `index.html`, `files.json`, `terminal.json`, `themes.json`, `skin.css`, `skin.js` when the skin has one, `fonts/` and a `vercel.json` with the COOP/COEP headers the sandbox needs.
26
26
 
27
27
  The terminal font is JetBrainsMono Nerd Font Mono, self-hosted as WOFF2 (about 600 KB per weight) so CLI icons render. `page/fonts/build.sh` rebuilds it from a pinned Nerd Fonts release, keeping the Basic Multilingual Plane and dropping the Material Design plane.
28
28
 
@@ -37,6 +37,18 @@ WebContainers is free for personal and open-source use; check the StackBlitz lic
37
37
 
38
38
  The page has a theme picker when there is more than one theme (the choice is remembered in the visitor's browser). A theme colors the page and the terminal's 16 ANSI colors together, so whatever the CLI prints follows it; the bright colors are derived from the normal ones.
39
39
 
40
+ A skin can bring its own picker as `skin.js`, an ES module whose default export the page calls once the terminal is up, instead of drawing its dropdown:
41
+
42
+ ```js
43
+ export default function ({ themes, current, apply, mount }) {
44
+ // themes: the entries of themes.json; current: the one in use
45
+ // apply(theme): recolors the page and the terminal, tells the sandbox (onTheme) and remembers the choice
46
+ // mount: an empty element in the page header for the picker's button
47
+ }
48
+ ```
49
+
50
+ The page keeps its state where a theme kit can read it: `data-theme` on `<html>` is the theme's name, `data-mode` and the `dark` class its mode. Its own colors are the CSS variables `--page`, `--ink`, `--ink-strong`, `--ink-muted`, `--highlight`, `--surface`, `--line` and `--terminal`.
51
+
40
52
  From code:
41
53
 
42
54
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soleone/showcase-terminal",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Build a static page that runs a CLI for real in the visitor's browser (WebContainers).",
5
5
  "license": "MIT",
6
6
  "author": "Soleone",
package/page/index.html CHANGED
@@ -15,11 +15,18 @@
15
15
  <link rel="preload" href="fonts/JetBrainsMonoNerdFontMono-Bold.woff2" as="font" type="font/woff2" crossorigin>
16
16
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css" crossorigin>
17
17
  <script>
18
- // Puts the visitor's theme on before first paint (themes.js saves the resolved colors when one is picked).
18
+ // Puts the visitor's theme on before first paint (apply() saves the resolved colors when one is picked).
19
+ // data-theme holds the theme's name and .dark its mode, where a skin's theme kit expects them.
20
+ // A choice saved before the variables were renamed waits for themes.json instead.
19
21
  try {
20
22
  const saved = JSON.parse(localStorage.getItem("browser-terminal.theme"));
21
- for (const [name, value] of Object.entries(saved.vars)) document.documentElement.style.setProperty(`--${name}`, value);
22
- document.documentElement.style.colorScheme = document.documentElement.dataset.theme = saved.mode;
23
+ if (saved.vars.highlight) {
24
+ const root = document.documentElement;
25
+ for (const [name, value] of Object.entries(saved.vars)) root.style.setProperty(`--${name}`, value);
26
+ root.style.colorScheme = root.dataset.mode = saved.mode;
27
+ root.dataset.theme = saved.name;
28
+ root.classList.toggle("dark", saved.mode === "dark");
29
+ }
23
30
  } catch {}
24
31
  </script>
25
32
  <style>
@@ -42,37 +49,39 @@
42
49
  --page: #111113;
43
50
  --ink: #d4d4d8;
44
51
  --ink-strong: #fafafa;
45
- --muted: #a1a1aa;
46
- --accent: #60a5fa;
52
+ --ink-muted: #a1a1aa;
53
+ --highlight: #60a5fa;
47
54
  --surface: #1c1c1f;
48
55
  --line: #2e2e33;
49
56
  --terminal: #0b0b0c;
50
57
  }
51
58
  @media (prefers-color-scheme: light) {
52
- :root:not([data-theme="dark"]) {
59
+ :root:not([data-mode="dark"]) {
53
60
  color-scheme: light;
54
61
  --page: #fafafa;
55
62
  --ink: #27272a;
56
63
  --ink-strong: #09090b;
57
- --muted: #52525b;
58
- --accent: #2563eb;
64
+ --ink-muted: #52525b;
65
+ --highlight: #2563eb;
59
66
  --surface: #f4f4f5;
60
67
  --line: #e4e4e7;
61
68
  --terminal: #ffffff;
62
69
  }
63
70
  }
64
- :root[data-theme="light"] {
71
+ :root[data-mode="light"] {
65
72
  color-scheme: light;
66
73
  --page: #fafafa;
67
74
  --ink: #27272a;
68
75
  --ink-strong: #09090b;
69
- --muted: #52525b;
70
- --accent: #2563eb;
76
+ --ink-muted: #52525b;
77
+ --highlight: #2563eb;
71
78
  --surface: #f4f4f5;
72
79
  --line: #e4e4e7;
73
80
  --terminal: #ffffff;
74
81
  }
75
82
  * { box-sizing: border-box; }
83
+ /* An id or class that sets display would otherwise show a hidden element (a skin's picker relies on this too). */
84
+ [hidden] { display: none !important; }
76
85
  html, body { margin: 0; min-height: 100%; }
77
86
  body {
78
87
  background: var(--page);
@@ -85,8 +94,8 @@
85
94
  header, main, footer { width: 100%; max-width: 1120px; margin: 0 auto; padding: 0 16px; }
86
95
  header { padding-top: 32px; display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 8px 24px; }
87
96
  h1 { margin: 0; color: var(--ink-strong); font-size: clamp(28px, 5vw, 44px); font-weight: 600; line-height: 1.05; letter-spacing: -0.03em; }
88
- .pitch { margin: 8px 0 0; color: var(--muted); font-size: 17px; max-width: 62ch; text-wrap: pretty; }
89
- a { color: var(--accent); text-underline-offset: 3px; }
97
+ .pitch { margin: 8px 0 0; color: var(--ink-muted); font-size: 17px; max-width: 62ch; text-wrap: pretty; }
98
+ a { color: var(--highlight); text-underline-offset: 3px; }
90
99
  .tools { display: flex; align-items: center; gap: 16px; position: relative; }
91
100
  .repo { font-size: 15px; white-space: nowrap; }
92
101
  #theme-button {
@@ -95,7 +104,7 @@
95
104
  background: var(--surface); color: var(--ink);
96
105
  border: 1px solid var(--line); border-radius: 6px; padding: 8px 11px; cursor: pointer;
97
106
  }
98
- #theme-button:hover, #theme-button:focus-visible, #theme-button[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); outline: none; }
107
+ #theme-button:hover, #theme-button:focus-visible, #theme-button[aria-expanded="true"] { border-color: var(--highlight); color: var(--highlight); outline: none; }
99
108
  .swatch { display: inline-flex; border-radius: 99px; overflow: hidden; border: 1px solid var(--line); flex: none; }
100
109
  .swatch i { width: 9px; height: 14px; display: block; }
101
110
  #theme-list {
@@ -103,13 +112,12 @@
103
112
  margin: 0; padding: 4px; list-style: none;
104
113
  background: var(--surface); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 12px 32px #0006;
105
114
  }
106
- #theme-list[hidden] { display: none; }
107
115
  #theme-list li { display: flex; align-items: center; gap: 10px; padding: 7px 9px; border-radius: 5px; font-size: 14px; cursor: pointer; }
108
- #theme-list li[aria-selected="true"] { color: var(--accent); }
116
+ #theme-list li[aria-selected="true"] { color: var(--highlight); }
109
117
  #theme-list li.active { background: var(--line); }
110
118
  main { flex: 1; display: flex; flex-direction: column; gap: 12px; padding-top: 24px; padding-bottom: 16px; }
111
119
  .hints { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
112
- .hints span { color: var(--muted); font-size: 14px; margin-right: 4px; }
120
+ .hints span { color: var(--ink-muted); font-size: 14px; margin-right: 4px; }
113
121
  .hints button {
114
122
  font: 13px/1 "JetBrainsMono Nerd Font Mono", ui-monospace, Menlo, Consolas, monospace;
115
123
  background: var(--surface);
@@ -120,7 +128,7 @@
120
128
  cursor: pointer;
121
129
  transition: border-color 120ms, color 120ms;
122
130
  }
123
- .hints button:hover:not(:disabled), .hints button:focus-visible { border-color: var(--accent); color: var(--accent); outline: none; }
131
+ .hints button:hover:not(:disabled), .hints button:focus-visible { border-color: var(--highlight); color: var(--highlight); outline: none; }
124
132
  .hints button:disabled { opacity: 0.45; cursor: progress; }
125
133
  .panel {
126
134
  flex: 1;
@@ -135,7 +143,7 @@
135
143
  #terminal .xterm, #terminal .xterm-viewport, #terminal .xterm-scrollable-element { background-color: var(--terminal) !important; }
136
144
  /* No focus ring inside the terminal; the blinking cursor shows focus. */
137
145
  #terminal .xterm:focus, #terminal .xterm :focus { outline: none; }
138
- footer { padding-bottom: 24px; color: var(--muted); font-size: 13px; }
146
+ footer { padding-bottom: 24px; color: var(--ink-muted); font-size: 13px; }
139
147
  @media (max-width: 600px) {
140
148
  header { padding-top: 20px; }
141
149
  .pitch { font-size: 16px; }
@@ -228,9 +236,12 @@
228
236
  }
229
237
  // Page and terminal change together; xterm maps its palette onto whatever the programs print.
230
238
  function apply(theme, save) {
231
- const vars = { page: theme.page, ink: theme.ink, "ink-strong": theme.inkStrong, muted: theme.muted, accent: theme.accent, surface: theme.surface, line: theme.line, terminal: theme.terminal };
232
- for (const [name, value] of Object.entries(vars)) document.documentElement.style.setProperty(`--${name}`, value);
233
- document.documentElement.style.colorScheme = document.documentElement.dataset.theme = theme.mode;
239
+ const vars = { page: theme.page, ink: theme.ink, "ink-strong": theme.inkStrong, "ink-muted": theme.muted, highlight: theme.accent, surface: theme.surface, line: theme.line, terminal: theme.terminal };
240
+ const root = document.documentElement;
241
+ for (const [name, value] of Object.entries(vars)) root.style.setProperty(`--${name}`, value);
242
+ root.style.colorScheme = root.dataset.mode = theme.mode;
243
+ root.dataset.theme = theme.name;
244
+ root.classList.toggle("dark", theme.mode === "dark");
234
245
  if (term) term.options.theme = terminalColors(theme);
235
246
  shareTheme(theme);
236
247
  if (save) try { localStorage.setItem(storeKey, JSON.stringify({ name: theme.name, mode: theme.mode, vars })); } catch {}
@@ -261,8 +272,16 @@
261
272
  }
262
273
  fit.fit();
263
274
  mark("terminal");
264
- // The theme picker: arrows preview a theme in the page and the terminal, Enter keeps it, Esc puts the old one back.
265
- if (themes.length > 1) {
275
+ // A skin's skin.js draws its own theme picker in place of the built-in one.
276
+ if (config.skinScript && themes.length > 1) {
277
+ const mount = document.createElement("div");
278
+ document.querySelector(".tools").append(mount);
279
+ // Not awaited: the shell does not wait for the picker.
280
+ import(new URL(config.skinScript, location.href))
281
+ .then(({ default: skin }) => skin({ themes, current, mount, apply: (theme) => { current = theme; apply(theme, true); } }))
282
+ .catch((error) => console.warn("skin.js failed", error));
283
+ } else if (themes.length > 1) {
284
+ // The theme picker: arrows preview a theme in the page and the terminal, Enter keeps it, Esc puts the old one back.
266
285
  const button = document.getElementById("theme-button");
267
286
  const list = document.getElementById("theme-list");
268
287
  const swatch = (theme) => [theme.page, theme.accent, theme.ink].map((color) => `<i style="background:${color}"></i>`).join("");
package/src/terminal.js CHANGED
@@ -74,8 +74,9 @@ function disclosure(title, externals = []) {
74
74
  return [`The real ${title} runs in a Node.js sandbox in your browser (WebContainers).`, ...notes, "Reload to start over."].join(" ");
75
75
  }
76
76
 
77
- // A skin is a folder or URL with the page's look: themes.json (the palettes) and skin.css (anything else, such
78
- // as fonts). Either file may be left out; the recipe's plain look fills in.
77
+ // A skin is a folder or URL with the page's look: themes.json (the palettes), skin.css (anything else, such
78
+ // as fonts) and skin.js (a theme picker in place of the built-in one). Any of them may be left out; the
79
+ // recipe's plain look fills in.
79
80
  async function skinFile(skin, name, recipeDir) {
80
81
  if (!skin) return null;
81
82
  if (/^https?:\/\//.test(skin)) {
@@ -111,6 +112,11 @@ export async function buildTerminal(recipeDir, out) {
111
112
  await copyFile(path.join(assets, "index.html"), path.join(out, "index.html"));
112
113
  await writeFile(path.join(out, "themes.json"), await skinFile(skin, "themes.json", recipeDir) ?? await readFile(path.join(assets, "themes.json")));
113
114
  await writeFile(path.join(out, "skin.css"), await skinFile(skin, "skin.css", recipeDir) ?? "");
115
+ const skinScript = await skinFile(skin, "skin.js", recipeDir);
116
+ if (skinScript !== null) {
117
+ await writeFile(path.join(out, "skin.js"), skinScript);
118
+ config.skinScript = "skin.js";
119
+ }
114
120
  await cp(path.join(assets, "fonts"), path.join(out, "fonts"), { recursive: true, filter: (source) => !source.endsWith(".sh") });
115
121
  await writeFile(path.join(out, "files.json"), JSON.stringify(files));
116
122
  if (config.seed) await copyFile(path.join(recipeDir, config.seed), path.join(out, config.seed));