@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 +14 -2
- package/package.json +1 -1
- package/page/index.html +43 -24
- package/src/terminal.js +8 -2
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`)
|
|
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
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 (
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
--
|
|
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-
|
|
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
|
-
--
|
|
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-
|
|
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
|
-
--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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,
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
//
|
|
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)
|
|
78
|
-
// as fonts).
|
|
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));
|